From afaf918973f65e42540fc6084a544a7fc47bcc92 Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:55:46 +0200 Subject: [PATCH] Update public CLI diagnostics dry run --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 513 +++++++++++++++++++++++++++- scripts/cli-first-contract-smoke.js | 18 + 3 files changed, 522 insertions(+), 13 deletions(-) diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 655be2c..a9e8b01 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "143d109ea0261671012be8ef6b48e86284601eec", - "release_name": "dryrun-143d109ea026", + "source_commit": "d7700a764f26f3f6963a12f20395e361c66d2a83", + "release_name": "dryrun-d7700a764f26", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 8c56953..f0d0d7e 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -8,9 +8,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; use disasmer_core::{ - BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, Capability, CliLoginFlow, Digest, - LimitKind, NodeCapabilities, ProjectModel, ResourceLimits, SelectedInput, SourceProviderKind, - SourceProviderManifest, + diagnose_environment_references, BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, + Capability, CliLoginFlow, Digest, EnvironmentReference, LimitKind, NodeCapabilities, + ProjectModel, ResourceLimits, SelectedInput, SourceProviderKind, SourceProviderManifest, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -236,6 +236,10 @@ struct ProjectSelectArgs { struct BundleInspectArgs { #[arg(long)] project: Option, + #[arg(long = "source-provider")] + source_provider: Option, + #[arg(long = "disable-source-provider")] + disabled_source_providers: Vec, #[arg(long)] json: bool, } @@ -244,6 +248,10 @@ struct BundleInspectArgs { struct BuildArgs { #[arg(long)] project: Option, + #[arg(long = "source-provider")] + source_provider: Option, + #[arg(long = "disable-source-provider")] + disabled_source_providers: Vec, #[arg(long)] output: Option, #[arg(long)] @@ -610,9 +618,38 @@ struct BundleInspection { project: PathBuf, default_source_providers: Vec, source_provider_manifest: SourceProviderManifest, + source_provider_statuses: Vec, + environment_diagnostics: Vec, + pre_schedule_diagnostics: Vec, metadata: BundleMetadata, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct SourceProviderStatus { + provider: String, + status: String, + active: bool, + reason: String, + coordinator_checkout_required: bool, + coordinator_receives_source_bytes_by_default: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct EnvironmentDiagnosticReport { + path: String, + reference: EnvironmentReference, + message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CliDiagnostic { + severity: String, + category: String, + code: String, + message: String, + next_actions: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeAttachPlan { node: String, @@ -997,6 +1034,8 @@ fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result let inspection = bundle_inspection( BundleInspectArgs { project: Some(cwd.clone()), + source_provider: None, + disabled_source_providers: Vec::new(), json: true, }, cwd.clone(), @@ -1092,17 +1131,45 @@ fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { let inspection = bundle_inspection( BundleInspectArgs { project: args.project.clone(), + source_provider: args.source_provider.clone(), + disabled_source_providers: args.disabled_source_providers.clone(), json: true, }, cwd, )?; - let report = json!({ + let diagnostics = inspection.pre_schedule_diagnostics.clone(); + let blocking_diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.severity == "error") + .cloned(); + let status = if blocking_diagnostic.is_some() { + "blocked_before_schedule" + } else { + "built" + }; + let mut report = json!({ "command": "build", + "status": status, "bundle": inspection, + "diagnostics": diagnostics, "contains_full_repository_upload": false, "content_addressed": true, "debug_metadata_available": true, + "scheduled_work": false, }); + if let Some(diagnostic) = blocking_diagnostic.as_ref() { + let machine_category = match diagnostic.category.as_str() { + "environment" => "environment", + "source_provider" | "capability" => "capability", + _ => "unknown", + }; + if let Some(report) = report.as_object_mut() { + report.insert( + "machine_error".to_owned(), + cli_error_summary_for_category(machine_category, &diagnostic.message), + ); + } + } if let Some(output) = args.output { if let Some(parent) = output.parent() { std::fs::create_dir_all(parent)?; @@ -1800,6 +1867,31 @@ fn human_report(value: &Value) -> String { if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { lines.push(format!("artifacts: {}", artifacts.len())); } + if let Some(statuses) = value + .get("source_provider_statuses") + .and_then(Value::as_array) + { + push_source_provider_statuses(&mut lines, statuses); + } + if let Some(bundle_statuses) = value + .pointer("/bundle/source_provider_statuses") + .and_then(Value::as_array) + { + push_source_provider_statuses(&mut lines, bundle_statuses); + } + if let Some(diagnostics) = value + .get("pre_schedule_diagnostics") + .or_else(|| value.get("diagnostics")) + .and_then(Value::as_array) + { + push_cli_diagnostics(&mut lines, diagnostics); + } + if let Some(bundle_diagnostics) = value + .pointer("/bundle/pre_schedule_diagnostics") + .and_then(Value::as_array) + { + push_cli_diagnostics(&mut lines, bundle_diagnostics); + } if let Some(current_usage) = value.get("current_usage") { lines.push(format!("quota usage: {}", compact_json(current_usage))); } @@ -2046,6 +2138,45 @@ fn push_machine_error_line(lines: &mut Vec, machine_error: Option<&Value } } +fn push_source_provider_statuses(lines: &mut Vec, statuses: &[Value]) { + let entries = statuses + .iter() + .filter_map(|status| { + let provider = status.get("provider").and_then(Value::as_str)?; + let state = status.get("status").and_then(Value::as_str)?; + let active = status + .get("active") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(format!( + "{provider}={state}{}", + if active { "(active)" } else { "" } + )) + }) + .collect::>(); + if !entries.is_empty() { + lines.push(format!("source providers: {}", entries.join(", "))); + } +} + +fn push_cli_diagnostics(lines: &mut Vec, diagnostics: &[Value]) { + if diagnostics.is_empty() { + return; + } + lines.push(format!("diagnostics: {}", diagnostics.len())); + for diagnostic in diagnostics.iter().take(3) { + let severity = diagnostic + .get("severity") + .and_then(Value::as_str) + .unwrap_or("info"); + let message = diagnostic + .get("message") + .and_then(Value::as_str) + .unwrap_or("diagnostic"); + lines.push(format!("diagnostic {severity}: {message}")); + } +} + fn compact_json(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()) } @@ -3673,20 +3804,35 @@ fn bundle_inspection(args: BundleInspectArgs, cwd: PathBuf) -> Result Result> { Ok(inputs) } -fn source_provider_manifest(project: &Path) -> SourceProviderManifest { - let kind = if project.join(".git").exists() { - SourceProviderKind::Git +#[derive(Clone, Debug, PartialEq, Eq)] +struct SourceProviderSelection { + active_provider: SourceProviderKind, + statuses: Vec, +} + +fn source_provider_selection( + project: &Path, + requested_provider: Option<&str>, + disabled_providers: &[String], +) -> SourceProviderSelection { + let has_git_checkout = project.join(".git").exists(); + let disabled = disabled_providers + .iter() + .map(|provider| provider.trim().to_ascii_lowercase()) + .collect::>(); + let requested = requested_provider.map(source_provider_kind_from_id); + let active_provider = requested.clone().unwrap_or_else(|| { + if has_git_checkout && !disabled.contains("git") { + SourceProviderKind::Git + } else { + SourceProviderKind::Filesystem + } + }); + let active_provider_id = active_provider.provider_id().to_owned(); + let mut statuses = vec![ + builtin_source_provider_status( + SourceProviderKind::Filesystem, + true, + disabled.contains("filesystem"), + active_provider_id == "filesystem", + ), + builtin_source_provider_status( + SourceProviderKind::Git, + has_git_checkout, + disabled.contains("git"), + active_provider_id == "git", + ), + SourceProviderStatus { + provider: "custom".to_owned(), + status: "disabled".to_owned(), + active: false, + reason: "no custom source-provider module was configured for this project".to_owned(), + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + }, + ]; + + if let Some(SourceProviderKind::Custom(provider)) = requested { + statuses.push(SourceProviderStatus { + provider, + status: "unsupported".to_owned(), + active: true, + reason: "custom source-provider modules must be supplied by public plugin/API code before use".to_owned(), + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + }); + } + + SourceProviderSelection { + active_provider, + statuses, + } +} + +fn source_provider_kind_from_id(provider: &str) -> SourceProviderKind { + match provider.trim().to_ascii_lowercase().as_str() { + "filesystem" => SourceProviderKind::Filesystem, + "git" => SourceProviderKind::Git, + other => SourceProviderKind::Custom(other.to_owned()), + } +} + +fn builtin_source_provider_status( + kind: SourceProviderKind, + available: bool, + disabled: bool, + active: bool, +) -> SourceProviderStatus { + let provider = kind.provider_id().to_owned(); + let (status, reason) = if disabled { + ( + "disabled", + format!("source provider `{provider}` was disabled by CLI override"), + ) + } else if available { + ( + "enabled", + format!("source provider `{provider}` is available in this checkout"), + ) } else { - SourceProviderKind::Filesystem + ( + "missing", + format!("source provider `{provider}` is not available for this checkout"), + ) }; + SourceProviderStatus { + provider, + status: status.to_owned(), + active, + reason, + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + } +} + +fn source_provider_manifest(kind: &SourceProviderKind) -> SourceProviderManifest { SourceProviderManifest::local_first( - kind, + kind.clone(), "default source provider manifest; snapshot creation can be scheduled as a node task", ) } +fn environment_diagnostics_for_inputs( + project: &Path, + selected_inputs: &[SelectedInput], + environments: &[disasmer_core::EnvironmentResource], +) -> Result> { + let mut diagnostics = Vec::new(); + for input in selected_inputs { + if !input.path.ends_with(".rs") { + continue; + } + let source = std::fs::read_to_string(project.join(&input.path)) + .with_context(|| format!("failed to read {}", project.join(&input.path).display()))?; + diagnostics.extend( + diagnose_environment_references(&source, environments) + .into_iter() + .map(|diagnostic| EnvironmentDiagnosticReport { + path: input.path.clone(), + reference: diagnostic.reference, + message: diagnostic.message, + }), + ); + } + Ok(diagnostics) +} + +fn pre_schedule_diagnostics( + source_provider_statuses: &[SourceProviderStatus], + environment_diagnostics: &[EnvironmentDiagnosticReport], + environments: &[disasmer_core::EnvironmentResource], +) -> Vec { + let mut diagnostics = Vec::new(); + diagnostics.extend( + environment_diagnostics + .iter() + .map(|diagnostic| CliDiagnostic { + severity: "error".to_owned(), + category: "environment".to_owned(), + code: "missing_environment".to_owned(), + message: format!("{} at {}", diagnostic.message, diagnostic.path), + next_actions: vec![ + "create the missing envs//Containerfile or envs//Dockerfile" + .to_owned(), + "rerun disasmer inspect".to_owned(), + ], + }), + ); + + diagnostics.extend( + source_provider_statuses + .iter() + .filter(|status| { + status.active + && matches!( + status.status.as_str(), + "missing" | "disabled" | "unsupported" + ) + }) + .map(|status| CliDiagnostic { + severity: "error".to_owned(), + category: "source_provider".to_owned(), + code: format!("source_provider_{}", status.status), + message: format!( + "active source provider `{}` is {}: {}", + status.provider, status.status, status.reason + ), + next_actions: vec![ + "choose an available source provider with --source-provider".to_owned(), + "rerun disasmer inspect --json".to_owned(), + ], + }), + ); + + for environment in environments { + if environment.requirements.capabilities.is_empty() { + continue; + } + let mut capabilities = environment + .requirements + .capabilities + .iter() + .map(|capability| format!("{capability:?}")) + .collect::>(); + capabilities.sort(); + diagnostics.push(CliDiagnostic { + severity: "info".to_owned(), + category: "capability".to_owned(), + code: "environment_capability_requirements".to_owned(), + message: format!( + "environment `{}` requires node capabilities: {}", + environment.name, + capabilities.join(", ") + ), + next_actions: vec![ + "attach a node that reports these capabilities before scheduling work".to_owned(), + ], + }); + } + + diagnostics +} + fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest { let mut parts = vec![b"wasm-source-proxy:v1".to_vec()]; for input in selected_inputs { @@ -4975,6 +5323,13 @@ mod tests { assert!(inspection .default_source_providers .contains(&SourceProviderKind::Git)); + assert!(inspection.source_provider_statuses.iter().any(|status| { + status.provider == "filesystem" && status.status == "enabled" && status.active + })); + assert!(inspection + .source_provider_statuses + .iter() + .any(|status| status.provider == "git" && status.status == "missing")); assert!(inspection .source_provider_manifest .description @@ -5002,6 +5357,101 @@ mod tests { .selected_inputs .iter() .any(|input| input.path == "src/main.rs")); + assert!(inspection.environment_diagnostics.is_empty()); + assert!(inspection + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| diagnostic.category == "capability" + && diagnostic.message.contains("environment `linux`"))); + } + + #[test] + fn bundle_inspect_reports_missing_environment_references_before_schedule() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { let _target = env!(\"linux\"); }\n", + ) + .unwrap(); + + let inspection = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(inspection.environment_diagnostics.len(), 1); + assert_eq!(inspection.environment_diagnostics[0].path, "src/main.rs"); + assert_eq!( + inspection.environment_diagnostics[0].reference.name, + "linux" + ); + assert!(inspection.environment_diagnostics[0] + .message + .contains("missing Disasmer environment `linux`")); + assert!(inspection + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.severity == "error" + && diagnostic.category == "environment" + && diagnostic.code == "missing_environment" + })); + } + + #[test] + fn bundle_inspect_reports_source_provider_overrides_before_schedule() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let missing_git = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: Some("git".to_owned()), + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + assert!(missing_git.source_provider_statuses.iter().any(|status| { + status.provider == "git" && status.status == "missing" && status.active + })); + assert!(missing_git + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.category == "source_provider" + && diagnostic.code == "source_provider_missing" + })); + + let unsupported = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: Some("custom-lfs".to_owned()), + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + assert!(unsupported.source_provider_statuses.iter().any(|status| { + status.provider == "custom-lfs" && status.status == "unsupported" && status.active + })); + assert!(unsupported + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.category == "source_provider" + && diagnostic.code == "source_provider_unsupported" + })); } #[test] @@ -5012,6 +5462,8 @@ mod tests { let first = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), json: false, }, PathBuf::from("/unused"), @@ -5025,6 +5477,8 @@ mod tests { let second = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), json: false, }, PathBuf::from("/unused"), @@ -5044,6 +5498,8 @@ mod tests { let first = bundle_inspection( BundleInspectArgs { project: Some(first.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), json: false, }, PathBuf::from("/unused"), @@ -5052,6 +5508,8 @@ mod tests { let second = bundle_inspection( BundleInspectArgs { project: Some(second.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), json: false, }, PathBuf::from("/unused"), @@ -6153,6 +6611,8 @@ mod tests { let report = build_report( BuildArgs { project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), output: None, json: false, }, @@ -6167,6 +6627,37 @@ mod tests { .as_str() .unwrap() .starts_with("sha256:")); + assert_eq!(report["status"], "built"); + assert_eq!(report["scheduled_work"], false); + } + + #[test] + fn build_blocks_before_schedule_on_missing_environment_reference() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { let _target = env!(\"linux\"); }\n", + ) + .unwrap(); + + let report = build_report( + BuildArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + output: None, + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(report["status"], "blocked_before_schedule"); + assert_eq!(report["scheduled_work"], false); + assert_eq!(report["machine_error"]["category"], "environment"); + assert_eq!(report["diagnostics"][0]["code"], "missing_environment"); } #[test] diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index d1486a7..b9a3b2d 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -127,6 +127,9 @@ for (const [name, pattern] of [ ["process control report coverage", /fn process_restart_and_cancel_reports_expose_control_boundaries\(\)/], ["task restart report coverage", /fn task_restart_reports_clean_boundary_requirements\(\)/], ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], + ["inspect missing environment coverage", /fn bundle_inspect_reports_missing_environment_references_before_schedule\(\)/], + ["inspect source provider override coverage", /fn bundle_inspect_reports_source_provider_overrides_before_schedule\(\)/], + ["build missing environment gate coverage", /fn build_blocks_before_schedule_on_missing_environment_reference\(\)/], ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], ]) { expect(cli, name, pattern); @@ -257,6 +260,21 @@ expect( "CLI parses dangerous capability overrides", /"host-filesystem"[\s\S]*Capability::HostFilesystem[\s\S]*"network"[\s\S]*Capability::Network[\s\S]*"secrets"[\s\S]*Capability::Secrets/ ); +expect( + cli, + "CLI exposes source provider diagnostics", + /source_provider_statuses[\s\S]*source_provider_selection[\s\S]*source_provider_unsupported/ +); +expect( + cli, + "CLI exposes environment pre-schedule diagnostics", + /environment_diagnostics_for_inputs[\s\S]*diagnose_environment_references[\s\S]*missing_environment/ +); +expect( + cli, + "CLI blocks build before scheduling unsafe inputs", + /blocked_before_schedule[\s\S]*scheduled_work[\s\S]*false/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],