From 8c0c336ae9ee4619bab1dc8fb2e745d0c4ae3a9a Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:54:07 +0200 Subject: [PATCH] Public dry run dryrun-f1e9b7b784b4 Source commit: f1e9b7b784b4a4dd28799719681535e2a4ef44c9 --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 1405 ++++++++++++++++++- crates/disasmer-dap/src/main.rs | 238 +++- scripts/cli-first-contract-smoke.js | 109 ++ scripts/website-inventory-contract-smoke.js | 93 ++ 5 files changed, 1829 insertions(+), 20 deletions(-) create mode 100644 scripts/cli-first-contract-smoke.js create mode 100644 scripts/website-inventory-contract-smoke.js diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 90d2ac7..cb270cd 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c", - "release_name": "dryrun-1714a9eedd5b", + "source_commit": "f1e9b7b784b4a4dd28799719681535e2a4ef44c9", + "release_name": "dryrun-f1e9b7b784b4", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 5aa0fc1..5a16663 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -5,12 +5,12 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use disasmer_core::{ BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, Capability, CliLoginFlow, Digest, NodeCapabilities, ProjectModel, SelectedInput, SourceProviderKind, SourceProviderManifest, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; const DEFAULT_OPERATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com:9443"; @@ -21,7 +21,7 @@ const BROWSER_CALLBACK_PATH: &str = "/callback"; const DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS: u64 = 300; #[derive(Clone, Debug, Parser)] -#[command(name = "disasmer")] +#[command(name = "disasmer", version, arg_required_else_help = true)] struct Cli { #[command(subcommand)] command: Commands, @@ -29,11 +29,26 @@ struct Cli { #[derive(Clone, Debug, Subcommand)] enum Commands { + Doctor(DoctorArgs), Login(LoginArgs), + Auth { + #[command(subcommand)] + command: AuthCommands, + }, Agent { #[command(subcommand)] command: AgentCommands, }, + Key { + #[command(subcommand)] + command: KeyCommands, + }, + Project { + #[command(subcommand)] + command: ProjectCommands, + }, + Inspect(BundleInspectArgs), + Build(BuildArgs), Bundle { #[command(subcommand)] command: BundleCommands, @@ -43,6 +58,38 @@ enum Commands { #[command(subcommand)] command: NodeCommands, }, + Process { + #[command(subcommand)] + command: ProcessCommands, + }, + Task { + #[command(subcommand)] + command: TaskCommands, + }, + Logs(LogsArgs), + Artifact { + #[command(subcommand)] + command: ArtifactCommands, + }, + Dap(DapArgs), + Debug { + #[command(subcommand)] + command: DebugCommands, + }, + Quota { + #[command(subcommand)] + command: QuotaCommands, + }, + Admin { + #[command(subcommand)] + command: AdminCommands, + }, +} + +#[derive(Clone, Debug, Parser)] +struct DoctorArgs { + #[command(flatten)] + scope: CliScopeArgs, } #[derive(Clone, Debug, Parser)] @@ -71,11 +118,38 @@ struct LoginArgs { state: Option, } +#[derive(Clone, Debug, Subcommand)] +enum AuthCommands { + Status(AuthStatusArgs), + Logout(AuthLogoutArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AuthStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct AuthLogoutArgs { + #[arg(long)] + yes: bool, + #[command(flatten)] + scope: CliScopeArgs, +} + #[derive(Clone, Debug, Subcommand)] enum AgentCommands { Enroll(AgentEnrollArgs), } +#[derive(Clone, Debug, Subcommand)] +enum KeyCommands { + Add(KeyAddArgs), + List(KeyListArgs), + Revoke(KeyRevokeArgs), +} + #[derive(Clone, Debug, Subcommand)] enum BundleCommands { Inspect(BundleInspectArgs), @@ -87,12 +161,86 @@ struct AgentEnrollArgs { public_key: String, } +#[derive(Clone, Debug, Parser)] +struct KeyAddArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "agent")] + agent: String, + #[arg(long = "public-key")] + public_key: String, +} + +#[derive(Clone, Debug, Parser)] +struct KeyListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct KeyRevokeArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "agent")] + agent: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum ProjectCommands { + Init(ProjectInitArgs), + Status(ProjectStatusArgs), + List(ProjectListArgs), + Select(ProjectSelectArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ProjectInitArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "project")] + new_project: String, + #[arg(long, default_value = "Disasmer Project")] + name: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectSelectArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(value_name = "PROJECT")] + selected_project: String, +} + #[derive(Clone, Debug, Parser)] struct BundleInspectArgs { #[arg(long)] project: Option, } +#[derive(Clone, Debug, Parser)] +struct BuildArgs { + #[arg(long)] + project: Option, + #[arg(long)] + output: Option, +} + #[derive(Clone, Debug, Parser)] struct RunArgs { entry: Option, @@ -107,6 +255,10 @@ struct RunArgs { #[derive(Clone, Debug, Subcommand)] enum NodeCommands { Attach(AttachArgs), + Enroll(NodeEnrollArgs), + List(NodeListArgs), + Status(NodeStatusArgs), + Revoke(NodeRevokeArgs), } #[derive(Clone, Debug, Parser)] @@ -127,6 +279,218 @@ struct AttachArgs { public_key: Option, } +#[derive(Clone, Debug, Parser)] +struct NodeEnrollArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "node-enrollment")] + grant: String, + #[arg(long, default_value_t = 900)] + ttl_seconds: u64, +} + +#[derive(Clone, Debug, Parser)] +struct NodeListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct NodeStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + node: Option, +} + +#[derive(Clone, Debug, Parser)] +struct NodeRevokeArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + node: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum ProcessCommands { + Status(ProcessStatusArgs), + Restart(ProcessRestartArgs), + Cancel(ProcessCancelArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ProcessStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessRestartArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessCancelArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long, default_value = "node-local")] + node: String, + #[arg(long, default_value = "compile-linux")] + task: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum TaskCommands { + List(TaskListArgs), +} + +#[derive(Clone, Debug, Parser)] +struct TaskListArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, +} + +#[derive(Clone, Debug, Parser)] +struct LogsArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, + #[arg(long)] + task: Option, +} + +#[derive(Clone, Debug, Subcommand)] +enum ArtifactCommands { + List(ArtifactListArgs), + Download(ArtifactDownloadArgs), + Export(ArtifactExportArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactListArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactDownloadArgs { + #[command(flatten)] + scope: CliScopeArgs, + artifact: String, + #[arg(long, default_value_t = 64 * 1024 * 1024)] + max_bytes: u64, +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactExportArgs { + #[command(flatten)] + scope: CliScopeArgs, + artifact: String, + #[arg(long)] + to: PathBuf, + #[arg(long, default_value = "node-local")] + receiver_node: String, +} + +#[derive(Clone, Debug, Parser)] +struct DapArgs { + #[arg(long)] + plan: bool, + #[arg(last = true)] + args: Vec, +} + +#[derive(Clone, Debug, Subcommand)] +enum DebugCommands { + Attach(DebugAttachArgs), +} + +#[derive(Clone, Debug, Parser)] +struct DebugAttachArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, +} + +#[derive(Clone, Debug, Subcommand)] +enum QuotaCommands { + Status(QuotaStatusArgs), +} + +#[derive(Clone, Debug, Parser)] +struct QuotaStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Subcommand)] +enum AdminCommands { + Status(AdminStatusArgs), + Bootstrap(AdminBootstrapArgs), + RevokeNode(NodeRevokeArgs), + StopProcess(ProcessCancelArgs), + SuspendTenant(AdminSuspendTenantArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AdminStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct AdminBootstrapArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "Disasmer Project")] + name: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct AdminSuspendTenantArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long = "target-tenant")] + target_tenant: Option, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Args)] +struct CliScopeArgs { + #[arg(long)] + coordinator: Option, + #[arg(long, default_value = "tenant")] + tenant: String, + #[arg(long = "project-id", default_value = "project")] + project: String, + #[arg(long, default_value = "user")] + user: String, + #[arg(long)] + json: bool, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct RunPlan { project: PathBuf, @@ -253,9 +617,645 @@ struct NodeEnrollmentPlan { exchanges_short_lived_grant_for_long_lived_node_identity: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct ProjectConfig { + tenant: String, + project: String, + user: String, + coordinator: Option, +} + +fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + Ok(json!({ + "command": "doctor", + "cwd": cwd, + "coordinator": args.scope.coordinator.or_else(|| config.as_ref().and_then(|config| config.coordinator.clone())), + "auth": auth_state_value(), + "project": config, + "dependencies": { + "cargo": command_available("cargo"), + "git": command_available("git"), + "podman": command_available("podman"), + "disasmer-node": command_available("disasmer-node") || sibling_binary("disasmer-node").is_some(), + "disasmer-coordinator": command_available("disasmer-coordinator") || sibling_binary("disasmer-coordinator").is_some(), + "disasmer-debug-dap": command_available("disasmer-debug-dap") || sibling_binary("disasmer-debug-dap").is_some(), + }, + "node_readiness": NodeCapabilities::detect_current(), + "next_actions": [ + "disasmer login --browser", + "disasmer project init", + "disasmer node attach", + "disasmer run" + ] + })) +} + +fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + Ok(json!({ + "command": "auth status", + "active_coordinator": args.scope.coordinator.or_else(|| config.as_ref().and_then(|config| config.coordinator.clone())).unwrap_or_else(default_operator_endpoint), + "principal": args.scope.user, + "tenant": args.scope.tenant, + "project": args.scope.project, + "session": auth_state_value(), + "project_config": config, + })) +} + +fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result { + let session_file = session_config_file(&cwd); + let existed = session_file.exists(); + if existed { + std::fs::remove_file(&session_file) + .with_context(|| format!("failed to remove {}", session_file.display()))?; + } + Ok(json!({ + "command": "auth logout", + "requires_confirmation": !args.yes, + "removed_cli_session_file": existed, + "node_credentials_untouched": true, + "session_file": session_file, + })) +} + +fn key_add_report(args: KeyAddArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "register_agent_public_key", + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "agent": args.agent, + "public_key": args.public_key, + }))?; + return Ok(json!({ + "command": "key add", + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key add", + "status": "planned_without_coordinator", + "agent": args.agent, + "public_key_fingerprint": Digest::sha256(args.public_key), + "browser_interaction_required_each_run": false, + })) +} + +fn key_list_report(args: KeyListArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "list_agent_public_keys", + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + }))?; + return Ok(json!({ + "command": "key list", + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key list", + "status": "requires_coordinator", + "records": [], + })) +} + +fn key_revoke_report(args: KeyRevokeArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "revoke_agent_public_key", + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "agent": args.agent, + }))?; + return Ok(json!({ + "command": "key revoke", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key revoke", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "agent": args.agent, + })) +} + +fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { + let config = ProjectConfig { + tenant: args.scope.tenant.clone(), + project: args.new_project.clone(), + user: args.scope.user.clone(), + coordinator: args.scope.coordinator.clone(), + }; + let config_file = project_config_file(&cwd); + if config_file.exists() && !args.yes { + anyhow::bail!( + "{} already exists; rerun with --yes to update the project link", + config_file.display() + ); + } + write_project_config(&cwd, &config)?; + let coordinator_response = if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + Some(session.request(json!({ + "type": "create_project", + "tenant": args.scope.tenant, + "actor_user": args.scope.user, + "project": args.new_project, + "name": args.name, + }))?) + } else { + None + }; + Ok(json!({ + "command": "project init", + "project_config": config, + "config_file": config_file, + "coordinator_response": coordinator_response, + })) +} + +fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + let inspection = bundle_inspection( + BundleInspectArgs { + project: Some(cwd.clone()), + }, + cwd.clone(), + ) + .ok(); + let coordinator_response = + list_task_events_if_available(args.scope.coordinator.as_deref(), &args.scope, None)?; + Ok(json!({ + "command": "project status", + "cwd": cwd, + "project_config": config, + "bundle": inspection, + "active_process": "vp-current", + "coordinator_response": coordinator_response, + })) +} + +fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "list_projects", + "tenant": args.scope.tenant, + "actor_user": args.scope.user, + }))?; + return Ok(json!({ + "command": "project list", + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "project list", + "source": "local_project_config", + "projects": read_project_config(&cwd)?.into_iter().collect::>(), + })) +} + +fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result { + let config = ProjectConfig { + tenant: args.scope.tenant.clone(), + project: args.selected_project.clone(), + user: args.scope.user.clone(), + coordinator: args.scope.coordinator.clone(), + }; + write_project_config(&cwd, &config)?; + let coordinator_response = if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + Some(session.request(json!({ + "type": "select_project", + "tenant": args.scope.tenant, + "actor_user": args.scope.user, + "project": args.selected_project, + }))?) + } else { + None + }; + Ok(json!({ + "command": "project select", + "project_config": config, + "coordinator_response": coordinator_response, + })) +} + +fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { + let inspection = bundle_inspection( + BundleInspectArgs { + project: args.project.clone(), + }, + cwd, + )?; + let report = json!({ + "command": "build", + "bundle": inspection, + "contains_full_repository_upload": false, + "content_addressed": true, + "debug_metadata_available": true, + }); + if let Some(output) = args.output { + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&output, serde_json::to_vec_pretty(&report)?)?; + } + Ok(report) +} + +fn node_enroll_report(args: NodeEnrollArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "create_node_enrollment_grant", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "grant": args.grant, + "now_epoch_seconds": 0, + "ttl_seconds": args.ttl_seconds, + }))?; + return Ok(json!({ + "command": "node enroll", + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "node enroll", + "status": "planned_without_coordinator", + "grant": args.grant, + "ttl_seconds": args.ttl_seconds, + "scope": "node:attach", + })) +} + +fn node_list_report(args: NodeListArgs) -> Result { + node_descriptors_report("node list", args.scope, None) +} + +fn node_status_report(args: NodeStatusArgs) -> Result { + node_descriptors_report("node status", args.scope, args.node) +} + +fn node_descriptors_report( + command: &str, + scope: CliScopeArgs, + node: Option, +) -> Result { + if let Some(coordinator) = &scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "list_node_descriptors", + "tenant": scope.tenant, + "project": scope.project, + "actor_user": scope.user, + }))?; + return Ok(json!({ + "command": command, + "coordinator": coordinator, + "node": node, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": command, + "status": "local_capability_snapshot", + "node": node.unwrap_or_else(default_node_id), + "capabilities": NodeCapabilities::detect_current(), + })) +} + +fn node_revoke_report(args: NodeRevokeArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "revoke_node_credential", + "node": args.node, + }))?; + return Ok(json!({ + "command": "node revoke", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "node revoke", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "node": args.node, + })) +} + +fn process_status_report(args: ProcessStatusArgs) -> Result { + let events = list_task_events_if_available( + args.scope.coordinator.as_deref(), + &args.scope, + Some(args.process.clone()), + )?; + Ok(json!({ + "command": "process status", + "process": args.process, + "state": if events.is_some() { "inspectable" } else { "unknown_without_coordinator" }, + "events": events, + })) +} + +fn process_restart_report(args: ProcessRestartArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "start_process", + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + }))?; + return Ok(json!({ + "command": "process restart", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "process restart", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "process": args.process, + })) +} + +fn process_cancel_report(args: ProcessCancelArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "cancel_task", + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + "node": args.node, + "task": args.task, + }))?; + return Ok(json!({ + "command": "process cancel", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "process cancel", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "process": args.process, + "node": args.node, + "task": args.task, + })) +} + +fn task_list_report(args: TaskListArgs) -> Result { + let events = list_task_events_if_available( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + )?; + Ok(json!({ + "command": "task list", + "process": args.process, + "events": events, + })) +} + +fn logs_report(args: LogsArgs) -> Result { + let events = list_task_events_if_available( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + )?; + Ok(json!({ + "command": "logs", + "process": args.process, + "task": args.task, + "logs_are_capped": true, + "secret_redaction_policy": "configured-redaction-boundary", + "events": events, + })) +} + +fn artifact_list_report(args: ArtifactListArgs) -> Result { + let events = list_task_events_if_available( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + )?; + Ok(json!({ + "command": "artifact list", + "process": args.process, + "source": "task_events", + "events": events, + })) +} + +fn artifact_download_report(args: ArtifactDownloadArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let nonce = command_nonce("artifact-download"); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "create_artifact_download_link", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "artifact": args.artifact, + "max_bytes": args.max_bytes, + "token_nonce": nonce, + "now_epoch_seconds": 0, + }))?; + return Ok(json!({ + "command": "artifact download", + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "artifact download", + "status": "requires_coordinator", + "artifact": args.artifact, + "max_bytes": args.max_bytes, + })) +} + +fn artifact_export_report(args: ArtifactExportArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "export_artifact_to_node", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "artifact": args.artifact, + "receiver_node": args.receiver_node, + "direct_connectivity": true, + "failure_reason": "", + }))?; + return Ok(json!({ + "command": "artifact export", + "coordinator": coordinator, + "to": args.to, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "artifact export", + "status": "requires_coordinator", + "artifact": args.artifact, + "to": args.to, + "receiver_node": args.receiver_node, + })) +} + +fn dap_plan(args: DapArgs) -> Result { + Ok(json!({ + "command": "dap", + "adapter": dap_binary_path()?.display().to_string(), + "args": args.args, + "private_website_required": false, + })) +} + +fn exec_dap(args: DapArgs) -> Result<()> { + let status = Command::new(dap_binary_path()?) + .args(args.args) + .status() + .context("failed to launch disasmer-debug-dap")?; + if !status.success() { + anyhow::bail!("disasmer-debug-dap exited with {status}"); + } + Ok(()) +} + +fn debug_attach_report(args: DebugAttachArgs) -> Result { + Ok(json!({ + "command": "debug attach", + "process": args.process, + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "dap": dap_binary_path()?.display().to_string(), + "private_website_required": false, + })) +} + +fn quota_status_report(args: QuotaStatusArgs) -> Result { + Ok(json!({ + "command": "quota status", + "tenant": args.scope.tenant, + "project": args.scope.project, + "coordinator": args.scope.coordinator, + "policy_surface": "generic public quota categories; hosted tuning remains private policy", + "limits": { + "artifact_download_bytes": "community-tier policy before dispatch", + "rendezvous_attempts": "community-tier policy before dispatch", + "hosted_wasm": "fuel/memory/wall-clock/state/logs/metadata/ui/api limits before start" + }, + "private_abuse_heuristics_exposed": false, + })) +} + +fn admin_status_report(args: AdminStatusArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ "type": "ping" }))?; + return Ok(json!({ + "command": "admin status", + "coordinator": coordinator, + "response": response, + "safe_default": "read_only", + })); + } + Ok(json!({ + "command": "admin status", + "mode": "self_hosted_local", + "safe_default": "read_only", + "private_website_required": false, + })) +} + +fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> Result { + let new_project = args.scope.project.clone(); + project_init_report( + ProjectInitArgs { + scope: args.scope, + new_project, + name: args.name, + yes: args.yes, + }, + cwd, + ) +} + +fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result { + let tenant = args + .target_tenant + .unwrap_or_else(|| args.scope.tenant.clone()); + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "suspend_tenant", + "tenant": tenant, + }))?; + return Ok(json!({ + "command": "admin suspend-tenant", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "admin suspend-tenant", + "status": "requires_private_hosted_coordinator", + "requires_confirmation": !args.yes, + "tenant": tenant, + })) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Commands::Doctor(args) => { + println!( + "{}", + serde_json::to_string_pretty(&doctor_report(args, std::env::current_dir()?)?)? + ); + } Commands::Login(args) => { if args.complete_browser_code.is_some() { let report = execute_browser_login_completion(args)?; @@ -273,12 +1273,45 @@ fn main() -> Result<()> { println!("{}", serde_json::to_string_pretty(&plan)?); } } + Commands::Auth { command } => { + let report = match command { + AuthCommands::Status(args) => auth_status_report(args, std::env::current_dir()?), + AuthCommands::Logout(args) => auth_logout_report(args, std::env::current_dir()?), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } Commands::Agent { command: AgentCommands::Enroll(args), } => { let plan = agent_enrollment_plan(args); println!("{}", serde_json::to_string_pretty(&plan)?); } + Commands::Key { command } => { + let report = match command { + KeyCommands::Add(args) => key_add_report(args), + KeyCommands::List(args) => key_list_report(args), + KeyCommands::Revoke(args) => key_revoke_report(args), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Commands::Project { command } => { + let cwd = std::env::current_dir()?; + let report = match command { + ProjectCommands::Init(args) => project_init_report(args, cwd), + ProjectCommands::Status(args) => project_status_report(args, cwd), + ProjectCommands::List(args) => project_list_report(args, cwd), + ProjectCommands::Select(args) => project_select_report(args, cwd), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Commands::Inspect(args) => { + let inspection = bundle_inspection(args, std::env::current_dir()?)?; + println!("{}", serde_json::to_string_pretty(&inspection)?); + } + Commands::Build(args) => { + let report = build_report(args, std::env::current_dir()?)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } Commands::Bundle { command: BundleCommands::Inspect(args), } => { @@ -305,10 +1338,208 @@ fn main() -> Result<()> { println!("{}", serde_json::to_string_pretty(&plan)?); } } + Commands::Node { command } => { + let report = match command { + NodeCommands::Enroll(args) => node_enroll_report(args), + NodeCommands::List(args) => node_list_report(args), + NodeCommands::Status(args) => node_status_report(args), + NodeCommands::Revoke(args) => node_revoke_report(args), + NodeCommands::Attach(_) => unreachable!("node attach is handled above"), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Commands::Process { command } => { + let report = match command { + ProcessCommands::Status(args) => process_status_report(args), + ProcessCommands::Restart(args) => process_restart_report(args), + ProcessCommands::Cancel(args) => process_cancel_report(args), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Commands::Task { + command: TaskCommands::List(args), + } => { + println!( + "{}", + serde_json::to_string_pretty(&task_list_report(args)?)? + ); + } + Commands::Logs(args) => { + println!("{}", serde_json::to_string_pretty(&logs_report(args)?)?); + } + Commands::Artifact { command } => { + let report = match command { + ArtifactCommands::List(args) => artifact_list_report(args), + ArtifactCommands::Download(args) => artifact_download_report(args), + ArtifactCommands::Export(args) => artifact_export_report(args), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Commands::Dap(args) => { + if args.plan { + println!("{}", serde_json::to_string_pretty(&dap_plan(args)?)?); + } else { + return exec_dap(args); + } + } + Commands::Debug { + command: DebugCommands::Attach(args), + } => { + println!( + "{}", + serde_json::to_string_pretty(&debug_attach_report(args)?)? + ); + } + Commands::Quota { + command: QuotaCommands::Status(args), + } => { + println!( + "{}", + serde_json::to_string_pretty("a_status_report(args)?)? + ); + } + Commands::Admin { command } => { + let report = match command { + AdminCommands::Status(args) => admin_status_report(args), + AdminCommands::Bootstrap(args) => { + admin_bootstrap_report(args, std::env::current_dir()?) + } + AdminCommands::RevokeNode(args) => node_revoke_report(args), + AdminCommands::StopProcess(args) => process_cancel_report(args), + AdminCommands::SuspendTenant(args) => admin_suspend_tenant_report(args), + }?; + println!("{}", serde_json::to_string_pretty(&report)?); + } } Ok(()) } +fn project_config_file(project: &Path) -> PathBuf { + project.join(".disasmer").join("project.json") +} + +fn session_config_file(project: &Path) -> PathBuf { + project.join(".disasmer").join("session.json") +} + +fn read_project_config(project: &Path) -> Result> { + let file = project_config_file(project); + if !file.exists() { + return Ok(None); + } + let bytes = + std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let config = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", file.display()))?; + Ok(Some(config)) +} + +fn write_project_config(project: &Path, config: &ProjectConfig) -> Result<()> { + let file = project_config_file(project); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(&file, serde_json::to_vec_pretty(config)?) + .with_context(|| format!("failed to write {}", file.display())) +} + +fn auth_state_value() -> Value { + match session_from_env() { + CliSession::Anonymous => json!({ + "kind": "anonymous", + "authenticated": false, + "source": "environment", + }), + CliSession::HumanSession => json!({ + "kind": "human", + "authenticated": true, + "source": "DISASMER_TOKEN", + "provider_tokens_exposed_to_nodes": false, + }), + CliSession::AgentPublicKey { + public_key_fingerprint, + browser_interaction_required, + } => json!({ + "kind": "agent_public_key", + "authenticated": true, + "source": "DISASMER_AGENT_PUBLIC_KEY", + "public_key_fingerprint": public_key_fingerprint, + "browser_interaction_required": browser_interaction_required, + }), + } +} + +fn command_available(command: &str) -> bool { + Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +fn sibling_binary(name: &str) -> Option { + let mut sibling = std::env::current_exe().ok()?; + sibling.set_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + sibling.is_file().then_some(sibling) +} + +fn dap_binary_path() -> Result { + if let Some(path) = std::env::var_os("DISASMER_DAP_BIN") { + return Ok(PathBuf::from(path)); + } + if let Some(path) = sibling_binary("disasmer-debug-dap") { + return Ok(path); + } + let release = PathBuf::from("target/release").join(format!( + "disasmer-debug-dap{}", + std::env::consts::EXE_SUFFIX + )); + if release.is_file() { + return Ok(release); + } + let debug = PathBuf::from("target/debug").join(format!( + "disasmer-debug-dap{}", + std::env::consts::EXE_SUFFIX + )); + if debug.is_file() { + return Ok(debug); + } + anyhow::bail!("could not locate disasmer-debug-dap; set DISASMER_DAP_BIN") +} + +fn command_nonce(prefix: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{prefix}-{now}-{}", std::process::id()) +} + +fn list_task_events_if_available( + coordinator: Option<&str>, + scope: &CliScopeArgs, + process: Option, +) -> Result> { + let Some(coordinator) = coordinator else { + return Ok(None); + }; + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "list_task_events", + "tenant": scope.tenant, + "project": scope.project, + "actor_user": scope.user, + "process": process, + }))?; + Ok(Some(json!({ + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + }))) +} + fn login_plan(args: LoginArgs) -> LoginPlan { login_plan_with_nonce(args, login_nonce()) } @@ -1239,12 +2470,21 @@ fn parse_capability(cap: &str) -> Option { mod tests { use std::fs; + use clap::CommandFactory; + use super::*; fn parse(args: &[&str]) -> Cli { Cli::parse_from(args) } + #[test] + fn top_level_version_is_available() { + let command = Cli::command(); + assert_eq!(command.get_name(), "disasmer"); + assert!(command.get_version().is_some()); + } + #[test] fn run_defaults_to_current_project_and_build_entry() { let Cli { @@ -1628,4 +2868,163 @@ mod tests { ); assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999"); } + + #[test] + fn cli_first_mvp_command_surface_parses() { + for args in [ + &["disasmer", "doctor"][..], + &["disasmer", "auth", "status"], + &["disasmer", "auth", "logout", "--yes"], + &[ + "disasmer", + "key", + "add", + "--agent", + "agent", + "--public-key", + "key", + ], + &["disasmer", "key", "list"], + &["disasmer", "key", "revoke", "--agent", "agent", "--yes"], + &["disasmer", "project", "init", "--yes"], + &["disasmer", "project", "status"], + &["disasmer", "project", "list"], + &["disasmer", "project", "select", "project"], + &["disasmer", "inspect"], + &["disasmer", "build"], + &["disasmer", "node", "enroll"], + &["disasmer", "node", "list"], + &["disasmer", "node", "status"], + &["disasmer", "node", "revoke", "--node", "node", "--yes"], + &["disasmer", "process", "status"], + &["disasmer", "process", "restart", "--yes"], + &["disasmer", "process", "cancel", "--yes"], + &["disasmer", "task", "list"], + &["disasmer", "logs"], + &["disasmer", "artifact", "list"], + &["disasmer", "artifact", "download", "artifact"], + &[ + "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", + ], + &["disasmer", "dap", "--plan"], + &["disasmer", "debug", "attach"], + &["disasmer", "quota", "status"], + &["disasmer", "admin", "status"], + &["disasmer", "admin", "bootstrap", "--yes"], + &[ + "disasmer", + "admin", + "revoke-node", + "--node", + "node", + "--yes", + ], + &["disasmer", "admin", "stop-process", "--yes"], + &["disasmer", "admin", "suspend-tenant", "--yes"], + ] { + let _ = parse(args); + } + } + + #[test] + fn project_init_select_and_status_use_local_project_config() { + let temp = tempfile::tempdir().unwrap(); + + let init = project_init_report( + ProjectInitArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "ignored".to_owned(), + user: "user".to_owned(), + json: false, + }, + new_project: "project-a".to_owned(), + name: "Project A".to_owned(), + yes: true, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(init["command"], "project init"); + + let config = read_project_config(temp.path()).unwrap().unwrap(); + assert_eq!(config.project, "project-a"); + assert_eq!(config.tenant, "tenant"); + + let selected = project_select_report( + ProjectSelectArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "ignored".to_owned(), + user: "user".to_owned(), + json: false, + }, + selected_project: "project-b".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(selected["command"], "project select"); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-b" + ); + } + + #[test] + fn build_command_reuses_bundle_inspection_without_full_repo_upload() { + 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() {}\n").unwrap(); + + let report = build_report( + BuildArgs { + project: Some(temp.path().to_path_buf()), + output: None, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(report["command"], "build"); + assert_eq!(report["content_addressed"], true); + assert_eq!(report["contains_full_repository_upload"], false); + assert!(report["bundle"]["metadata"]["identity"] + .as_str() + .unwrap() + .starts_with("sha256:")); + } + + #[test] + fn node_enroll_and_process_commands_have_safe_plan_without_coordinator() { + let scope = CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + let enroll = node_enroll_report(NodeEnrollArgs { + scope: scope.clone(), + grant: "grant".to_owned(), + ttl_seconds: 60, + }) + .unwrap(); + assert_eq!(enroll["status"], "planned_without_coordinator"); + assert_eq!(enroll["scope"], "node:attach"); + + let cancel = process_cancel_report(ProcessCancelArgs { + scope, + process: "vp".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + yes: false, + }) + .unwrap(); + assert_eq!(cancel["status"], "requires_coordinator"); + assert_eq!(cancel["requires_confirmation"], true); + } } diff --git a/crates/disasmer-dap/src/main.rs b/crates/disasmer-dap/src/main.rs index 3f6ac32..59bc838 100644 --- a/crates/disasmer-dap/src/main.rs +++ b/crates/disasmer-dap/src/main.rs @@ -305,7 +305,7 @@ fn launch_threads(entry: &str) -> BTreeMap { thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12), thread(LINUX_THREAD, "compile-linux", "compile linux", 42), thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52), - thread(PACKAGE_THREAD, "package", "package artifacts", 64), + thread(PACKAGE_THREAD, "package-release", "package artifacts", 64), ] .into_iter() .map(|thread| (thread.id, thread)) @@ -1428,6 +1428,7 @@ fn parse_let_binding_name(line: &str) -> Option { #[derive(Clone, Debug, PartialEq, Eq)] enum SourceLocalRuntimeValue { + SourceSnapshot(String), TaskHandle { task: TaskId, thread_id: i64, @@ -1441,6 +1442,9 @@ enum SourceLocalRuntimeValue { impl SourceLocalRuntimeValue { fn display(&self, _state: &AdapterState) -> String { match self { + SourceLocalRuntimeValue::SourceSnapshot(digest) => { + format!("SourceSnapshot {{ digest = \"{digest}\" }}") + } SourceLocalRuntimeValue::TaskHandle { task, thread_id, @@ -1462,7 +1466,18 @@ fn infer_disasmer_source_local_value( statement: &str, runtime_values: &BTreeMap, ) -> Option { - if let Some(task_function) = extract_call_argument(statement, "disasmer::spawn::task(") { + if statement.contains("prepare_source_snapshot()") { + return Some(SourceLocalRuntimeValue::SourceSnapshot( + source_snapshot_digest_from_project(state).unwrap_or_else(|| { + format!( + "source://local-checkout/{}", + project_snapshot_suffix(&state.project) + ) + }), + )); + } + + if let Some(task_function) = extract_spawn_task_function(statement) { let task_name = extract_string_argument(statement, ".name("); let spawned_thread = thread_for_spawn_statement(state, &task_function, task_name.as_deref())?; @@ -1498,12 +1513,83 @@ fn infer_disasmer_source_local_value( None } -fn extract_call_argument(statement: &str, marker: &str) -> Option { +fn extract_spawn_task_function(statement: &str) -> Option { + if let Some(args) = extract_call_arguments(statement, "disasmer::spawn::task(") { + return args.first().cloned(); + } + let args = extract_call_arguments(statement, "disasmer::spawn::task_with_arg(")?; + args.get(1).cloned() +} + +fn source_snapshot_digest_from_project(state: &AdapterState) -> Option { + let source = + fs::read_to_string(resolve_source_path(&state.project, &state.source_path)).ok()?; + let digest_marker = source.find("digest:")?; + let after_digest = &source[digest_marker..]; + let first_quote = after_digest.find('"')? + 1; + let rest = &after_digest[first_quote..]; + let end_quote = rest.find('"')?; + Some(rest[..end_quote].to_owned()) +} + +fn extract_call_arguments(statement: &str, marker: &str) -> Option> { let start = statement.find(marker)? + marker.len(); let rest = &statement[start..]; - let end = rest.find(')')?; - let value = rest[..end].trim(); - (!value.is_empty()).then_some(value.to_owned()) + let mut args = Vec::new(); + let mut current = String::new(); + let mut depth = 0_i32; + let mut in_string = false; + let mut escaped = false; + + for ch in rest.chars() { + if in_string { + current.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => { + in_string = true; + current.push(ch); + } + '(' | '[' | '{' => { + depth += 1; + current.push(ch); + } + ')' => { + if depth == 0 { + let value = current.trim(); + if !value.is_empty() { + args.push(value.to_owned()); + } + return (!args.is_empty()).then_some(args); + } + depth -= 1; + current.push(ch); + } + ']' | '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + let value = current.trim(); + if !value.is_empty() { + args.push(value.to_owned()); + } + current.clear(); + } + _ => current.push(ch), + } + } + + None } fn extract_string_argument(statement: &str, marker: &str) -> Option { @@ -1518,9 +1604,15 @@ fn extract_env_name(statement: &str) -> Option { if statement.contains("windows_env") || statement.contains("env!(\"windows\")") { return Some("windows".to_owned()); } + if statement.contains("linux_command_env") || statement.contains("env!(\"linux-command\")") { + return Some("linux-command".to_owned()); + } if statement.contains("linux_env") || statement.contains("env!(\"linux\")") { return Some("linux".to_owned()); } + if statement.contains("coordinator_env") || statement.contains("env!(\"coordinator\")") { + return Some("coordinator".to_owned()); + } None } @@ -1552,25 +1644,27 @@ fn thread_for_spawn_statement<'a>( } } let normalized = task_function.replace('_', "-"); - state - .threads - .values() - .find(|thread| thread.task.as_str() == normalized) + state.threads.values().find(|thread| { + thread.task.as_str() == normalized + || (thread.id == PACKAGE_THREAD && normalized == "package-release") + }) } fn task_environment(thread: &VirtualThread) -> &'static str { match thread.id { WINDOWS_THREAD => "windows", MAIN_THREAD => "coordinator", + PACKAGE_THREAD => "coordinator", _ => "linux", } } fn task_arguments_value(thread: &VirtualThread) -> String { - if thread.task.as_str() == "task_add_one" { - "[input: i32]".to_owned() - } else { - "[]".to_owned() + match thread.task.as_str() { + "compile-linux" => "[source: SourceSnapshot]".to_owned(), + "package-release" => "[inputs: Vec]".to_owned(), + "task_add_one" => "[input: i32]".to_owned(), + _ => "[]".to_owned(), } } @@ -2224,6 +2318,7 @@ fn read_message(reader: &mut R) -> Result> { #[cfg(test)] mod tests { + use std::fs; use std::io::Cursor; use serde_json::json; @@ -2349,7 +2444,7 @@ mod tests { let error = freeze_all(&mut state, LINUX_THREAD, None).unwrap_err(); assert_eq!(error.thread_id, PACKAGE_THREAD); - assert_eq!(error.task, TaskId::from("package")); + assert_eq!(error.task, TaskId::from("package-release")); assert!(error.message().contains("could not freeze")); assert_eq!(state.epoch, 0); assert!(state @@ -2541,6 +2636,119 @@ mod tests { })); } + #[test] + fn source_locals_infer_task_with_arg_values_from_runtime_state() { + let project = + std::env::temp_dir().join(format!("disasmer-dap-task-with-arg-{}", std::process::id())); + let src = project.join("src"); + fs::create_dir_all(&src).unwrap(); + fs::write( + src.join("main.rs"), + r#"use disasmer::{Artifact, EnvRef, SourceSnapshot}; + +async fn build_release() -> Result<(), disasmer::TaskArgError> { + let source = prepare_source_snapshot(); + + let compile = disasmer::spawn::task_with_arg(source.clone(), compile_linux) + .name("compile linux") + .env(linux_command_env()) + .start() + .await?; + + let compile_thread = compile.virtual_thread_id(); + let linux_artifact = compile.join().await; + + let package = disasmer::spawn::task_with_arg(vec![linux_artifact.clone()], package_release) + .name("package release") + .env(coordinator_env()) + .start() + .await?; + + let package_thread = package.virtual_thread_id(); + let release_artifact = package.join().await; + Ok(()) +} + +fn prepare_source_snapshot() -> SourceSnapshot { + SourceSnapshot { + digest: "source://coordinator/quick-test-checkout".to_owned(), + } +} + +fn linux_command_env() -> EnvRef { + disasmer::env!("linux-command") +} + +fn coordinator_env() -> EnvRef { + disasmer::env!("coordinator") +} + +fn compile_linux(source: SourceSnapshot) -> Artifact { + Artifact { id: source.digest } +} + +fn package_release(inputs: Vec) -> Artifact { + inputs.into_iter().next().unwrap() +} +"#, + ) + .unwrap(); + + let mut state = AdapterState::default(); + state.project = project.to_string_lossy().into_owned(); + state.source_path = "src/main.rs".to_owned(); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = 23; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "source" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("source://coordinator/quick-test-checkout")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "compile" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("compile-linux") + && value.contains("virtual_thread_id = 2") + && value.contains("linux-command") + }) + })); + assert!(locals + .iter() + .any(|variable| variable["name"] == "compile_thread" && variable["value"] == "2")); + assert!(locals.iter().any(|variable| { + variable["name"] == "linux_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "package" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("package-release") + && value.contains("virtual_thread_id = 4") + && value.contains("coordinator") + }) + })); + assert!(locals + .iter() + .any(|variable| variable["name"] == "package_thread" && variable["value"] == "4")); + assert!(locals.iter().any(|variable| { + variable["name"] == "release_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + + let _ = fs::remove_dir_all(project); + } + #[test] fn wasm_frame_locals_expose_wasmtime_runtime_values() { let mut state = AdapterState::default(); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js new file mode 100644 index 0000000..3a7f45f --- /dev/null +++ b/scripts/cli-first-contract-smoke.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + const fullPath = path.join(repo, relativePath); + if (!fs.existsSync(fullPath)) { + if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { + console.log( + `CLI-first contract smoke skipped: ${relativePath} is filtered from this public tree` + ); + process.exit(0); + } + throw new Error(`${relativePath} is missing`); + } + return fs.readFileSync(fullPath, "utf8"); +} + +function criterionLines(source) { + return source + .split(/\r?\n/) + .filter((line) => /^- \[[ x]\] \*\*/.test(line)); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing CLI-first contract evidence: ${name}`); +} + +const criteria = read("cli_acceptance_criteria.md"); +const cli = read("crates/disasmer-cli/src/main.rs"); + +expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); +expect( + criteria, + "addendum status", + /\*\*Status:\*\* CLI-first addendum to `acceptance_criteria\.md` and `acceptance_criteria_phase2\.md`/ +); +expect( + criteria, + "website exception", + /hosted account creation as the only intentional private website exception/ +); +expect( + criteria, + "no duplicate work note", + /does not automatically mean new product code, a new feature, or even actual implementation work is required/ +); +expect( + criteria, + "future hosted business non-goal", + /billing, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/ +); + +const lines = criterionLines(criteria); +assert(lines.length > 0, "CLI-first criteria must contain criteria lines"); +for (const line of lines) { + assert.match( + line, + /^- \[[ x]\] \*\*(Passed|Partial|Open)(?: \([^)]+\))?:\*\*/, + `CLI-first criterion lacks an explicit status prefix: ${line}` + ); +} + +const openCriteria = lines.filter((line) => /\*\*Open(?::| \()/.test(line)); +assert.deepStrictEqual( + openCriteria, + [ + "- [ ] **Open:** Every criterion in this document is validated end-to-end or with integration-style coverage, preferably against a realistic NixOS deployment that can pretend to be live hosted infrastructure.", + ], + "only the full CLI-first verification gate should remain Open while command/API facts are Partial" +); + +for (const [name, pattern] of [ + ["top-level version metadata", /#\[command\(name = "disasmer", version, arg_required_else_help = true\)\]/], + ["doctor command", /Doctor\(DoctorArgs\)/], + ["auth status command", /enum AuthCommands[\s\S]*Status\(AuthStatusArgs\)/], + ["auth logout command", /enum AuthCommands[\s\S]*Logout\(AuthLogoutArgs\)/], + ["key lifecycle commands", /enum KeyCommands[\s\S]*Add\(KeyAddArgs\)[\s\S]*List\(KeyListArgs\)[\s\S]*Revoke\(KeyRevokeArgs\)/], + ["project commands", /enum ProjectCommands[\s\S]*Init\(ProjectInitArgs\)[\s\S]*Status\(ProjectStatusArgs\)[\s\S]*List\(ProjectListArgs\)[\s\S]*Select\(ProjectSelectArgs\)/], + ["inspect command", /Inspect\(BundleInspectArgs\)/], + ["build command", /Build\(BuildArgs\)/], + ["node lifecycle commands", /enum NodeCommands[\s\S]*Attach\(AttachArgs\)[\s\S]*Enroll\(NodeEnrollArgs\)[\s\S]*List\(NodeListArgs\)[\s\S]*Status\(NodeStatusArgs\)[\s\S]*Revoke\(NodeRevokeArgs\)/], + ["process commands", /enum ProcessCommands[\s\S]*Status\(ProcessStatusArgs\)[\s\S]*Restart\(ProcessRestartArgs\)[\s\S]*Cancel\(ProcessCancelArgs\)/], + ["task list command", /enum TaskCommands[\s\S]*List\(TaskListArgs\)/], + ["logs command", /Logs\(LogsArgs\)/], + ["artifact commands", /enum ArtifactCommands[\s\S]*List\(ArtifactListArgs\)[\s\S]*Download\(ArtifactDownloadArgs\)[\s\S]*Export\(ArtifactExportArgs\)/], + ["DAP command", /Dap\(DapArgs\)/], + ["debug attach command", /enum DebugCommands[\s\S]*Attach\(DebugAttachArgs\)/], + ["quota command", /enum QuotaCommands[\s\S]*Status\(QuotaStatusArgs\)/], + ["admin commands", /enum AdminCommands[\s\S]*Status\(AdminStatusArgs\)[\s\S]*Bootstrap\(AdminBootstrapArgs\)[\s\S]*RevokeNode\(NodeRevokeArgs\)[\s\S]*StopProcess\(ProcessCancelArgs\)[\s\S]*SuspendTenant\(AdminSuspendTenantArgs\)/], +]) { + expect(cli, name, pattern); +} + +for (const [name, pattern] of [ + ["CLI parse coverage", /fn cli_first_mvp_command_surface_parses\(\)/], + ["CLI version coverage", /fn top_level_version_is_available\(\)/], + ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], + ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], + ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], +]) { + expect(cli, name, pattern); +} + +console.log("CLI-first contract smoke passed"); diff --git a/scripts/website-inventory-contract-smoke.js b/scripts/website-inventory-contract-smoke.js new file mode 100644 index 0000000..eb96be2 --- /dev/null +++ b/scripts/website-inventory-contract-smoke.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const inventoryPath = path.join(repo, "website_mvp_inventory.md"); +if (!fs.existsSync(inventoryPath)) { + if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { + console.log( + "Website inventory contract smoke skipped: website_mvp_inventory.md is filtered from this public tree" + ); + process.exit(0); + } + throw new Error("website_mvp_inventory.md is missing"); +} +const source = fs.readFileSync(inventoryPath, "utf8"); + +function expect(name, pattern) { + assert.match(source, pattern, `missing website inventory evidence: ${name}`); +} + +function criterionLines() { + return source + .split(/\r?\n/) + .filter((line) => /^- \[[ x]\] \*\*/.test(line)); +} + +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`/ +); +expect( + "hosted signup exception", + /except hosted Authentik account creation/ +); +expect( + "no duplicate work note", + /does not automatically mean new product code, a new feature, or even actual implementation work is required/ +); +expect( + "barebones no CSS", + /barebones functional HTML with no CSS/ +); +expect( + "future hosted business scope", + /Billing, checkout, hosted support tooling, a full hosted admin console, broad moderation workflows/ +); +expect( + "billing is not MVP website work", + /Do not build billing\/upgrade flows into the minimal website for this MVP/ +); +assert.doesNotMatch(source, /community-tier/, "use `community tier`, not `community-tier`"); + +const lines = criterionLines(); +assert(lines.length > 0, "website inventory must contain status-prefixed checklist items"); +for (const line of lines) { + assert.match( + line, + /^- \[[ x]\] \*\*(Passed|Partial|Open)(?: \([^)]+\))?:\*\*/, + `website inventory item lacks an explicit status prefix: ${line}` + ); +} + +for (const [name, pattern] of [ + ["CLI login command", /`disasmer login \[--browser\] \[--coordinator \]`/], + ["CLI auth status command", /`disasmer auth status`/], + ["CLI key command", /`disasmer key add --public-key `/], + ["CLI project init command", /`disasmer project init`/], + ["CLI node enroll command", /`disasmer node enroll --coordinator --ttl-seconds `/], + ["CLI process status command", /`disasmer process status`/], + ["CLI task list command", /`disasmer task list`/], + ["CLI DAP command", /`disasmer dap`/], + ["CLI quota command", /`disasmer quota status`/], + ["CLI admin status command", /`disasmer admin status`/], +]) { + expect(name, pattern); +} + +for (const [name, pattern] of [ + ["website default project is open", /- \[ \] \*\*Open:\*\* land in their default project\./], + ["website node attach is open", /- \[ \] \*\*Open:\*\* copy a node attach command\./], + ["website process view is open", /- \[ \] \*\*Open:\*\* see the single current virtual process\./], + ["website logs are open", /- \[ \] \*\*Open:\*\* see recent bounded logs\./], + ["website artifact download is open", /- \[ \] \*\*Open:\*\* securely download an available best-effort retained artifact\./], + ["website keys are partial through CLI", /- \[ \] \*\*Partial:\*\* manage\/revoke public keys or know the CLI command to do so\./], +]) { + expect(name, pattern); +} + +console.log("Website inventory contract smoke passed");