From f2fead7ebd7dba0d79684704cf10250eefd577d6 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 3 Jul 2026 02:27:42 +0900 Subject: [PATCH] feat: add workspace runtime and worker controls --- crates/workspace-server/src/config.rs | 28 +- crates/workspace-server/src/hosts.rs | 3 + crates/workspace-server/src/server.rs | 798 +++++++++++++++++- web/workspace/src/app.css | 153 ++++ .../workspace-settings/SettingsPage.svelte | 325 ++++++- .../src/lib/workspace-settings/model.test.ts | 31 +- .../src/lib/workspace-settings/model.ts | 80 +- .../WorkersNavSection.svelte | 142 +++- .../src/lib/workspace-sidebar/types.ts | 31 + web/workspace/tsconfig.json | 1 + 10 files changed, 1550 insertions(+), 42 deletions(-) diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 28d20f20..5ba59434 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::{fs, io}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; @@ -16,7 +16,7 @@ const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_MAX_RECORDS: usize = 200; -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendConfigFile { #[serde(default)] @@ -29,7 +29,7 @@ pub struct WorkspaceBackendConfigFile { pub runtimes: WorkspaceBackendRuntimesConfig, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendServerConfig { #[serde(default)] @@ -40,7 +40,7 @@ pub struct WorkspaceBackendServerConfig { pub static_assets_dir: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendDataConfig { #[serde(default)] @@ -51,21 +51,21 @@ pub struct WorkspaceBackendDataConfig { pub embedded_runtime_store_root: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendLimitsConfig { #[serde(default)] pub max_records: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { #[serde(default)] pub remote: Vec, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RemoteRuntimeConfigFile { pub id: String, @@ -189,6 +189,20 @@ impl WorkspaceBackendConfigFile { } } + pub fn write_for_workspace(&self, workspace_root: impl AsRef) -> Result<()> { + let path = Self::path_for_workspace(workspace_root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self).map_err(|error| { + Error::Config(format!( + "failed to serialize workspace backend config: {error}" + )) + })?; + fs::write(path, raw)?; + Ok(()) + } + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { toml::from_str(raw).map_err(|error| { Error::Config(format!( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c18515fd..103a7bc0 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -266,6 +266,7 @@ pub struct WorkerSpawnRequest { pub enum WorkerSpawnIntent { WorkspaceCompanion, WorkspaceOrchestrator, + WorkspaceCoding, TicketRole { ticket_id: String, role: TicketWorkerRole, @@ -2260,6 +2261,7 @@ fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector { ProfileSelector::Builtin("builtin:companion".to_string()) } WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault, + WorkerSpawnIntent::WorkspaceCoding => ProfileSelector::Builtin("builtin:coder".to_string()), } } @@ -2680,6 +2682,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str { match intent { WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion", WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator", + WorkerSpawnIntent::WorkspaceCoding => "workspace_coding", WorkerSpawnIntent::TicketRole { role, .. } => match role { TicketWorkerRole::Intake => "ticket_intake", TicketWorkerRole::Orchestrator => "ticket_orchestrator", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cec76ffb..5600ff1c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,8 +6,9 @@ use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::CONTENT_TYPE; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; +use chrono::{SecondsFormat, Utc}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; @@ -17,11 +18,13 @@ use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, }; +use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile}; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, - WorkerLifecycleResult, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, + WorkerLifecycleResult, WorkerOperationState, WorkerSpawnAcceptanceRequirement, + WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection, }; use crate::identity::WorkspaceIdentity; @@ -35,8 +38,14 @@ use crate::records::{ use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; -use worker_runtime::catalog::ConfigBundleRef; +use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector}; use worker_runtime::config_bundle::ConfigBundle; +use worker_runtime::http_server::RuntimeHttpSummaryResponse; +use worker_runtime::interaction::{ + WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind, +}; + +const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthConfig { @@ -222,7 +231,30 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route("/api/hosts", get(list_hosts)) .route("/api/runtimes", get(list_runtimes)) - .route("/api/workers", get(list_workers)) + .route( + "/api/workers", + get(list_workers).post(create_workspace_worker), + ) + .route( + "/api/workers/launch-options", + get(get_worker_launch_options), + ) + .route( + "/api/settings/runtime-connections", + get(get_runtime_connection_settings), + ) + .route( + "/api/settings/runtime-connections/remotes", + post(add_remote_runtime_connection), + ) + .route( + "/api/settings/runtime-connections/remotes/{runtime_id}", + delete(delete_remote_runtime_connection), + ) + .route( + "/api/settings/runtime-connections/remotes/{runtime_id}/test", + post(test_remote_runtime_connection), + ) .route("/api/companion/status", get(get_companion_status)) .route("/api/companion/transcript", get(get_companion_transcript)) .route("/api/companion/messages", post(post_companion_message)) @@ -321,6 +353,108 @@ pub struct RuntimeListResponse { pub diagnostics: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionSettingsResponse { + pub workspace_id: String, + pub embedded: RuntimeConnectionSummary, + pub remotes: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionSummary { + pub runtime_id: String, + pub display_name: String, + pub kind: String, + pub built_in: bool, + pub config_managed: bool, + pub active: bool, + pub can_spawn_worker: bool, + pub restart_required: bool, + pub status: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RemoteRuntimeConnectionSummary { + #[serde(flatten)] + pub summary: RuntimeConnectionSummary, + pub endpoint_configured: bool, + pub token_ref_configured: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RuntimeConnectionMutationResponse { + pub workspace_id: String, + pub restart_required: bool, + pub remotes: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AddRemoteRuntimeConnectionRequest { + pub runtime_id: String, + pub display_name: Option, + pub endpoint: String, + pub token_ref: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RemoteRuntimeTestResponse { + pub workspace_id: String, + pub runtime_id: String, + pub checked_at: String, + pub state: String, + pub protocol_version: Option, + pub compatibility_basis: String, + pub capabilities: Vec, + pub health_result: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkerLaunchOptionsResponse { + pub workspace_id: String, + pub runtimes: Vec, + pub profiles: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkerLaunchRuntimeOption { + pub runtime_id: String, + pub display_name: String, + pub built_in: bool, + pub can_spawn_worker: bool, + pub status: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerLaunchProfileCandidate { + pub id: String, + pub label: String, + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct BrowserCreateWorkerRequest { + pub runtime_id: String, + pub display_name: String, + pub profile: String, + pub initial_text: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BrowserCreateWorkerResponse { + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub console_href: String, + pub worker: WorkerSummary, + pub diagnostics: Vec, +} + #[derive(Debug, Serialize, Deserialize)] pub struct RepositoryListResponse { pub workspace_id: String, @@ -599,6 +733,194 @@ async fn list_workers( workers_response(api).map(Json) } +async fn get_runtime_connection_settings( + State(api): State, +) -> ApiResult> { + let local_config = load_workspace_backend_config_for_settings(&api)?; + Ok(Json(runtime_connection_settings_response( + &api, + &local_config, + ))) +} + +async fn add_remote_runtime_connection( + State(api): State, + Json(request): Json, +) -> ApiResult> { + validate_runtime_connection_request(&request)?; + let mut local_config = load_workspace_backend_config_for_settings(&api)?; + let id = request.runtime_id.trim().to_string(); + if id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_not_config_managed", + "the embedded Runtime is built in and cannot be managed from local remote Runtime config", + )); + } + if request + .token_ref + .as_ref() + .is_some_and(|value| !value.trim().is_empty()) + { + return Err(settings_bad_request( + "remote_runtime_token_ref_unsupported", + "remote Runtime token_ref persistence is not supported by this v0 browser settings surface", + )); + } + if local_config + .runtimes + .remote + .iter() + .any(|remote| remote.id == id) + { + return Err(settings_bad_request( + "remote_runtime_already_exists", + "a remote Runtime connection with that id is already configured", + )); + } + local_config.runtimes.remote.push(RemoteRuntimeConfigFile { + id, + endpoint: request.endpoint.trim().to_string(), + display_name: request + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + token_ref: None, + }); + write_workspace_backend_config_for_settings(&api, &local_config)?; + let mut response = runtime_connection_mutation_response(&api, &local_config); + response.diagnostics.push(settings_diagnostic( + "workspace_backend_config_rewritten", + DiagnosticSeverity::Info, + "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + )); + Ok(Json(response)) +} + +async fn delete_remote_runtime_connection( + State(api): State, + AxumPath(runtime_id): AxumPath, +) -> ApiResult> { + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_not_config_managed", + "the embedded Runtime is built in and cannot be deleted from remote Runtime config", + )); + } + let mut local_config = load_workspace_backend_config_for_settings(&api)?; + let before = local_config.runtimes.remote.len(); + local_config + .runtimes + .remote + .retain(|remote| remote.id != runtime_id); + if before == local_config.runtimes.remote.len() { + return Err(Error::UnknownRuntime(runtime_id).into()); + } + write_workspace_backend_config_for_settings(&api, &local_config)?; + let mut response = runtime_connection_mutation_response(&api, &local_config); + response.diagnostics.push(settings_diagnostic( + "workspace_backend_config_rewritten", + DiagnosticSeverity::Info, + "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + )); + Ok(Json(response)) +} + +async fn test_remote_runtime_connection( + State(api): State, + AxumPath(runtime_id): AxumPath, +) -> ApiResult> { + let local_config = load_workspace_backend_config_for_settings(&api)?; + let remote = local_config + .runtimes + .remote + .iter() + .find(|remote| remote.id == runtime_id) + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + Ok(Json(test_remote_runtime_config(&api, remote).await)) +} + +async fn get_worker_launch_options( + State(api): State, +) -> ApiResult> { + Ok(Json(worker_launch_options_response(&api))) +} + +async fn create_workspace_worker( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { + settings_bad_request( + "unsupported_worker_profile", + "profile must be selected from Backend-published worker profile candidates", + ) + })?; + let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| { + settings_bad_request( + "invalid_worker_display_name", + "display_name must contain at least one non-control character", + ) + })?; + let initial_text = request.initial_text.trim().to_string(); + let initial_input = if initial_text.is_empty() { + None + } else { + Some(EmbeddedWorkerInput { + kind: EmbeddedWorkerInputKind::User, + content: initial_text, + }) + }; + let result = api + .runtime + .spawn_worker( + &request.runtime_id, + WorkerSpawnRequest { + requested_worker_name: Some(display_name), + intent: WorkerSpawnIntent::WorkspaceCoding, + acceptance: if initial_input.is_some() { + WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 1, + } + } else { + WorkerSpawnAcceptanceRequirement::SocketReady + }, + profile: Some(profile_selector), + initial_input, + }, + ) + .map_err(|err| err.into_error())?; + if result.state != WorkerOperationState::Accepted { + return Err(Error::RuntimeOperationFailed { + runtime_id: request.runtime_id.clone(), + code: "workspace_worker_create_failed".to_string(), + message: "Runtime did not complete worker creation".to_string(), + } + .into()); + } + let worker = result.worker.ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: request.runtime_id.clone(), + code: "workspace_worker_create_missing_summary".to_string(), + message: "Runtime completed worker creation without returning a Worker summary".to_string(), + })?; + let runtime_id = worker.runtime_id.clone(); + let worker_id = worker.worker_id.clone(); + let console_href = format!( + "/runtimes/{}/workers/{}/console", + encode_path_segment(&runtime_id), + encode_path_segment(&worker_id) + ); + Ok(Json(BrowserCreateWorkerResponse { + workspace_id: api.config.workspace_id, + runtime_id, + worker_id, + console_href, + worker, + diagnostics: result.diagnostics, + })) +} + async fn get_companion_status( State(api): State, ) -> ApiResult> { @@ -914,6 +1236,424 @@ fn workers_response(api: WorkspaceApi) -> ApiResult ApiResult { + WorkspaceBackendConfigFile::load_for_workspace(&api.config.workspace_root).map_err(|error| { + Error::Config(format!( + "failed to read workspace backend local config for Runtime connections: {}", + sanitize_backend_error(&error.to_string()) + )) + .into() + }) +} + +fn write_workspace_backend_config_for_settings( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> ApiResult<()> { + local_config + .write_for_workspace(&api.config.workspace_root) + .map_err(|error| { + Error::Config(format!( + "failed to write workspace backend local config for Runtime connections: {}", + sanitize_backend_error(&error.to_string()) + )) + .into() + }) +} + +fn runtime_connection_settings_response( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> RuntimeConnectionSettingsResponse { + RuntimeConnectionSettingsResponse { + workspace_id: api.config.workspace_id.clone(), + embedded: embedded_runtime_connection_summary(api), + remotes: remote_runtime_connection_summaries(api, local_config, false), + diagnostics: Vec::new(), + } +} + +fn runtime_connection_mutation_response( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, +) -> RuntimeConnectionMutationResponse { + RuntimeConnectionMutationResponse { + workspace_id: api.config.workspace_id.clone(), + restart_required: true, + remotes: remote_runtime_connection_summaries(api, local_config, true), + diagnostics: vec![settings_diagnostic( + "runtime_registry_restart_required", + DiagnosticSeverity::Warning, + "Runtime connection config changed; restart the Workspace backend for the live Runtime registry to use the new config.", + )], + } +} + +fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionSummary { + let active = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items + .into_iter() + .find(|runtime| runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID); + match active { + Some(runtime) => RuntimeConnectionSummary { + runtime_id: runtime.runtime_id, + display_name: runtime.label, + kind: runtime.kind, + built_in: true, + config_managed: false, + active: runtime.status == "active", + can_spawn_worker: runtime.capabilities.can_spawn_worker, + restart_required: false, + status: runtime.status, + diagnostics: runtime.diagnostics, + }, + None => RuntimeConnectionSummary { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Embedded Runtime".to_string(), + kind: "embedded_worker_runtime".to_string(), + built_in: true, + config_managed: false, + active: false, + can_spawn_worker: false, + restart_required: false, + status: "unavailable".to_string(), + diagnostics: vec![settings_diagnostic( + "embedded_runtime_unavailable", + DiagnosticSeverity::Warning, + "The built-in embedded Runtime is not active in the current Runtime registry projection.", + )], + }, + } +} + +fn remote_runtime_connection_summaries( + api: &WorkspaceApi, + local_config: &WorkspaceBackendConfigFile, + restart_required: bool, +) -> Vec { + let live_runtimes = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items; + local_config + .runtimes + .remote + .iter() + .map(|remote| { + let live = live_runtimes + .iter() + .find(|runtime| runtime.runtime_id == remote.id); + let (display_name, kind, active, can_spawn_worker, status, diagnostics) = match live { + Some(runtime) => ( + runtime.label.clone(), + runtime.kind.clone(), + runtime.status == "active", + runtime.capabilities.can_spawn_worker, + runtime.status.clone(), + runtime.diagnostics.clone(), + ), + None => ( + remote + .display_name + .clone() + .unwrap_or_else(|| remote.id.clone()), + "remote_http".to_string(), + false, + false, + "configured_restart_required".to_string(), + if restart_required { + vec![settings_diagnostic( + "runtime_registry_restart_required", + DiagnosticSeverity::Warning, + "This remote Runtime config is persisted but not active until the Workspace backend restarts.", + )] + } else { + Vec::new() + }, + ), + }; + RemoteRuntimeConnectionSummary { + summary: RuntimeConnectionSummary { + runtime_id: remote.id.clone(), + display_name, + kind, + built_in: false, + config_managed: true, + active, + can_spawn_worker, + restart_required, + status, + diagnostics, + }, + endpoint_configured: !remote.endpoint.trim().is_empty(), + token_ref_configured: remote + .token_ref + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + } + }) + .collect() +} + +fn validate_runtime_connection_request( + request: &AddRemoteRuntimeConnectionRequest, +) -> ApiResult<()> { + validate_public_runtime_id(request.runtime_id.trim())?; + let endpoint = request.endpoint.trim(); + if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) + { + return Err(settings_bad_request( + "invalid_remote_runtime_endpoint", + "endpoint must be an absolute http or https URL", + )); + } + if request + .display_name + .as_deref() + .is_some_and(|value| value.chars().any(char::is_control)) + { + return Err(settings_bad_request( + "invalid_remote_runtime_display_name", + "display_name cannot contain control characters", + )); + } + Ok(()) +} + +fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { + if runtime_id.is_empty() { + return Err(settings_bad_request( + "invalid_runtime_id", + "runtime_id must not be empty", + )); + } + if runtime_id.len() > 96 + || !runtime_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return Err(settings_bad_request( + "invalid_runtime_id", + "runtime_id may contain only ASCII letters, digits, '-', '_' and '.' and must be at most 96 characters", + )); + } + Ok(()) +} + +async fn test_remote_runtime_config( + api: &WorkspaceApi, + remote: &RemoteRuntimeConfigFile, +) -> RemoteRuntimeTestResponse { + let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + if remote + .token_ref + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + return RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: "rejected".to_string(), + protocol_version: None, + compatibility_basis: "not_checked_token_ref_unsupported".to_string(), + capabilities: Vec::new(), + health_result: "not_checked".to_string(), + diagnostics: vec![settings_diagnostic( + "remote_runtime_token_ref_unsupported", + DiagnosticSeverity::Error, + "Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.", + )], + }; + } + let url = format!("{}/v1/runtime", remote.endpoint.trim_end_matches('/')); + let result = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .and_then(|client| client.get(url).build().map(|request| (client, request))); + let Ok((client, request)) = result else { + return remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_invalid_request", + "failed to build a remote Runtime test request", + ); + }; + match client.execute(request).await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(summary) => RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: "compatible".to_string(), + protocol_version: None, + compatibility_basis: "worker-runtime /v1/runtime summary endpoint responded successfully; no protocol_version field was advertised".to_string(), + capabilities: Vec::new(), + health_result: format!("status={:?}", summary.runtime.status), + diagnostics: Vec::new(), + }, + Err(_) => remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_malformed_summary", + "remote Runtime test endpoint responded, but the summary payload was not recognized", + ), + } + } + Ok(response) => remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_unhealthy", + format!( + "remote Runtime test endpoint returned HTTP status {}", + response.status().as_u16() + ), + ), + Err(error) => remote_runtime_test_failed( + api, + remote, + checked_at, + "remote_runtime_test_failed", + sanitize_backend_error(&error.to_string()), + ), + } +} + +fn remote_runtime_test_failed( + api: &WorkspaceApi, + remote: &RemoteRuntimeConfigFile, + checked_at: String, + code: impl Into, + message: impl Into, +) -> RemoteRuntimeTestResponse { + RemoteRuntimeTestResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: remote.id.clone(), + checked_at, + state: "failed".to_string(), + protocol_version: None, + compatibility_basis: "worker-runtime /v1/runtime summary endpoint test".to_string(), + capabilities: Vec::new(), + health_result: "failed".to_string(), + diagnostics: vec![settings_diagnostic( + code, + DiagnosticSeverity::Error, + message, + )], + } +} + +fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse { + let runtimes = api + .runtime + .list_runtimes(api.config.max_records.min(200)) + .items + .into_iter() + .map(|runtime| { + let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; + WorkerLaunchRuntimeOption { + runtime_id: runtime.runtime_id, + display_name: runtime.label, + built_in, + can_spawn_worker: runtime.capabilities.can_spawn_worker, + status: runtime.status, + diagnostics: runtime.diagnostics, + } + }) + .collect(); + WorkerLaunchOptionsResponse { + workspace_id: api.config.workspace_id.clone(), + runtimes, + profiles: worker_profile_candidates(), + diagnostics: Vec::new(), + } +} + +fn worker_profile_candidates() -> Vec { + vec![ + WorkerLaunchProfileCandidate { + id: "builtin:coder".to_string(), + label: "Coding Worker".to_string(), + description: "Built-in coding role profile for implementation work.".to_string(), + }, + WorkerLaunchProfileCandidate { + id: "runtime_default".to_string(), + label: "Runtime default".to_string(), + description: "Use the selected Runtime's default profile.".to_string(), + }, + ] +} + +fn profile_selector_for_candidate(profile: &str) -> Option { + match profile { + "builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())), + "runtime_default" => Some(ProfileSelector::RuntimeDefault), + _ => None, + } +} + +fn sanitize_worker_display_name(value: &str) -> Option { + let display_name = value.trim(); + if display_name.is_empty() || display_name.chars().any(char::is_control) { + None + } else { + Some(display_name.chars().take(80).collect()) + } +} + +fn encode_path_segment(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.to_string(), + message: message.to_string(), + } + .into() +} + +fn settings_diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.into(), + severity, + message: message.into(), + } +} + +fn sanitize_backend_error(message: &str) -> String { + let workspace_paths = ["/home/", "/Users/", "\\\\", ":\\"]; + if workspace_paths + .iter() + .any(|needle| message.contains(needle)) + { + "operation failed; backend-private path details were omitted".to_string() + } else { + message.to_string() + } +} + fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result { let canonical_repository_id = api.local_repository_id(); if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) { @@ -1086,6 +1826,16 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { StatusCode::NOT_IMPLEMENTED } + Error::RuntimeOperationFailed { code, .. } + if code.starts_with("workspace_settings_") + || code.starts_with("invalid_") + || code.starts_with("unsupported_worker_profile") + || code.ends_with("_already_exists") + || code.ends_with("_not_config_managed") + || code.ends_with("_unsupported") => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -1125,6 +1875,46 @@ mod tests { const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; + #[test] + fn worker_profile_candidates_are_backend_published_and_mapped() { + let candidates = worker_profile_candidates(); + assert!( + candidates + .iter() + .any(|candidate| candidate.id == "builtin:coder") + ); + assert!(matches!( + profile_selector_for_candidate("builtin:coder"), + Some(ProfileSelector::Builtin(value)) if value == "builtin:coder" + )); + assert!(profile_selector_for_candidate("free-text-profile").is_none()); + } + + #[test] + fn runtime_connection_request_validation_bounds_browser_input() { + let ok = AddRemoteRuntimeConnectionRequest { + runtime_id: "team-runtime_1".to_string(), + display_name: Some("Team Runtime".to_string()), + endpoint: "https://runtime.example".to_string(), + token_ref: None, + }; + assert!(validate_runtime_connection_request(&ok).is_ok()); + + let bad_endpoint = AddRemoteRuntimeConnectionRequest { + endpoint: "/tmp/socket".to_string(), + ..ok + }; + assert!(validate_runtime_connection_request(&bad_endpoint).is_err()); + } + + #[test] + fn sanitized_errors_omit_backend_private_paths() { + let sanitized = sanitize_backend_error( + "failed to open /home/example/.yoi/workspace-backend.local.toml", + ); + assert!(!sanitized.contains("/home/example")); + } + #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index fda816ee..f448b453 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -1140,3 +1140,156 @@ display: grid; } } + +.section-heading-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section-action { + margin-left: auto; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-strong); + font-size: 0.72rem; + padding: 0.2rem 0.55rem; + cursor: pointer; +} + +.worker-new-form { + display: grid; + gap: 0.65rem; + margin: 0.6rem 0 0.75rem; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.03); +} + +.worker-new-form label, +.settings-runtime-form label { + display: grid; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.78rem; +} + +.worker-new-form input, +.worker-new-form select, +.worker-new-form textarea, +.settings-runtime-form input { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--bg-raised); + color: var(--text-strong); + padding: 0.45rem 0.55rem; + font: inherit; +} + +.worker-new-form textarea { + resize: vertical; +} + +.worker-new-form button, +.settings-runtime-form button, +.settings-action-row button { + border: 0; + border-radius: 0.6rem; + background: var(--accent); + color: var(--bg); + font-weight: 700; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.worker-new-form button:disabled, +.settings-runtime-form button:disabled, +.settings-action-row button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.settings-runtime-form, +.settings-runtime-list { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.settings-runtime-form { + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.03); +} + +.settings-runtime-card { + display: grid; + gap: 0.75rem; + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: var(--bg-raised); +} + +.settings-runtime-card header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.settings-runtime-card.inactive { + opacity: 0.86; +} + +.settings-identity-list.compact { + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); +} + +.settings-action-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.settings-action-row .danger { + background: var(--danger); + color: var(--bg); +} + +.settings-diagnostics-list { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; +} + +.settings-diagnostics-list li { + display: grid; + gap: 0.15rem; + border-radius: 0.6rem; + border: 1px solid var(--line); + padding: 0.55rem 0.65rem; + color: var(--text-muted); +} + +.settings-diagnostics-list li.error { + border-color: rgba(255, 99, 99, 0.55); +} + +.settings-diagnostics-list li.warning { + border-color: rgba(255, 205, 86, 0.55); +} + +.settings-test-result { + display: grid; + gap: 0.3rem; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.75rem; +} diff --git a/web/workspace/src/lib/workspace-settings/SettingsPage.svelte b/web/workspace/src/lib/workspace-settings/SettingsPage.svelte index fbe897b5..b2241cac 100644 --- a/web/workspace/src/lib/workspace-settings/SettingsPage.svelte +++ b/web/workspace/src/lib/workspace-settings/SettingsPage.svelte @@ -5,12 +5,39 @@ SETTINGS_PATTERNS, SETTINGS_PERMISSION_NOTICE, SETTINGS_SECTIONS, + diagnosticLabel, settingsSectionHref, + type Diagnostic, + type RemoteRuntimeConnectionSummary, + type RemoteRuntimeTestResponse, + type RuntimeConnectionMutationResponse, + type RuntimeConnectionSettingsResponse, + type RuntimeConnectionSummary, } from "./model"; + type RemoteAddForm = { + runtime_id: string; + display_name: string; + endpoint: string; + }; + let workspace = $state(null); + let runtimeSettings = $state(null); let loading = $state(true); + let runtimeLoading = $state(true); let error = $state(null); + let runtimeError = $state(null); + let mutationMessage = $state(null); + let mutationDiagnostics = $state([]); + let tests = $state>({}); + let deleting = $state(null); + let testing = $state(null); + let submitting = $state(false); + let remoteForm = $state({ + runtime_id: "", + display_name: "", + endpoint: "", + }); $effect(() => { let cancelled = false; @@ -39,12 +66,165 @@ } } + async function loadRuntimeSettings() { + runtimeLoading = true; + runtimeError = null; + try { + const response = await fetch("/api/settings/runtime-connections"); + if (!response.ok) { + throw new Error(`runtime settings request failed (${response.status})`); + } + const data = (await response.json()) as RuntimeConnectionSettingsResponse; + if (!cancelled) { + runtimeSettings = data; + } + } catch (err) { + if (!cancelled) { + runtimeError = err instanceof Error ? err.message : "runtime settings request failed"; + } + } finally { + if (!cancelled) { + runtimeLoading = false; + } + } + } + loadWorkspace(); + loadRuntimeSettings(); return () => { cancelled = true; }; }); + + async function submitRemoteRuntime() { + submitting = true; + mutationMessage = null; + mutationDiagnostics = []; + try { + const response = await fetch("/api/settings/runtime-connections/remotes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + runtime_id: remoteForm.runtime_id, + display_name: remoteForm.display_name || null, + endpoint: remoteForm.endpoint, + }), + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "add remote Runtime failed")); + } + const data = (await response.json()) as RuntimeConnectionMutationResponse; + applyRuntimeMutation(data); + remoteForm = { runtime_id: "", display_name: "", endpoint: "" }; + } catch (err) { + mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed"; + } finally { + submitting = false; + } + } + + async function deleteRemoteRuntime(runtimeId: string) { + deleting = runtimeId; + mutationMessage = null; + mutationDiagnostics = []; + try { + const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "delete remote Runtime failed")); + } + const data = (await response.json()) as RuntimeConnectionMutationResponse; + applyRuntimeMutation(data); + const nextTests = { ...tests }; + delete nextTests[runtimeId]; + tests = nextTests; + } catch (err) { + mutationMessage = err instanceof Error ? err.message : "delete remote Runtime failed"; + } finally { + deleting = null; + } + } + + async function testRemoteRuntime(runtimeId: string) { + testing = runtimeId; + try { + const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "test remote Runtime failed")); + } + const data = (await response.json()) as RemoteRuntimeTestResponse; + tests = { ...tests, [runtimeId]: data }; + } catch (err) { + tests = { + ...tests, + [runtimeId]: { + workspace_id: runtimeSettings?.workspace_id ?? "unknown", + runtime_id: runtimeId, + checked_at: new Date().toISOString(), + state: "failed", + protocol_version: null, + compatibility_basis: "browser request failed", + capabilities: [], + health_result: "failed", + diagnostics: [ + { + code: "browser_runtime_test_failed", + severity: "error", + message: err instanceof Error ? err.message : "test remote Runtime failed", + }, + ], + }, + }; + } finally { + testing = null; + } + } + + function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) { + runtimeSettings = runtimeSettings + ? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics } + : { + workspace_id: data.workspace_id, + embedded: { + runtime_id: "embedded-worker-runtime", + display_name: "Embedded Runtime", + kind: "embedded_worker_runtime", + built_in: true, + config_managed: false, + active: false, + can_spawn_worker: false, + restart_required: false, + status: "unknown", + diagnostics: [], + }, + remotes: data.remotes, + diagnostics: data.diagnostics, + }; + mutationDiagnostics = data.diagnostics; + mutationMessage = data.restart_required + ? "Runtime config saved. Restart the Workspace backend to apply live registry changes." + : "Runtime config saved."; + } + + async function responseErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string }; + if (typeof payload.error === "object" && payload.error?.message) { + return `${payload.error.code ?? "request_failed"}: ${payload.error.message}`; + } + if (payload.message) { + const code = typeof payload.error === "string" ? payload.error : "request_failed"; + return `${code}: ${payload.message}`; + } + } catch { + // fall through + } + return `${fallback} (${response.status})`; + } @@ -60,11 +240,10 @@

Workspace Browser

Settings / Admin

- Read-only shell for future local administration surfaces. This page creates - navigation and operator context without adding mutation authority. + Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.

- shell only + local only
@@ -74,8 +253,8 @@

{SETTINGS_PERMISSION_NOTICE}

- Diagnostic pattern - Future controls must use typed Backend diagnostics and restart-required states. + Restart-required + Runtime config changes are persisted, then applied after backend restart.
@@ -83,13 +262,95 @@ {#each SETTINGS_SECTIONS as section} {section.label} - {section.status === "read-only" ? "Read-only" : "Placeholder"} + {section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"} {/each} +
+
+
+

editable

+

Runtime Connections

+
+ typed API +
+

{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}

+ + {#if runtimeLoading} +

Loading Runtime connections…

+ {:else if runtimeError} +

Runtime connection settings unavailable: {runtimeError}

+ {:else if runtimeSettings} + {@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })} + +
{ event.preventDefault(); void submitRemoteRuntime(); }}> +

