diff --git a/docs/WORKSPACE_API.md b/docs/WORKSPACE_API.md index 35a9738..c9446b3 100644 --- a/docs/WORKSPACE_API.md +++ b/docs/WORKSPACE_API.md @@ -197,6 +197,140 @@ Opens an interactive PTY shell session via WebSocket. --- +## Debug Endpoints (Template Development) + +These endpoints help debug init script issues when developing workspace templates. + +### Get Debug Info + +``` +GET /api/workspaces/:id/debug +``` + +Returns detailed information about the container state, useful for understanding why an init script might be failing. + +**Response**: +```json +{ + "id": "uuid", + "name": "minecraft", + "status": "error", + "path": "/root/.openagent/containers/minecraft", + "path_exists": true, + "size_bytes": 1234567890, + "directories": [ + {"path": "bin", "exists": true, "file_count": 156}, + {"path": "usr", "exists": true, "file_count": 12}, + {"path": "etc", "exists": true, "file_count": 45}, + {"path": "var", "exists": true, "file_count": 8}, + {"path": "var/log", "exists": true, "file_count": 3}, + {"path": "root", "exists": true, "file_count": 2}, + {"path": "tmp", "exists": true, "file_count": 0} + ], + "has_bash": true, + "init_script_exists": false, + "init_script_modified": null, + "distro": "ubuntu-noble", + "last_error": "Init script failed: E: Unable to correct problems..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `path_exists` | boolean | Whether the container directory exists | +| `size_bytes` | number | Total size of container in bytes | +| `directories` | array | Key directories and their file counts | +| `has_bash` | boolean | Whether `/bin/bash` is available | +| `init_script_exists` | boolean | Whether the init script file exists | +| `init_script_modified` | string | Last modification time of init script | +| `last_error` | string | Error message from last build attempt | + +### Get Init Script Log + +``` +GET /api/workspaces/:id/init-log +``` + +Reads the init script log from `/var/log/openagent-init.log` inside the container. + +**Response**: +```json +{ + "exists": true, + "content": "Starting init script\nRunning apt-get update...\n...", + "total_lines": 1234, + "log_path": "/var/log/openagent-init.log" +} +``` + +**Note**: Returns the last 500 lines if the log is larger. + +### Re-run Init Script + +``` +POST /api/workspaces/:id/rerun-init +``` + +Re-runs the init script without rebuilding the container from scratch. This is much faster for iterating on init script development. + +**Requirements**: +- Workspace must be `chroot` type +- Container must already exist (debootstrap completed) +- Workspace must have an init script configured + +**Response**: +```json +{ + "success": false, + "exit_code": 1, + "stdout": "Starting init script\nRunning apt-get update...\n...", + "stderr": "", + "duration_secs": 45.3 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Whether the script completed successfully | +| `exit_code` | number | Exit code from the script | +| `stdout` | string | Standard output from the script | +| `stderr` | string | Standard error from the script | +| `duration_secs` | number | How long the script took to run | + +### Debug Workflow Example + +```bash +# 1. Check current container state +curl "http://localhost:3000/api/workspaces/{id}/debug" \ + -H "Authorization: Bearer " + +# 2. View the init script log +curl "http://localhost:3000/api/workspaces/{id}/init-log" \ + -H "Authorization: Bearer " + +# 3. Update the template with a fix +curl -X PUT "http://localhost:3000/api/library/workspace-template/my-template" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"init_script": "#!/bin/bash\n# fixed script..."}' + +# 4. Update workspace to use new init script +curl -X PUT "http://localhost:3000/api/workspaces/{id}" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"init_script": "#!/bin/bash\n# fixed script..."}' + +# 5. Re-run the init script (fast iteration) +curl -X POST "http://localhost:3000/api/workspaces/{id}/rerun-init" \ + -H "Authorization: Bearer " + +# 6. If it works, do a full rebuild to verify +curl -X POST "http://localhost:3000/api/workspaces/{id}/build?rebuild=true" \ + -H "Authorization: Bearer " +``` + +--- + ## Workspace Templates Templates are stored in the library and define reusable workspace configurations. diff --git a/src/api/workspaces.rs b/src/api/workspaces.rs index 977d8ca..79bf706 100644 --- a/src/api/workspaces.rs +++ b/src/api/workspaces.rs @@ -33,6 +33,10 @@ pub fn routes() -> Router> { .route("/:id/build", post(build_workspace)) .route("/:id/sync", post(sync_workspace)) .route("/:id/exec", post(exec_workspace_command)) + // Debug endpoints for template development + .route("/:id/debug", get(get_workspace_debug)) + .route("/:id/rerun-init", post(rerun_init_script)) + .route("/:id/init-log", get(get_init_log)) } // ───────────────────────────────────────────────────────────────────────────── @@ -771,6 +775,74 @@ pub struct ExecCommandResponse { pub timed_out: bool, } +// ───────────────────────────────────────────────────────────────────────────── +// Debug Types (for template development) +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct WorkspaceDebugInfo { + /// Workspace ID + pub id: Uuid, + /// Workspace name + pub name: String, + /// Current status + pub status: WorkspaceStatus, + /// Container/workspace path + pub path: String, + /// Whether the container directory exists + pub path_exists: bool, + /// Size of the container in bytes (if applicable) + pub size_bytes: Option, + /// Key directories that exist in the container + pub directories: Vec, + /// Whether bash is available + pub has_bash: bool, + /// Whether the init script file exists in the container + pub init_script_exists: bool, + /// Last modification time of init script + pub init_script_modified: Option, + /// Distro information + pub distro: Option, + /// Any error message from last build + pub last_error: Option, +} + +#[derive(Debug, Serialize)] +pub struct DirectoryInfo { + /// Directory path + pub path: String, + /// Whether it exists + pub exists: bool, + /// Approximate file count (if exists) + pub file_count: Option, +} + +#[derive(Debug, Serialize)] +pub struct InitLogResponse { + /// Whether the log file exists + pub exists: bool, + /// Log content (last N lines) + pub content: String, + /// Total lines in log + pub total_lines: Option, + /// Log file path inside container + pub log_path: String, +} + +#[derive(Debug, Serialize)] +pub struct RerunInitResponse { + /// Whether the rerun was successful + pub success: bool, + /// Exit code from the script + pub exit_code: i32, + /// Standard output from the script + pub stdout: String, + /// Standard error from the script + pub stderr: String, + /// Execution time in seconds + pub duration_secs: f64, +} + /// POST /api/workspaces/:id/exec - Execute a command in a workspace. async fn exec_workspace_command( State(state): State>, @@ -953,6 +1025,285 @@ async fn exec_workspace_command( } } +// ───────────────────────────────────────────────────────────────────────────── +// Debug Endpoints (for template development) +// ───────────────────────────────────────────────────────────────────────────── + +/// GET /api/workspaces/:id/debug - Get debug information about a workspace container. +/// +/// Returns detailed information about the container state useful for debugging +/// init script issues: directory structure, file existence, sizes, etc. +async fn get_workspace_debug( + State(state): State>, + AxumPath(id): AxumPath, +) -> Result, (StatusCode, String)> { + let workspace = state + .workspaces + .get(id) + .await + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?; + + let path = &workspace.path; + let path_exists = path.exists(); + + // Calculate container size (only for chroot workspaces) + let size_bytes = if workspace.workspace_type == WorkspaceType::Chroot && path_exists { + // Use du command for quick size calculation + let output = tokio::process::Command::new("du") + .args(["-sb", &path.to_string_lossy()]) + .output() + .await + .ok(); + + output.and_then(|o| { + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.split_whitespace().next()?.parse::().ok() + }) + } else { + None + }; + + // Check key directories + let key_dirs = ["bin", "usr", "etc", "var", "var/log", "root", "tmp"]; + let directories: Vec = key_dirs + .iter() + .map(|dir| { + let full_path = path.join(dir); + let exists = full_path.exists() && full_path.is_dir(); + let file_count = if exists { + std::fs::read_dir(&full_path) + .map(|entries| entries.count() as u32) + .ok() + } else { + None + }; + DirectoryInfo { + path: dir.to_string(), + exists, + file_count, + } + }) + .collect(); + + // Check for bash + let has_bash = path.join("bin/bash").exists() || path.join("usr/bin/bash").exists(); + + // Check for init script + let init_script_path = path.join("openagent-init.sh"); + let init_script_exists = init_script_path.exists(); + let init_script_modified = if init_script_exists { + std::fs::metadata(&init_script_path) + .ok() + .and_then(|m| m.modified().ok()) + .map(|t| { + chrono::DateTime::::from(t) + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string() + }) + } else { + None + }; + + Ok(Json(WorkspaceDebugInfo { + id: workspace.id, + name: workspace.name.clone(), + status: workspace.status.clone(), + path: path.to_string_lossy().to_string(), + path_exists, + size_bytes, + directories, + has_bash, + init_script_exists, + init_script_modified, + distro: workspace.distro.clone(), + last_error: workspace.error_message.clone(), + })) +} + +/// GET /api/workspaces/:id/init-log - Get the init script log from inside the container. +/// +/// Reads /var/log/openagent-init.log from inside the container to show what +/// the init script has logged. Useful for debugging template issues. +async fn get_init_log( + State(state): State>, + AxumPath(id): AxumPath, +) -> Result, (StatusCode, String)> { + let workspace = state + .workspaces + .get(id) + .await + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?; + + let log_path = "/var/log/openagent-init.log"; + let host_log_path = workspace.path.join("var/log/openagent-init.log"); + + if !host_log_path.exists() { + return Ok(Json(InitLogResponse { + exists: false, + content: String::new(), + total_lines: None, + log_path: log_path.to_string(), + })); + } + + // Read the log file + let content = tokio::fs::read_to_string(&host_log_path) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read log file: {}", e), + ) + })?; + + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len() as u32; + + // Return last 500 lines max + let start = if lines.len() > 500 { lines.len() - 500 } else { 0 }; + let truncated_content = lines[start..].join("\n"); + + Ok(Json(InitLogResponse { + exists: true, + content: truncated_content, + total_lines: Some(total_lines), + log_path: log_path.to_string(), + })) +} + +/// POST /api/workspaces/:id/rerun-init - Re-run the init script without rebuilding the container. +/// +/// This allows template developers to iterate on their init script without +/// waiting for a full container rebuild. The container must already exist. +async fn rerun_init_script( + State(state): State>, + AxumPath(id): AxumPath, +) -> Result, (StatusCode, String)> { + let mut workspace = state + .workspaces + .get(id) + .await + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?; + + // Only works for container workspaces + if workspace.workspace_type != WorkspaceType::Chroot { + return Err(( + StatusCode::BAD_REQUEST, + "Rerun init only works for container workspaces".to_string(), + )); + } + + // Container must exist (at least have basic structure) + if !workspace.path.join("bin").exists() { + return Err(( + StatusCode::BAD_REQUEST, + "Container doesn't exist yet. Build it first.".to_string(), + )); + } + + // Must have an init script configured + let init_script = workspace.init_script.clone().unwrap_or_default(); + if init_script.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "No init script configured for this workspace".to_string(), + )); + } + + // Write the init script to the container + let script_path = workspace.path.join("openagent-init.sh"); + tokio::fs::write(&script_path, &init_script) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write init script: {}", e), + ) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + tokio::fs::set_permissions(&script_path, perms) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set script permissions: {}", e), + ) + })?; + } + + // Update status to building + workspace.status = WorkspaceStatus::Building; + workspace.error_message = None; + state.workspaces.update(workspace.clone()).await; + + // Run the init script + let start_time = std::time::Instant::now(); + + let shell = if workspace.path.join("bin/bash").exists() { + "/bin/bash" + } else { + "/bin/sh" + }; + + let mut config = crate::nspawn::NspawnConfig::default(); + config.env = workspace.env_vars.clone(); + + let command = vec![shell.to_string(), "/openagent-init.sh".to_string()]; + let output = crate::nspawn::execute_in_container(&workspace.path, &command, &config) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to execute init script: {}", e), + ) + })?; + + let duration_secs = start_time.elapsed().as_secs_f64(); + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let success = output.status.success(); + + // Update workspace status + if success { + workspace.status = WorkspaceStatus::Ready; + workspace.error_message = None; + } else { + workspace.status = WorkspaceStatus::Error; + let mut error_msg = String::new(); + if !stderr.trim().is_empty() { + error_msg.push_str(stderr.trim()); + } + if !stdout.trim().is_empty() { + if !error_msg.is_empty() { + error_msg.push_str(" | "); + } + error_msg.push_str(stdout.trim()); + } + if error_msg.is_empty() { + error_msg = format!("Init script failed with exit code {}", exit_code); + } + workspace.error_message = Some(format!("Init script failed: {}", error_msg)); + } + + // Clean up the script file + let _ = tokio::fs::remove_file(&script_path).await; + + state.workspaces.update(workspace).await; + + Ok(Json(RerunInitResponse { + success, + exit_code, + stdout, + stderr, + duration_secs, + })) +} + #[cfg(test)] mod tests { use super::*;