feat: add workspace runtime and worker controls
This commit is contained in:
parent
2eb90470bb
commit
f2fead7ebd
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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<
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user