Add remote Runtime

+

Endpoint is submitted to the Backend but not echoed back in settings responses.

+ + + + +
+ + {#if mutationMessage} +

{mutationMessage}

+ {/if} + {@render DiagnosticsList({ diagnostics: mutationDiagnostics })} + +
+

Remote Runtimes

+ {#if runtimeSettings.remotes.length === 0} +

No remote Runtime connections configured.

+ {:else} + {#each runtimeSettings.remotes as remote (remote.runtime_id)} +
+ {@render RuntimeConnectionCard({ connection: remote })} +
+
+
Endpoint
+
{remote.endpoint_configured ? "configured (hidden)" : "not configured"}
+
+
+
Token ref
+
{remote.token_ref_configured ? "configured (hidden)" : "not configured"}
+
+
+
+ + +
+ {#if tests[remote.runtime_id]} + {@const test = tests[remote.runtime_id]} +
+ Test: {test.state} + {test.health_result} · {test.checked_at} +

{test.compatibility_basis}

+ {@render DiagnosticsList({ diagnostics: test.diagnostics })} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
+
- {#each SETTINGS_SECTIONS as section} + {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
@@ -132,7 +393,7 @@

Implementation patterns

-

How future settings should appear

+

How settings should appear

{#each SETTINGS_PATTERNS as pattern} @@ -151,3 +412,51 @@ {/if}
+ +{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })} +
+
+
+

{connection.display_name}

+

{connection.runtime_id}

+
+ {connection.status} +
+
+
+
Kind
+
{connection.kind}
+
+
+
Built in
+
{connection.built_in ? "yes" : "no"}
+
+
+
Config managed
+
{connection.config_managed ? "yes" : "no"}
+
+
+
Spawn
+
{connection.can_spawn_worker ? "available" : "unavailable"}
+
+
+
Restart required
+
{connection.restart_required ? "yes" : "no"}
+
+
+ {@render DiagnosticsList({ diagnostics: connection.diagnostics })} +
+{/snippet} + +{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })} + {#if diagnostics.length > 0} +
    + {#each diagnostics as diagnostic} +
  • + {diagnosticLabel(diagnostic)} + {diagnostic.message} +
  • + {/each} +
+ {/if} +{/snippet} diff --git a/web/workspace/src/lib/workspace-settings/model.test.ts b/web/workspace/src/lib/workspace-settings/model.test.ts index 6c876dc9..bd4c96ad 100644 --- a/web/workspace/src/lib/workspace-settings/model.test.ts +++ b/web/workspace/src/lib/workspace-settings/model.test.ts @@ -3,6 +3,7 @@ import { SETTINGS_PERMISSION_NOTICE, SETTINGS_ROUTE, SETTINGS_SECTIONS, + diagnosticLabel, settingsSectionHref, } from "./model.ts"; @@ -43,7 +44,12 @@ Deno.test("settings shell advertises no fake browser admin model", () => { ); }); -Deno.test("settings placeholders avoid mutation promises and raw authority leaks", () => { +Deno.test("runtime connections are editable without advertising raw authority leaks", () => { + const runtimeSection = SETTINGS_SECTIONS.find((section) => + section.id === "runtime-connections" + ); + assert(runtimeSection?.status === "editable", "Runtime Connections should be editable"); + const allText = [ SETTINGS_PERMISSION_NOTICE, ...SETTINGS_SECTIONS.flatMap((section) => [ @@ -55,14 +61,12 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks ].join("\n"); assert( - allText.includes( - "does not add, remove, test, or persist Runtime endpoints", - ), - "Runtime Connections should remain a placeholder", + allText.includes("restart_required=true") || allText.includes("Restart-required"), + "restart-required pattern should be visible", ); assert( - allText.includes("Restart-required"), - "restart-required pattern should be visible", + allText.includes("not echoed back") || allText.includes("not echoed"), + "endpoint submission should not imply endpoint echoing", ); for ( @@ -72,6 +76,7 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks "token:", "secret:", "store root:", + "config file path:", ] ) { assert( @@ -80,3 +85,15 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks ); } }); + +Deno.test("diagnostic labels preserve severity and code", () => { + const diagnostic = { + severity: "warning", + code: "runtime_registry_restart_required", + message: "Restart required.", + } as const; + assert( + diagnosticLabel(diagnostic) === "warning: runtime_registry_restart_required", + "diagnostic label should be bounded and stable", + ); +}); diff --git a/web/workspace/src/lib/workspace-settings/model.ts b/web/workspace/src/lib/workspace-settings/model.ts index f823c162..c692244e 100644 --- a/web/workspace/src/lib/workspace-settings/model.ts +++ b/web/workspace/src/lib/workspace-settings/model.ts @@ -1,3 +1,9 @@ +export type Diagnostic = { + severity: "info" | "warning" | "error"; + code: string; + message: string; +}; + export type SettingsSectionId = | "runtime-connections" | "backend-config" @@ -6,7 +12,7 @@ export type SettingsSectionId = export type SettingsSection = { readonly id: SettingsSectionId; readonly label: string; - readonly status: "placeholder" | "read-only"; + readonly status: "editable" | "placeholder" | "read-only"; readonly summary: string; readonly bullets: readonly string[]; }; @@ -16,22 +22,66 @@ export type SettingsPattern = { readonly body: string; }; +export type RuntimeConnectionSummary = { + runtime_id: string; + display_name: string; + kind: string; + built_in: boolean; + config_managed: boolean; + active: boolean; + can_spawn_worker: boolean; + restart_required: boolean; + status: string; + diagnostics: Diagnostic[]; +}; + +export type RemoteRuntimeConnectionSummary = RuntimeConnectionSummary & { + endpoint_configured: boolean; + token_ref_configured: boolean; +}; + +export type RuntimeConnectionSettingsResponse = { + workspace_id: string; + embedded: RuntimeConnectionSummary; + remotes: RemoteRuntimeConnectionSummary[]; + diagnostics: Diagnostic[]; +}; + +export type RuntimeConnectionMutationResponse = { + workspace_id: string; + restart_required: boolean; + remotes: RemoteRuntimeConnectionSummary[]; + diagnostics: Diagnostic[]; +}; + +export type RemoteRuntimeTestResponse = { + workspace_id: string; + runtime_id: string; + checked_at: string; + state: string; + protocol_version?: string | null; + compatibility_basis: string; + capabilities: string[]; + health_result: string; + diagnostics: Diagnostic[]; +}; + export const SETTINGS_ROUTE = "/settings"; export const SETTINGS_PERMISSION_NOTICE = - "Yoi currently has no browser user, role, permission, or multi-user authorization model. This shell is intentionally local and descriptive; it does not create an admin role or grant mutation authority."; + "Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority."; export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { id: "runtime-connections", label: "Runtime Connections", - status: "placeholder", + status: "editable", summary: - "Future Runtime connection management will live here. The current view does not add, remove, test, or persist Runtime endpoints.", + "Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.", bullets: [ - "Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.", - "Connection changes require a later typed Backend API and are not performed by this shell.", - "Restart-required states should be shown as bounded diagnostics rather than live mutation controls.", + "Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.", + "The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.", + "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", ], }, { @@ -39,11 +89,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ label: "Backend Config", status: "placeholder", summary: - "Configuration inspection is planned, but editing Backend config or secrets is out of scope for this shell.", + "General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.", bullets: [ "Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.", "Missing-provider or invalid-config states should be displayed as typed diagnostics.", - "No fake permission model is created to make config editing appear available.", + "No fake permission model is created to make unrelated config editing appear available.", ], }, { @@ -64,20 +114,24 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [ { title: "Sanitized diagnostics", body: - "Settings cards should show bounded codes and operator-facing messages, not raw socket paths, credentials, secret refs, token values, or Runtime store paths.", + "Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, or Runtime store paths.", }, { title: "Restart-required changes", body: - "When a future setting cannot apply live, the browser should say restart required and leave the mutation to a typed Backend workflow.", + "Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.", }, { - title: "Read-only until typed APIs exist", + title: "Typed Runtime surface only", body: - "Placeholder sections describe planned surfaces without pretending that user, role, permission, or Runtime mutation APIs already exist.", + "Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.", }, ]; export function settingsSectionHref(id: SettingsSectionId): string { return `${SETTINGS_ROUTE}#${id}`; } + +export function diagnosticLabel(diagnostic: Diagnostic): string { + return `${diagnostic.severity}: ${diagnostic.code}`; +} diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index 10a414d0..cd657241 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,6 +1,11 @@