feat: add workspace runtime and worker controls

This commit is contained in:
Keisuke Hirata 2026-07-03 02:27:42 +09:00
parent 2eb90470bb
commit f2fead7ebd
No known key found for this signature in database
10 changed files with 1550 additions and 42 deletions

View File

@ -2,7 +2,7 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{fs, io}; use std::{fs, io};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity; 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_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_MAX_RECORDS: usize = 200; 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)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile { pub struct WorkspaceBackendConfigFile {
#[serde(default)] #[serde(default)]
@ -29,7 +29,7 @@ pub struct WorkspaceBackendConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig { pub struct WorkspaceBackendServerConfig {
#[serde(default)] #[serde(default)]
@ -40,7 +40,7 @@ pub struct WorkspaceBackendServerConfig {
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig { pub struct WorkspaceBackendDataConfig {
#[serde(default)] #[serde(default)]
@ -51,21 +51,21 @@ pub struct WorkspaceBackendDataConfig {
pub embedded_runtime_store_root: Option<PathBuf>, pub embedded_runtime_store_root: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig { pub struct WorkspaceBackendLimitsConfig {
#[serde(default)] #[serde(default)]
pub max_records: Option<usize>, pub max_records: Option<usize>,
} }
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig { pub struct WorkspaceBackendRuntimesConfig {
#[serde(default)] #[serde(default)]
pub remote: Vec<RemoteRuntimeConfigFile>, pub remote: Vec<RemoteRuntimeConfigFile>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RemoteRuntimeConfigFile { pub struct RemoteRuntimeConfigFile {
pub id: String, pub id: String,
@ -189,6 +189,20 @@ impl WorkspaceBackendConfigFile {
} }
} }
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> 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<Path>) -> Result<Self> { pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| { toml::from_str(raw).map_err(|error| {
Error::Config(format!( Error::Config(format!(

View File

@ -266,6 +266,7 @@ pub struct WorkerSpawnRequest {
pub enum WorkerSpawnIntent { pub enum WorkerSpawnIntent {
WorkspaceCompanion, WorkspaceCompanion,
WorkspaceOrchestrator, WorkspaceOrchestrator,
WorkspaceCoding,
TicketRole { TicketRole {
ticket_id: String, ticket_id: String,
role: TicketWorkerRole, role: TicketWorkerRole,
@ -2260,6 +2261,7 @@ fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
ProfileSelector::Builtin("builtin:companion".to_string()) ProfileSelector::Builtin("builtin:companion".to_string())
} }
WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault, 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 { match intent {
WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion", WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion",
WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator", WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator",
WorkerSpawnIntent::WorkspaceCoding => "workspace_coding",
WorkerSpawnIntent::TicketRole { role, .. } => match role { WorkerSpawnIntent::TicketRole { role, .. } => match role {
TicketWorkerRole::Intake => "ticket_intake", TicketWorkerRole::Intake => "ticket_intake",
TicketWorkerRole::Orchestrator => "ticket_orchestrator", TicketWorkerRole::Orchestrator => "ticket_orchestrator",

View File

@ -6,8 +6,9 @@ use axum::extract::{Path as AxumPath, Query, State};
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::{StatusCode, Uri}; use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::{SecondsFormat, Utc};
use futures::StreamExt; use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
@ -17,11 +18,13 @@ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection, CompanionStatusResponse, CompanionTranscriptProjection,
}; };
use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile};
use crate::hosts::{ use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime, ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest,
WorkerLifecycleResult, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerLifecycleResult, WorkerOperationState, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary,
WorkerTranscriptProjection, WorkerTranscriptProjection,
}; };
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
@ -35,8 +38,14 @@ use crate::records::{
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::store::{ControlPlaneStore, WorkspaceRecord};
use crate::{Error, Result}; use crate::{Error, Result};
use worker_runtime::catalog::ConfigBundleRef; use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
use worker_runtime::config_bundle::ConfigBundle; 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig { pub enum AuthConfig {
@ -222,7 +231,30 @@ pub fn build_router(api: WorkspaceApi) -> Router {
) )
.route("/api/hosts", get(list_hosts)) .route("/api/hosts", get(list_hosts))
.route("/api/runtimes", get(list_runtimes)) .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/status", get(get_companion_status))
.route("/api/companion/transcript", get(get_companion_transcript)) .route("/api/companion/transcript", get(get_companion_transcript))
.route("/api/companion/messages", post(post_companion_message)) .route("/api/companion/messages", post(post_companion_message))
@ -321,6 +353,108 @@ pub struct RuntimeListResponse<T> {
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct RuntimeConnectionSettingsResponse {
pub workspace_id: String,
pub embedded: RuntimeConnectionSummary,
pub remotes: Vec<RemoteRuntimeConnectionSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[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<RuntimeDiagnostic>,
}
#[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<RemoteRuntimeConnectionSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Deserialize)]
pub struct AddRemoteRuntimeConnectionRequest {
pub runtime_id: String,
pub display_name: Option<String>,
pub endpoint: String,
pub token_ref: Option<String>,
}
#[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<String>,
pub compatibility_basis: String,
pub capabilities: Vec<String>,
pub health_result: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkerLaunchOptionsResponse {
pub workspace_id: String,
pub runtimes: Vec<WorkerLaunchRuntimeOption>,
pub profiles: Vec<WorkerLaunchProfileCandidate>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[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<RuntimeDiagnostic>,
}
#[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<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryListResponse { pub struct RepositoryListResponse {
pub workspace_id: String, pub workspace_id: String,
@ -599,6 +733,194 @@ async fn list_workers(
workers_response(api).map(Json) workers_response(api).map(Json)
} }
async fn get_runtime_connection_settings(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
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<WorkspaceApi>,
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
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<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
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<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
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<WorkspaceApi>,
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
Ok(Json(worker_launch_options_response(&api)))
}
async fn create_workspace_worker(
State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
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( async fn get_companion_status(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
) -> ApiResult<Json<CompanionStatusResponse>> { ) -> ApiResult<Json<CompanionStatusResponse>> {
@ -914,6 +1236,424 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
}) })
} }
fn load_workspace_backend_config_for_settings(
api: &WorkspaceApi,
) -> ApiResult<WorkspaceBackendConfigFile> {
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<RemoteRuntimeConnectionSummary> {
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::<RuntimeHttpSummaryResponse>().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<String>,
message: impl Into<String>,
) -> 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<WorkerLaunchProfileCandidate> {
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<ProfileSelector> {
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<String> {
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<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> 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<String> { fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
let canonical_repository_id = api.local_repository_id(); let canonical_repository_id = api.local_repository_id();
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_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" => { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
StatusCode::NOT_IMPLEMENTED 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, Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
@ -1125,6 +1875,46 @@ mod tests {
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; 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)] #[derive(Default)]
struct DeterministicExecutionBackend { struct DeterministicExecutionBackend {
contexts: std::sync::Mutex< contexts: std::sync::Mutex<

View File

@ -1140,3 +1140,156 @@
display: grid; 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;
}

View File

@ -5,12 +5,39 @@
SETTINGS_PATTERNS, SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref, settingsSectionHref,
type Diagnostic,
type RemoteRuntimeConnectionSummary,
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
type RuntimeConnectionSummary,
} from "./model"; } from "./model";
type RemoteAddForm = {
runtime_id: string;
display_name: string;
endpoint: string;
};
let workspace = $state<WorkspaceResponse | null>(null); let workspace = $state<WorkspaceResponse | null>(null);
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
let loading = $state(true); let loading = $state(true);
let runtimeLoading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let runtimeError = $state<string | null>(null);
let mutationMessage = $state<string | null>(null);
let mutationDiagnostics = $state<Diagnostic[]>([]);
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
let deleting = $state<string | null>(null);
let testing = $state<string | null>(null);
let submitting = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: "",
display_name: "",
endpoint: "",
});
$effect(() => { $effect(() => {
let cancelled = false; 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(); loadWorkspace();
loadRuntimeSettings();
return () => { return () => {
cancelled = true; 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<string> {
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})`;
}
</script> </script>
<svelte:head> <svelte:head>
@ -60,11 +240,10 @@
<p class="eyebrow">Workspace Browser</p> <p class="eyebrow">Workspace Browser</p>
<h1 id="settings-title">Settings / Admin</h1> <h1 id="settings-title">Settings / Admin</h1>
<p class="hero-copy"> <p class="hero-copy">
Read-only shell for future local administration surfaces. This page creates Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
navigation and operator context without adding mutation authority.
</p> </p>
</div> </div>
<span class="badge warning">shell only</span> <span class="badge warning">local only</span>
</section> </section>
<section class="card settings-notice" aria-labelledby="settings-boundary-title"> <section class="card settings-notice" aria-labelledby="settings-boundary-title">
@ -74,8 +253,8 @@
<p>{SETTINGS_PERMISSION_NOTICE}</p> <p>{SETTINGS_PERMISSION_NOTICE}</p>
</div> </div>
<div class="settings-diagnostic" role="note"> <div class="settings-diagnostic" role="note">
<strong>Diagnostic pattern</strong> <strong>Restart-required</strong>
<span>Future controls must use typed Backend diagnostics and restart-required states.</span> <span>Runtime config changes are persisted, then applied after backend restart.</span>
</div> </div>
</section> </section>
@ -83,13 +262,95 @@
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={settingsSectionHref(section.id)}> <a class="settings-nav-link" href={settingsSectionHref(section.id)}>
<span>{section.label}</span> <span>{section.label}</span>
<small>{section.status === "read-only" ? "Read-only" : "Placeholder"}</small> <small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
</a> </a>
{/each} {/each}
</section> </section>
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="runtime-connections-title">Runtime Connections</h2>
</div>
<span class="badge success">typed API</span>
</header>
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</p>
{#if runtimeLoading}
<p class="status-message">Loading Runtime connections…</p>
{:else if runtimeError}
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
{:else if runtimeSettings}
{@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
<h3>Add remote Runtime</h3>
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
<label>
<span>Runtime id</span>
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
</label>
<label>
<span>Display name</span>
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
</label>
<label>
<span>Endpoint</span>
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{#if mutationMessage}
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
{/if}
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
<div class="settings-runtime-list" aria-label="Remote Runtime connections">
<h3>Remote Runtimes</h3>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{:else}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<article class="settings-runtime-card">
{@render RuntimeConnectionCard({ connection: remote })}
<dl class="settings-identity-list compact">
<div>
<dt>Endpoint</dt>
<dd>{remote.endpoint_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
<div>
<dt>Token ref</dt>
<dd>{remote.token_ref_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
{testing === remote.runtime_id ? "Testing…" : "Test"}
</button>
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
{/if}
</article>
{/each}
{/if}
</div>
{/if}
</section>
<div class="grid settings-grid"> <div class="grid settings-grid">
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}> <section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
<header class="settings-section-header"> <header class="settings-section-header">
<div> <div>
@ -132,7 +393,7 @@
<section class="card settings-patterns" aria-labelledby="settings-patterns-title"> <section class="card settings-patterns" aria-labelledby="settings-patterns-title">
<div> <div>
<p class="eyebrow">Implementation patterns</p> <p class="eyebrow">Implementation patterns</p>
<h2 id="settings-patterns-title">How future settings should appear</h2> <h2 id="settings-patterns-title">How settings should appear</h2>
</div> </div>
<div class="grid settings-pattern-grid"> <div class="grid settings-pattern-grid">
{#each SETTINGS_PATTERNS as pattern} {#each SETTINGS_PATTERNS as pattern}
@ -151,3 +412,51 @@
{/if} {/if}
</main> </main>
</div> </div>
{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })}
<article class="settings-runtime-card embedded" class:inactive={!connection.active}>
<header>
<div>
<h3>{connection.display_name}</h3>
<p><code>{connection.runtime_id}</code></p>
</div>
<span class="badge" class:success={connection.active} class:warning={!connection.active}>{connection.status}</span>
</header>
<dl class="settings-identity-list compact">
<div>
<dt>Kind</dt>
<dd>{connection.kind}</dd>
</div>
<div>
<dt>Built in</dt>
<dd>{connection.built_in ? "yes" : "no"}</dd>
</div>
<div>
<dt>Config managed</dt>
<dd>{connection.config_managed ? "yes" : "no"}</dd>
</div>
<div>
<dt>Spawn</dt>
<dd>{connection.can_spawn_worker ? "available" : "unavailable"}</dd>
</div>
<div>
<dt>Restart required</dt>
<dd>{connection.restart_required ? "yes" : "no"}</dd>
</div>
</dl>
{@render DiagnosticsList({ diagnostics: connection.diagnostics })}
</article>
{/snippet}
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnosticLabel(diagnostic)}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{/snippet}

View File

@ -3,6 +3,7 @@ import {
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
SETTINGS_ROUTE, SETTINGS_ROUTE,
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref, settingsSectionHref,
} from "./model.ts"; } 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 = [ const allText = [
SETTINGS_PERMISSION_NOTICE, SETTINGS_PERMISSION_NOTICE,
...SETTINGS_SECTIONS.flatMap((section) => [ ...SETTINGS_SECTIONS.flatMap((section) => [
@ -55,14 +61,12 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
].join("\n"); ].join("\n");
assert( assert(
allText.includes( allText.includes("restart_required=true") || allText.includes("Restart-required"),
"does not add, remove, test, or persist Runtime endpoints", "restart-required pattern should be visible",
),
"Runtime Connections should remain a placeholder",
); );
assert( assert(
allText.includes("Restart-required"), allText.includes("not echoed back") || allText.includes("not echoed"),
"restart-required pattern should be visible", "endpoint submission should not imply endpoint echoing",
); );
for ( for (
@ -72,6 +76,7 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
"token:", "token:",
"secret:", "secret:",
"store root:", "store root:",
"config file path:",
] ]
) { ) {
assert( 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",
);
});

View File

@ -1,3 +1,9 @@
export type Diagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
};
export type SettingsSectionId = export type SettingsSectionId =
| "runtime-connections" | "runtime-connections"
| "backend-config" | "backend-config"
@ -6,7 +12,7 @@ export type SettingsSectionId =
export type SettingsSection = { export type SettingsSection = {
readonly id: SettingsSectionId; readonly id: SettingsSectionId;
readonly label: string; readonly label: string;
readonly status: "placeholder" | "read-only"; readonly status: "editable" | "placeholder" | "read-only";
readonly summary: string; readonly summary: string;
readonly bullets: readonly string[]; readonly bullets: readonly string[];
}; };
@ -16,22 +22,66 @@ export type SettingsPattern = {
readonly body: string; 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_ROUTE = "/settings";
export const SETTINGS_PERMISSION_NOTICE = 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[] = [ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ {
id: "runtime-connections", id: "runtime-connections",
label: "Runtime Connections", label: "Runtime Connections",
status: "placeholder", status: "editable",
summary: 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: [ bullets: [
"Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.", "Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
"Connection changes require a later typed Backend API and are not performed by this shell.", "The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
"Restart-required states should be shown as bounded diagnostics rather than live mutation controls.", "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", label: "Backend Config",
status: "placeholder", status: "placeholder",
summary: 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: [ bullets: [
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.", "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.", "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", title: "Sanitized diagnostics",
body: 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", title: "Restart-required changes",
body: 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: 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 { export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}#${id}`; return `${SETTINGS_ROUTE}#${id}`;
} }
export function diagnosticLabel(diagnostic: Diagnostic): string {
return `${diagnostic.severity}: ${diagnostic.code}`;
}

View File

@ -1,6 +1,11 @@
<script lang="ts"> <script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model'; import { workerConsoleHref } from '$lib/workspace-console/model';
import type { ListResponse, Worker } from './types'; import type {
BrowserCreateWorkerResponse,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
} from './types';
const MAX_VISIBLE_WORKERS = 6; const MAX_VISIBLE_WORKERS = 6;
@ -14,14 +19,24 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<Worker[]>([]); let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null); let placeholder = $state<string | null>(null);
let options = $state<WorkerLaunchOptionsResponse | null>(null);
let optionsError = $state<string | null>(null);
let showNewWorker = $state(false);
let submitting = $state(false);
let submitError = $state<string | null>(null);
let displayName = $state('Coding Worker');
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
$effect(() => { $effect(() => {
const controller = new AbortController(); const controller = new AbortController();
void loadWorkers(controller.signal); void loadWorkers(controller.signal);
void loadLaunchOptions(controller.signal);
return () => controller.abort(); return () => controller.abort();
}); });
async function loadWorkers(signal: AbortSignal) { async function loadWorkers(signal?: AbortSignal) {
loading = true; loading = true;
error = null; error = null;
placeholder = null; placeholder = null;
@ -47,21 +62,142 @@
error = err instanceof Error ? err.message : 'workers request failed'; error = err instanceof Error ? err.message : 'workers request failed';
workers = []; workers = [];
} finally { } finally {
if (!signal.aborted) { if (!signal?.aborted) {
loading = false; loading = false;
} }
} }
} }
async function loadLaunchOptions(signal?: AbortSignal) {
optionsError = null;
try {
const response = await fetch('/api/workers/launch-options', { signal });
if (!response.ok) {
throw new Error(`worker launch options failed (${response.status})`);
}
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
options = payload;
const preferredRuntime = payload.runtimes.find((runtime) => runtime.can_spawn_worker && runtime.status === 'active')
?? payload.runtimes.find((runtime) => runtime.can_spawn_worker)
?? payload.runtimes[0];
if (preferredRuntime && !runtimeId) {
runtimeId = preferredRuntime.runtime_id;
}
const preferredProfile = payload.profiles.find((candidate) => candidate.id === 'builtin:coder') ?? payload.profiles[0];
if (preferredProfile && !payload.profiles.some((candidate) => candidate.id === profile)) {
profile = preferredProfile.id;
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
}
}
async function createWorker() {
submitError = null;
submitting = true;
try {
const response = await fetch('/api/workers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName,
profile,
initial_text: initialText,
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'worker create failed'));
}
const payload = (await response.json()) as BrowserCreateWorkerResponse;
await loadWorkers();
window.location.href = payload.console_href;
} catch (err) {
submitError = err instanceof Error ? err.message : 'worker create failed';
} finally {
submitting = false;
}
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
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})`;
}
</script> </script>
<section class="nav-section" aria-labelledby="workers-heading"> <section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="workers-heading">workers</h2> <h2 id="workers-heading">workers</h2>
<button type="button" class="section-action" onclick={() => (showNewWorker = !showNewWorker)}>
{showNewWorker ? 'Close' : 'New'}
</button>
{#if !loading && !error && workers.length > 0} {#if !loading && !error && workers.length > 0}
<span class="section-count">{workers.length}</span> <span class="section-count">{workers.length}</span>
{/if} {/if}
</div> </div>
{#if showNewWorker}
<form class="worker-new-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
<label>
<span>Display name</span>
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
</label>
<label>
<span>Runtime</span>
<select bind:value={runtimeId} required>
{#if options?.runtimes.length}
{#each options.runtimes as runtime}
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
{runtime.display_name} · {runtime.status}{runtime.built_in ? ' · embedded' : ''}
</option>
{/each}
{:else}
<option value="" disabled>No Runtime options</option>
{/if}
</select>
</label>
<label>
<span>Profile</span>
<select bind:value={profile} required>
{#if options?.profiles.length}
{#each options.profiles as candidate}
<option value={candidate.id}>{candidate.label}</option>
{/each}
{:else}
<option value="" disabled>No profile candidates</option>
{/if}
</select>
</label>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
</label>
{#if optionsError}
<p class="section-state error">{optionsError}</p>
{/if}
{#if submitError}
<p class="section-state error">{submitError}</p>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
{/if}
{#if loading} {#if loading}
<p class="section-state">Checking workers…</p> <p class="section-state">Checking workers…</p>
{:else if error} {:else if error}

View File

@ -93,6 +93,37 @@ export type Worker = {
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected'; export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
can_spawn_worker: boolean;
status: string;
diagnostics: Diagnostic[];
};
export type WorkerLaunchProfileCandidate = {
id: string;
label: string;
description: string;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
diagnostics: Diagnostic[];
};
export type BrowserCreateWorkerResponse = {
workspace_id: string;
runtime_id: string;
worker_id: string;
console_href: string;
worker: Worker;
diagnostics: Diagnostic[];
};
export type WorkerInputResult = { export type WorkerInputResult = {
state: WorkerOperationState; state: WorkerOperationState;
runtime_id: string; runtime_id: string;

View File

@ -10,6 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"module": "ESNext",
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
} }