feat: unify workspace settings and runtime resources

This commit is contained in:
2026-08-31 01:32:31 +09:00
parent e84a9d3f9b
commit 12cc2eb0e9
24 changed files with 776 additions and 916 deletions
+41
View File
@@ -237,6 +237,7 @@ pub enum RuntimeSourceStatus {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RuntimeIdentityAuthority { pub enum RuntimeIdentityAuthority {
RuntimeRegistryProjection, RuntimeRegistryProjection,
ServerRuntimeConfiguration,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -263,6 +264,46 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeManagementSummary {
pub built_in: bool,
pub config_managed: bool,
pub removable: bool,
pub endpoint_configured: bool,
pub token_ref_configured: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceRuntimeResource {
#[serde(flatten)]
pub runtime: RuntimeSummary,
pub management: RuntimeManagementSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
pub runtime_id: String,
pub display_name: Option<String>,
pub endpoint: String,
pub token_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeConnectionTestResponse {
pub workspace_id: String,
pub runtime_id: String,
pub checked_at: String,
pub state: String,
pub protocol_version: Option<String>,
pub compatibility_basis: String,
#[serde(default)]
pub capabilities: Vec<String>,
pub health_result: String,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerWorkspaceSummary { pub struct WorkerWorkspaceSummary {
pub visibility: String, pub visibility: String,
+205 -335
View File
@@ -58,12 +58,13 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use workspace_api::{ use workspace_api::{
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
TICKET_RELATIONS_QUERY_PATH, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource,
}; };
use crate::auth::{ use crate::auth::{
@@ -2428,7 +2429,18 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory), get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory),
) )
.route("/api/runtimes", get(list_runtimes)) .route("/api/runtimes", get(list_runtimes))
.route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes)) .route(
"/api/w/{workspace_id}/runtimes",
get(scoped_list_runtimes).post(scoped_create_remote_runtime),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}",
delete(scoped_delete_remote_runtime),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
post(scoped_test_runtime_connection),
)
.route( .route(
"/api/workers", "/api/workers",
get(list_workers).post(create_workspace_worker), get(list_workers).post(create_workspace_worker),
@@ -2486,38 +2498,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/workers/launch-options", "/api/w/{workspace_id}/workers/launch-options",
get(scoped_get_worker_launch_options), get(scoped_get_worker_launch_options),
) )
.route(
"/api/settings/runtime-connections",
get(get_runtime_connection_settings),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections",
get(scoped_get_runtime_connection_settings),
)
.route(
"/api/settings/runtime-connections/remotes",
post(add_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes",
post(scoped_add_remote_runtime_connection),
)
.route(
"/api/settings/runtime-connections/remotes/{runtime_id}",
delete(delete_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}",
delete(scoped_delete_remote_runtime_connection),
)
.route(
"/api/settings/runtime-connections/remotes/{runtime_id}/test",
post(test_remote_runtime_connection),
)
.route(
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test",
post(scoped_test_remote_runtime_connection),
)
.route( .route(
"/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", "/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
post(scoped_post_internal_runtime_resource_fetch), post(scoped_post_internal_runtime_resource_fetch),
@@ -2958,66 +2938,6 @@ pub struct WorkerRetentionResponse {
pub retention_state: String, pub retention_state: String,
} }
#[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 worker_creation_available: 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)]
#[serde(deny_unknown_fields)]
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)] #[derive(Debug, Serialize, Deserialize)]
pub struct WorkerLaunchOptionsResponse { pub struct WorkerLaunchOptionsResponse {
pub workspace_id: String, pub workspace_id: String,
@@ -7961,9 +7881,13 @@ async fn scoped_get_profile_source_archive(
async fn scoped_list_runtimes( async fn scoped_list_runtimes(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> { ) -> ApiResult<Json<workspace_api::ListResponse<WorkspaceRuntimeResource>>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
list_runtimes(State(api)).await let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
Ok(Json(workspace_runtime_resources_response(
&api,
&runtime_config,
)))
} }
async fn scoped_workspace_protocol_ws( async fn scoped_workspace_protocol_ws(
@@ -9833,37 +9757,29 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError {
.into() .into()
} }
async fn scoped_get_runtime_connection_settings( async fn scoped_create_remote_runtime(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> { Json(request): Json<CreateRemoteRuntimeRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
get_runtime_connection_settings(State(api)).await create_remote_runtime(State(api), Json(request)).await
} }
async fn scoped_add_remote_runtime_connection( async fn scoped_delete_remote_runtime(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
add_remote_runtime_connection(State(api), Json(request)).await
}
async fn scoped_delete_remote_runtime_connection(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> { ) -> ApiResult<StatusCode> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
delete_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
} }
async fn scoped_test_remote_runtime_connection( async fn scoped_test_runtime_connection(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
) -> ApiResult<Json<RemoteRuntimeTestResponse>> { ) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
test_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await test_runtime_connection(State(api), AxumPath(path.runtime_id)).await
} }
async fn scoped_get_companion_status( async fn scoped_get_companion_status(
@@ -11154,27 +11070,17 @@ async fn list_workers(
workers_response(api).map(Json) workers_response(api).map(Json)
} }
async fn get_runtime_connection_settings( async fn create_remote_runtime(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> { Json(request): Json<CreateRemoteRuntimeRequest>,
let runtime_config = load_backend_runtimes_config_for_settings(&api)?; ) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
Ok(Json(runtime_connection_settings_response(
&api,
&runtime_config,
)))
}
async fn add_remote_runtime_connection(
State(api): State<WorkspaceApi>,
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
validate_runtime_connection_request(&request)?; validate_runtime_connection_request(&request)?;
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
let id = request.runtime_id.trim().to_string(); let id = request.runtime_id.trim().to_string();
if id == EMBEDDED_WORKER_RUNTIME_ID { if id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request( return Err(settings_bad_request(
"embedded_runtime_not_config_managed", "embedded_runtime_not_config_managed",
"the embedded Runtime is built in and cannot be managed from local remote Runtime config", "the embedded Runtime is built in and cannot be managed as a remote Runtime",
)); ));
} }
if request if request
@@ -11184,7 +11090,7 @@ async fn add_remote_runtime_connection(
{ {
return Err(settings_bad_request( return Err(settings_bad_request(
"remote_runtime_token_ref_unsupported", "remote_runtime_token_ref_unsupported",
"remote Runtime token_ref persistence is not supported by this v0 browser settings surface", "remote Runtime token_ref persistence is not supported",
)); ));
} }
if runtime_config if runtime_config
@@ -11195,11 +11101,11 @@ async fn add_remote_runtime_connection(
{ {
return Err(settings_bad_request( return Err(settings_bad_request(
"remote_runtime_already_exists", "remote_runtime_already_exists",
"a remote Runtime connection with that id is already configured", "a remote Runtime with that id already exists",
)); ));
} }
let remote_config = RemoteRuntimeConfigFile { let remote_config = RemoteRuntimeConfigFile {
id, id: id.clone(),
endpoint: request.endpoint.trim().to_string(), endpoint: request.endpoint.trim().to_string(),
display_name: request display_name: request
.display_name .display_name
@@ -11232,31 +11138,19 @@ async fn add_remote_runtime_connection(
runtime_config.runtimes.remote.push(remote_config); runtime_config.runtimes.remote.push(remote_config);
write_backend_runtimes_config_for_settings(&api, &runtime_config)?; write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
api.runtime.register_or_replace(active_runtime); api.runtime.register_or_replace(active_runtime);
let mut response = runtime_connection_mutation_response( let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id)
&api, .ok_or_else(|| Error::UnknownRuntime(id.clone()))?;
&runtime_config, Ok((StatusCode::CREATED, Json(resource)))
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
"Remote Runtime config was persisted and applied to the active Runtime registry without restarting the Workspace backend.",
)],
);
response.diagnostics.push(settings_diagnostic(
"backend_runtimes_config_rewritten",
DiagnosticSeverity::Info,
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
));
Ok(Json(response))
} }
async fn delete_remote_runtime_connection( async fn delete_remote_runtime(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>, AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> { ) -> ApiResult<StatusCode> {
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request( return Err(settings_bad_request(
"embedded_runtime_not_config_managed", "embedded_runtime_not_config_managed",
"the embedded Runtime is built in and cannot be deleted from remote Runtime config", "the embedded Runtime is built in and cannot be deleted",
)); ));
} }
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
@@ -11283,41 +11177,27 @@ async fn delete_remote_runtime_connection(
"remote_runtime_delete_blocked", "remote_runtime_delete_blocked",
DiagnosticSeverity::Error, DiagnosticSeverity::Error,
format!( format!(
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting the connection." "Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting it."
), ),
)); ));
return Err(ApiError::with_diagnostics( return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed { Error::RuntimeOperationFailed {
runtime_id, runtime_id,
code: "remote_runtime_delete_blocked".to_string(), code: "remote_runtime_delete_blocked".to_string(),
message: "Remote Runtime connection has active workers".to_string(), message: "Remote Runtime has active workers".to_string(),
}, },
diagnostics, diagnostics,
)); ));
} }
} }
write_backend_runtimes_config_for_settings(&api, &runtime_config)?; write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
let mut response = runtime_connection_mutation_response( Ok(StatusCode::NO_CONTENT)
&api,
&runtime_config,
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
"Remote Runtime config was removed from persisted config and the active Runtime registry without restarting the Workspace backend.",
)],
);
response.diagnostics.push(settings_diagnostic(
"backend_runtimes_config_rewritten",
DiagnosticSeverity::Info,
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
));
Ok(Json(response))
} }
async fn test_remote_runtime_connection( async fn test_runtime_connection(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>, AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RemoteRuntimeTestResponse>> { ) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
let runtime_config = load_backend_runtimes_config_for_settings(&api)?; let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
let remote = runtime_config let remote = runtime_config
.runtimes .runtimes
@@ -13218,142 +13098,112 @@ fn write_backend_runtimes_config_for_settings(
}) })
} }
fn runtime_connection_settings_response( fn workspace_runtime_resources_response(
api: &WorkspaceApi, api: &WorkspaceApi,
runtime_config: &BackendRuntimesConfigFile, runtime_config: &BackendRuntimesConfigFile,
) -> RuntimeConnectionSettingsResponse { ) -> workspace_api::ListResponse<WorkspaceRuntimeResource> {
RuntimeConnectionSettingsResponse { let limit = api.config.max_records.min(200);
workspace_id: api.config.workspace_id.clone(), let runtimes = api.runtime.list_runtimes(limit);
embedded: embedded_runtime_connection_summary(api), let mut items = runtimes
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
diagnostics: Vec::new(),
}
}
fn runtime_connection_mutation_response(
api: &WorkspaceApi,
runtime_config: &BackendRuntimesConfigFile,
diagnostics: Vec<RuntimeDiagnostic>,
) -> RuntimeConnectionMutationResponse {
RuntimeConnectionMutationResponse {
workspace_id: api.config.workspace_id.clone(),
restart_required: false,
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
diagnostics,
}
}
fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionSummary {
let active = api
.runtime
.list_runtimes(api.config.max_records.min(200))
.items .items
.into_iter() .into_iter()
.find(|runtime| runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID); .map(|runtime| {
match active { let remote = runtime_config
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",
worker_creation_available: runtime.worker_creation_available,
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,
worker_creation_available: 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,
runtime_config: &BackendRuntimesConfigFile,
restart_required: bool,
) -> Vec<RemoteRuntimeConnectionSummary> {
let live_runtimes = api
.runtime
.list_runtimes(api.config.max_records.min(200))
.items;
runtime_config
.runtimes .runtimes
.remote .remote
.iter() .iter()
.map(|remote| { .find(|remote| remote.id == runtime.runtime_id);
let live = live_runtimes let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
.iter() WorkspaceRuntimeResource {
.find(|runtime| runtime.runtime_id == remote.id); runtime: runtime.into(),
let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live { management: RuntimeManagementSummary {
Some(runtime) => ( built_in,
runtime.label.clone(), config_managed: remote.is_some(),
runtime.kind.clone(), removable: remote.is_some() && !built_in,
runtime.status == "active", endpoint_configured: remote
runtime.worker_creation_available, .is_some_and(|remote| !remote.endpoint.trim().is_empty()),
runtime.status.clone(), token_ref_configured: remote.is_some_and(|remote| {
runtime.diagnostics.clone(),
),
None => (
remote remote
.token_ref
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}),
},
}
})
.collect::<Vec<_>>();
for remote in &runtime_config.runtimes.remote {
if items
.iter()
.any(|resource| resource.runtime.runtime_id == remote.id)
{
continue;
}
items.push(WorkspaceRuntimeResource {
runtime: workspace_api::RuntimeSummary {
runtime_id: remote.id.clone(),
label: remote
.display_name .display_name
.clone() .clone()
.unwrap_or_else(|| remote.id.clone()), .unwrap_or_else(|| remote.id.clone()),
"remote_http".to_string(), kind: "remote_http".to_string(),
false, status: "unavailable".to_string(),
false, source: workspace_api::RuntimeSourceSummary {
"configured_restart_required".to_string(), kind: workspace_api::RuntimeSourceKind::RemoteHttp,
if restart_required { status: workspace_api::RuntimeSourceStatus::Reserved,
vec![settings_diagnostic( identity_authority:
"runtime_registry_restart_required", workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
DiagnosticSeverity::Warning, note: "The configured Runtime is not present in the active Runtime registry."
"This remote Runtime config is persisted but not active until the Workspace backend restarts.", .to_string(),
)]
} else {
Vec::new()
}, },
), host_ids: Vec::new(),
}; worker_creation_available: false,
RemoteRuntimeConnectionSummary { os: String::new(),
summary: RuntimeConnectionSummary { arch: String::new(),
runtime_id: remote.id.clone(), diagnostics: vec![
display_name, settings_diagnostic(
kind, "configured_runtime_unavailable",
DiagnosticSeverity::Warning,
"The configured Runtime is not present in the active Runtime registry.",
)
.into(),
],
},
management: RuntimeManagementSummary {
built_in: false, built_in: false,
config_managed: true, config_managed: true,
active, removable: true,
worker_creation_available,
restart_required,
status,
diagnostics,
},
endpoint_configured: !remote.endpoint.trim().is_empty(), endpoint_configured: !remote.endpoint.trim().is_empty(),
token_ref_configured: remote token_ref_configured: remote
.token_ref .token_ref
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()), .is_some_and(|value| !value.trim().is_empty()),
},
});
}
workspace_api::ListResponse {
workspace_id: api.config.workspace_id.clone(),
limit,
items,
source: "workspace-runtime-resources".to_string(),
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
} }
})
.collect()
} }
fn validate_runtime_connection_request( fn workspace_runtime_resource_by_id(
request: &AddRemoteRuntimeConnectionRequest, api: &WorkspaceApi,
) -> ApiResult<()> { runtime_config: &BackendRuntimesConfigFile,
runtime_id: &str,
) -> Option<WorkspaceRuntimeResource> {
workspace_runtime_resources_response(api, runtime_config)
.items
.into_iter()
.find(|resource| resource.runtime.runtime_id == runtime_id)
}
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
validate_public_runtime_id(request.runtime_id.trim())?; validate_public_runtime_id(request.runtime_id.trim())?;
let endpoint = request.endpoint.trim(); let endpoint = request.endpoint.trim();
if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://"))
@@ -13411,14 +13261,14 @@ fn remote_runtime_config_from_file(
async fn test_remote_runtime_config( async fn test_remote_runtime_config(
api: &WorkspaceApi, api: &WorkspaceApi,
remote: &RemoteRuntimeConfigFile, remote: &RemoteRuntimeConfigFile,
) -> RemoteRuntimeTestResponse { ) -> RuntimeConnectionTestResponse {
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
if remote if remote
.token_ref .token_ref
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()) .is_some_and(|value| !value.trim().is_empty())
{ {
return RemoteRuntimeTestResponse { return RuntimeConnectionTestResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: remote.id.clone(), runtime_id: remote.id.clone(),
checked_at, checked_at,
@@ -13431,7 +13281,8 @@ async fn test_remote_runtime_config(
"remote_runtime_token_ref_unsupported", "remote_runtime_token_ref_unsupported",
DiagnosticSeverity::Error, DiagnosticSeverity::Error,
"Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.", "Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.",
)], )
.into()],
}; };
} }
@@ -13690,7 +13541,7 @@ async fn test_remote_runtime_config(
"No connection problem found. Config-bundle sync was not checked because this lightweight test does not upload bundles as a side effect.", "No connection problem found. Config-bundle sync was not checked because this lightweight test does not upload bundles as a side effect.",
); );
RemoteRuntimeTestResponse { RuntimeConnectionTestResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: remote.id.clone(), runtime_id: remote.id.clone(),
checked_at, checked_at,
@@ -13705,7 +13556,11 @@ async fn test_remote_runtime_config(
observation.incompatible_count, observation.incompatible_count,
observation.unknown_count observation.unknown_count
), ),
diagnostics: observation.diagnostics, diagnostics: observation
.diagnostics
.into_iter()
.map(Into::into)
.collect(),
} }
} }
@@ -13715,8 +13570,8 @@ fn remote_runtime_test_failed(
checked_at: String, checked_at: String,
code: impl Into<String>, code: impl Into<String>,
message: impl Into<String>, message: impl Into<String>,
) -> RemoteRuntimeTestResponse { ) -> RuntimeConnectionTestResponse {
RemoteRuntimeTestResponse { RuntimeConnectionTestResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: remote.id.clone(), runtime_id: remote.id.clone(),
checked_at, checked_at,
@@ -13725,11 +13580,7 @@ fn remote_runtime_test_failed(
compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(), compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(),
capabilities: Vec::new(), capabilities: Vec::new(),
health_result: "failed".to_string(), health_result: "failed".to_string(),
diagnostics: vec![settings_diagnostic( diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()],
code,
DiagnosticSeverity::Error,
message,
)],
} }
} }
@@ -17046,7 +16897,7 @@ mod tests {
#[test] #[test]
fn runtime_connection_request_validation_bounds_browser_input() { fn runtime_connection_request_validation_bounds_browser_input() {
let ok = AddRemoteRuntimeConnectionRequest { let ok = CreateRemoteRuntimeRequest {
runtime_id: "team-runtime_1".to_string(), runtime_id: "team-runtime_1".to_string(),
display_name: Some("Team Runtime".to_string()), display_name: Some("Team Runtime".to_string()),
endpoint: "https://runtime.example".to_string(), endpoint: "https://runtime.example".to_string(),
@@ -17054,7 +16905,7 @@ mod tests {
}; };
assert!(validate_runtime_connection_request(&ok).is_ok()); assert!(validate_runtime_connection_request(&ok).is_ok());
let bad_endpoint = AddRemoteRuntimeConnectionRequest { let bad_endpoint = CreateRemoteRuntimeRequest {
endpoint: "/tmp/socket".to_string(), endpoint: "/tmp/socket".to_string(),
..ok ..ok
}; };
@@ -22698,34 +22549,52 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn runtime_connection_settings_add_delete_apply_live_registry() { async fn runtime_rest_resource_create_list_and_delete_apply_live_registry() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
let settings = get_json(app.clone(), "/api/settings/runtime-connections").await; let initial = get_json(app.clone(), &runtimes_uri).await;
assert_eq!(settings["embedded"]["built_in"], true); let embedded = initial["items"]
assert_eq!(settings["embedded"]["config_managed"], false);
let added = post_json(
app.clone(),
"/api/settings/runtime-connections/remotes",
serde_json::json!({
"runtime_id": "team-runtime",
"display_name": "Team Runtime",
"endpoint": "https://runtime.example.invalid"
}),
)
.await;
assert_eq!(added["restart_required"], false);
assert_eq!(added["remotes"][0]["runtime_id"], "team-runtime");
assert_eq!(added["remotes"][0]["endpoint_configured"], true);
assert!(
added["diagnostics"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|diagnostic| diagnostic["code"] == "runtime_registry_applied") .find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
); .unwrap();
assert_eq!(embedded["management"]["built_in"], true);
assert_eq!(embedded["management"]["config_managed"], false);
request_json(
app.clone(),
"DELETE",
&format!("{runtimes_uri}/{EMBEDDED_WORKER_RUNTIME_ID}"),
None,
StatusCode::BAD_REQUEST,
)
.await;
request_json(
app.clone(),
"GET",
"/api/settings/runtime-connections",
None,
StatusCode::NOT_FOUND,
)
.await;
let added = request_json(
app.clone(),
"POST",
&runtimes_uri,
Some(serde_json::json!({
"runtime_id": "team-runtime",
"display_name": "Team Runtime",
"endpoint": "https://runtime.example.invalid"
})),
StatusCode::CREATED,
)
.await;
assert_eq!(added["runtime_id"], "team-runtime");
assert_eq!(added["management"]["config_managed"], true);
assert_eq!(added["management"]["endpoint_configured"], true);
let projected = serde_json::to_string(&added).unwrap(); let projected = serde_json::to_string(&added).unwrap();
assert!(!projected.contains("runtime.example.invalid")); assert!(!projected.contains("runtime.example.invalid"));
@@ -22756,13 +22625,12 @@ mod tests {
let deleted = request_json( let deleted = request_json(
app.clone(), app.clone(),
"DELETE", "DELETE",
"/api/settings/runtime-connections/remotes/team-runtime", &format!("{runtimes_uri}/team-runtime"),
None, None,
StatusCode::OK, StatusCode::NO_CONTENT,
) )
.await; .await;
assert_eq!(deleted["restart_required"], false); assert_eq!(deleted["message"], "");
assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0);
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
assert!( assert!(
!launch_options["runtimes"] !launch_options["runtimes"]
@@ -22794,17 +22662,19 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let added = post_json( let added = request_json(
app.clone(), app.clone(),
"/api/settings/runtime-connections/remotes", "POST",
serde_json::json!({ &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"),
Some(serde_json::json!({
"runtime_id": "busy-runtime", "runtime_id": "busy-runtime",
"display_name": "Busy Runtime", "display_name": "Busy Runtime",
"endpoint": format!("http://{runtime_addr}") "endpoint": format!("http://{runtime_addr}")
}), })),
StatusCode::CREATED,
) )
.await; .await;
assert_eq!(added["restart_required"], false); assert_eq!(added["runtime_id"], "busy-runtime");
let workers = get_json(app.clone(), "/api/workers").await; let workers = get_json(app.clone(), "/api/workers").await;
assert!( assert!(
workers["items"] workers["items"]
@@ -22818,7 +22688,7 @@ mod tests {
let response = request_json( let response = request_json(
app, app,
"DELETE", "DELETE",
"/api/settings/runtime-connections/remotes/busy-runtime", &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"),
None, None,
StatusCode::CONFLICT, StatusCode::CONFLICT,
) )
@@ -22876,7 +22746,7 @@ mod tests {
let response = post_json( let response = post_json(
app, app,
"/api/settings/runtime-connections/remotes/probe-runtime/test", &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
serde_json::json!({}), serde_json::json!({}),
) )
.await; .await;
@@ -22940,7 +22810,7 @@ mod tests {
let response = post_json( let response = post_json(
app, app,
"/api/settings/runtime-connections/remotes/control-only-runtime/test", &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/control-only-runtime/connection-tests"),
serde_json::json!({}), serde_json::json!({}),
) )
.await; .await;
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -107,7 +107,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
"workspaceRoute(workspaceId, '/settings/runtimes')", "workspaceRoute(workspaceId, '/settings/runtimes')",
) && ) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"), workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page", "top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
); );
assert( assert(
!workspacePage.includes("workerConsoleHref") && !workspacePage.includes("workerConsoleHref") &&
@@ -599,21 +599,24 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
assert( assert(
!sidebar.includes("RuntimesNavSection") && !sidebar.includes("RuntimesNavSection") &&
settingsModel.includes('id: "runtime-inventory"') && settingsModel.includes('id: "runtimes"') &&
settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"), settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"),
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation", "Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
); );
assert( assert(
runtimesPage.includes("Runtime Inventory") && runtimesPage.includes("Add remote Runtime") &&
runtimesPage.includes("Open workdirs") && runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") && runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes(
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests",
) &&
runtimesPage.includes( runtimesPage.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs", "/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
), ),
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs", "Settings Runtimes page should expose canonical REST actions and link to each Runtime's workdirs",
); );
assert( assert(
workdirsPage.includes("Runtime Inventory") && workdirsPage.includes(">Runtimes</a>") &&
workdirsPage.includes("workdirs-table") && workdirsPage.includes("workdirs-table") &&
workdirsLoad.includes("/working-directories"), workdirsLoad.includes("/working-directories"),
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects", "Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
@@ -834,9 +837,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order", "Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
); );
assert( assert(
sidebarOverride.includes("controller.setSidebar(sidebar)") && sidebarOverride.includes("controller.registerSidebar(sidebar)") &&
sidebarOverride.includes("controller.clearSidebar(sidebar)"), rootLayout.includes("createOverrideStack<SidebarSnippet>") &&
"SidebarOverride should register and clean up the child-provided sidebar snippet", rootLayout.includes("registerSidebar: sidebarOverrides.register"),
"SidebarOverride should register a nested sidebar whose cleanup restores the parent override",
); );
assert( assert(
rootLayoutLoad.includes("export const load") && rootLayoutLoad.includes("export const load") &&
@@ -71,13 +71,13 @@ Deno.test("Repository access settings are editable and canonically routed", () =
); );
}); });
Deno.test("runtime connections are editable without advertising raw authority leaks", () => { Deno.test("Runtimes are one editable REST resource without authority leaks", () => {
const runtimeSection = SETTINGS_SECTIONS.find((section) => const runtimeSection = SETTINGS_SECTIONS.find((section) =>
section.id === "runtime-connections" section.id === "runtimes"
); );
assert( assert(
runtimeSection?.status === "editable", runtimeSection?.status === "editable",
"Runtime Connections should be editable", "Runtimes should be editable",
); );
const allText = [ const allText = [
@@ -91,13 +91,13 @@ Deno.test("runtime connections are editable without advertising raw authority le
].join("\n"); ].join("\n");
assert( assert(
allText.includes("restart_required=true") || allText.includes("canonical") && allText.includes("REST resource"),
allText.includes("Restart-required"), "Runtime settings should describe the canonical REST resource",
"restart-required pattern should be visible",
); );
assert( assert(
allText.includes("not echoed back") || allText.includes("not echoed"), !allText.includes("restart_required") &&
"endpoint submission should not imply endpoint echoing", !allText.includes("Restart-required"),
"Runtime settings should not retain obsolete restart-required semantics",
); );
for ( for (
@@ -120,12 +120,12 @@ Deno.test("runtime connections are editable without advertising raw authority le
Deno.test("diagnostic labels preserve severity and code", () => { Deno.test("diagnostic labels preserve severity and code", () => {
const diagnostic = { const diagnostic = {
severity: "warning", severity: "warning",
code: "runtime_registry_restart_required", code: "configured_runtime_unavailable",
message: "Restart required.", message: "Configured Runtime unavailable.",
} as const; } as const;
assert( assert(
diagnosticLabel(diagnostic) === diagnosticLabel(diagnostic) ===
"warning: runtime_registry_restart_required", "warning: configured_runtime_unavailable",
"diagnostic label should be bounded and stable", "diagnostic label should be bounded and stable",
); );
}); });
@@ -5,18 +5,16 @@ export type Diagnostic = {
}; };
export type SettingsSectionId = export type SettingsSectionId =
| "runtime-connections" | "runtimes"
| "runtime-inventory"
| "configuration-sources" | "configuration-sources"
| "repository-access" | "repository-access"
| "profile-sources" | "profile-sources"
| "backend-config"
| "workspace-identity"; | "workspace-identity";
export type SettingsSection = { export type SettingsSection = {
readonly id: SettingsSectionId; readonly id: SettingsSectionId;
readonly label: string; readonly label: string;
readonly status: "editable" | "placeholder" | "read-only"; readonly status: "editable" | "read-only";
readonly summary: string; readonly summary: string;
readonly bullets: readonly string[]; readonly bullets: readonly string[];
}; };
@@ -26,50 +24,6 @@ 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;
worker_creation_available: 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 =
@@ -77,27 +31,15 @@ export const SETTINGS_PERMISSION_NOTICE =
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ {
id: "runtime-connections", id: "runtimes",
label: "Runtime Connections", label: "Runtimes",
status: "editable", status: "editable",
summary: summary:
"Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.", "Register and inspect Workspace Runtimes, verify connectivity, and open their Workdir inventory from one resource list.",
bullets: [ bullets: [
"Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.", "Embedded and remote Runtimes share one canonical Workspace resource representation.",
"The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.", "Remote Runtime creation, connection tests, and guarded deletion use the same REST collection.",
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", "Runtime status, worker creation availability, diagnostics, and Workdir inventory remain visible without exposing endpoints or credentials.",
],
},
{
id: "runtime-inventory",
label: "Runtime Inventory",
status: "read-only",
summary:
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
bullets: [
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
], ],
}, },
{ {
@@ -136,18 +78,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.", "Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
], ],
}, },
{
id: "backend-config",
label: "Backend Config",
status: "placeholder",
summary:
"General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.",
bullets: [
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.",
"Missing-provider or invalid-config states should be displayed as typed diagnostics.",
"No fake permission model is created to make unrelated config editing appear available.",
],
},
{ {
id: "workspace-identity", id: "workspace-identity",
label: "Workspace Identity", label: "Workspace Identity",
@@ -169,22 +99,20 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
"Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, 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: "Live Runtime resources",
body: body:
"Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.", "Remote Runtime create/delete operations update persisted configuration and the live Runtime registry through one canonical REST resource.",
}, },
{ {
title: "Typed Runtime surface only", title: "Typed Runtime surface only",
body: body:
"Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.", "The Runtime REST resource exposes embedded and remote inventory, guarded create/delete/test operations, and Workdir links without broader Backend admin controls.",
}, },
]; ];
export function settingsSectionHref(id: SettingsSectionId): string { export function settingsSectionHref(id: SettingsSectionId): string {
switch (id) { switch (id) {
case "runtime-connections": case "runtimes":
return `${SETTINGS_ROUTE}/runtime-connections`;
case "runtime-inventory":
return `${SETTINGS_ROUTE}/runtimes`; return `${SETTINGS_ROUTE}/runtimes`;
case "configuration-sources": case "configuration-sources":
return `${SETTINGS_ROUTE}/configuration`; return `${SETTINGS_ROUTE}/configuration`;
@@ -194,8 +122,6 @@ export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}/profiles`; return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity": case "workspace-identity":
return `${SETTINGS_ROUTE}/workspace`; return `${SETTINGS_ROUTE}/workspace`;
case "backend-config":
return `${SETTINGS_ROUTE}/backend`;
} }
} }
@@ -0,0 +1,45 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
function sectionHref(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(href: string): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`);
}
</script>
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="nav-section">
<div class="section-heading">
<h2>Settings</h2>
</div>
<div class="sidebar-list">
<a
class:active={currentPath === settingsHref}
class="sidebar-link"
href={settingsHref}
aria-current={currentPath === settingsHref ? 'page' : undefined}
>
<span class="sidebar-link-label">Overview</span>
</a>
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a
class:active={isActive(href)}
class="sidebar-link"
href={href}
aria-current={isActive(href) ? 'page' : undefined}
>
<span class="sidebar-link-label">{section.label}</span>
</a>
{/each}
</div>
</div>
</nav>
@@ -8,8 +8,5 @@
const { sidebar }: Props = $props(); const { sidebar }: Props = $props();
const controller = getSidebarController(); const controller = getSidebarController();
$effect(() => { $effect(() => controller.registerSidebar(sidebar));
controller.setSidebar(sidebar);
return () => controller.clearSidebar(sidebar);
});
</script> </script>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import './sidebar.css'; import './sidebar.css';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte'; import MemoryNavSection from './MemoryNavSection.svelte';
@@ -12,9 +13,15 @@
workspace: WorkspaceResponse | null; workspace: WorkspaceResponse | null;
workspaceError?: string | null; workspaceError?: string | null;
currentPath?: string; currentPath?: string;
content?: Snippet | null;
}; };
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props(); let {
workspace,
workspaceError = null,
currentPath = '/',
content = null,
}: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? ''); let workspaceId = $derived(workspace?.workspace_id ?? '');
</script> </script>
@@ -38,6 +45,9 @@
{/if} {/if}
</header> </header>
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Workspace sections"> <nav class="sidebar-sections" aria-label="Workspace sections">
<TicketsNavSection {currentPath} {workspaceId} /> <TicketsNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} /> <ObjectivesNavSection {currentPath} {workspaceId} />
@@ -45,4 +55,5 @@
<MergeRequestsNavSection {currentPath} {workspaceId} /> <MergeRequestsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} /> <WorkersNavSection {currentPath} {workspaceId} />
</nav> </nav>
{/if}
</div> </div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { getContext, type Snippet } from 'svelte';
import {
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
type WorkspaceSidebarContentController,
} from './workspace-content-context';
let { content }: { content: Snippet } = $props();
const controller = getContext<WorkspaceSidebarContentController>(
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
);
$effect(() => controller.registerContent(content));
</script>
@@ -4,8 +4,7 @@ import type { Snippet } from "svelte";
export type SidebarSnippet = Snippet<[]>; export type SidebarSnippet = Snippet<[]>;
export type SidebarController = { export type SidebarController = {
setSidebar(snippet: SidebarSnippet): void; registerSidebar(sidebar: SidebarSnippet): () => void;
clearSidebar(snippet: SidebarSnippet): void;
}; };
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context"); export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
@@ -0,0 +1,132 @@
import { createOverrideStack } from "./override-stack.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFile(path: string | URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals<T>(actual: T, expected: T, message: string): void {
if (!Object.is(actual, expected)) {
throw new Error(
`${message}: expected ${String(expected)}, received ${String(actual)}`,
);
}
}
Deno.test("nested sidebar cleanup restores the parent override", () => {
let active: string | null = null;
const stack = createOverrideStack<string>((value) => {
active = value;
});
const clearWorkspace = stack.register("workspace");
assertEquals(active, "workspace", "workspace sidebar should become active");
const clearSettings = stack.register("settings");
assertEquals(
active,
"settings",
"child settings sidebar should override its parent",
);
clearSettings();
assertEquals(
active,
"workspace",
"removing the child should restore its parent",
);
clearWorkspace();
assertEquals(
active,
null,
"removing the final override should restore the global sidebar",
);
});
Deno.test("sidebar disposers remove only their own registration", () => {
let active: string | null = null;
const stack = createOverrideStack<string>((value) => {
active = value;
});
const clearWorkspace = stack.register("workspace");
const clearSettings = stack.register("settings");
clearWorkspace();
assertEquals(
active,
"settings",
"removing an inactive parent should preserve the active child",
);
clearWorkspace();
assertEquals(active, "settings", "a disposer should be idempotent");
clearSettings();
assertEquals(
active,
null,
"removing the remaining child should empty the stack",
);
});
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
const layoutUrl = new URL(
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
import.meta.url,
);
const workspaceLayoutUrl = new URL(
"../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
);
const workspaceSidebarUrl = new URL(
"./WorkspaceSidebar.svelte",
import.meta.url,
);
const settingsContentUrl = new URL(
"./SettingsSidebarContent.svelte",
import.meta.url,
);
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
await Promise.all([
Deno.readTextFile(layoutUrl),
Deno.readTextFile(workspaceLayoutUrl),
Deno.readTextFile(workspaceSidebarUrl),
Deno.readTextFile(settingsContentUrl),
]);
assert(
layout.includes(
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
),
"settings layout should override the WorkspaceSidebar content slot",
);
assert(
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
"settings layout should not replace the whole sidebar or retain inline navigation",
);
assert(
workspaceLayout.includes(
"registerContent: sidebarContentOverrides.register",
) &&
workspaceLayout.includes("content={sidebarContent}"),
"workspace layout should provide and project the active child content",
);
assert(
workspaceSidebar.includes("<WorkspaceSwitcher") &&
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
workspaceSidebar.indexOf("{#if content}") &&
workspaceSidebar.includes("{@render content()}"),
"WorkspaceSidebar should retain its header and render child content below it",
);
assert(
settingsContent.includes("SETTINGS_SECTIONS") &&
settingsContent.includes('aria-label="Settings sections"'),
"SettingsSidebarContent should render the authoritative settings section catalog",
);
});
@@ -0,0 +1,28 @@
export type OverrideDisposer = () => void;
export type OverrideStack<T> = {
register(value: T): OverrideDisposer;
};
export function createOverrideStack<T>(
setActive: (value: T | null) => void,
): OverrideStack<T> {
let entries: Array<{ id: symbol; value: T }> = [];
return {
register(value: T): OverrideDisposer {
const id = Symbol();
let disposed = false;
entries = [...entries, { id, value }];
setActive(value);
return () => {
if (disposed) return;
disposed = true;
entries = entries.filter((entry) => entry.id !== id);
setActive(entries.at(-1)?.value ?? null);
};
},
};
}
@@ -39,6 +39,13 @@ export type Runtime = {
os: string; os: string;
arch: string; arch: string;
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
management?: {
built_in: boolean;
config_managed: boolean;
removable: boolean;
endpoint_configured: boolean;
token_ref_configured: boolean;
};
}; };
export type Host = { export type Host = {
@@ -0,0 +1,9 @@
import type { Snippet } from "svelte";
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
"workspace-sidebar-content",
);
export type WorkspaceSidebarContentController = {
registerContent(content: Snippet): () => void;
};
@@ -16,7 +16,6 @@
gap: var(--space-5); gap: var(--space-5);
} }
.settings-hero,
.settings-notice, .settings-notice,
.settings-section-header, .settings-section-header,
.settings-patterns { .settings-patterns {
@@ -183,7 +182,6 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.settings-hero,
.settings-notice, .settings-notice,
.settings-section-header, .settings-section-header,
.settings-patterns { .settings-patterns {
@@ -345,18 +343,11 @@
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
} }
.settings-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.settings-nav a,
.settings-section-card, .settings-section-card,
.button-link { .button-link {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.settings-nav a,
.settings-section-card { .settings-section-card {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
@@ -366,14 +357,10 @@
border-radius: var(--radius-panel); border-radius: var(--radius-panel);
background: var(--bg-raised); background: var(--bg-raised);
} }
.settings-nav a:hover,
.settings-nav a:focus-visible,
.settings-nav a.active,
.settings-section-card:hover, .settings-section-card:hover,
.settings-section-card:focus-visible { .settings-section-card:focus-visible {
background: var(--interactive-hover); background: var(--interactive-hover);
} }
.settings-nav span,
.settings-section-card h3 { .settings-section-card h3 {
color: var(--text-strong); color: var(--text-strong);
font-weight: 800; font-weight: 800;
+7 -8
View File
@@ -5,22 +5,21 @@
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context'; import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let sidebar = $state<SidebarSnippet | null>(null); let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
sidebar = activeSidebar;
});
const headerController = $state<HeaderController>({ content: null }); const headerController = $state<HeaderController>({ content: null });
provideHeaderController(headerController); provideHeaderController(headerController);
setContext(SIDEBAR_CONTEXT, { setContext<SidebarController>(SIDEBAR_CONTEXT, {
setSidebar(snippet: SidebarSnippet) { registerSidebar: sidebarOverrides.register,
sidebar = snippet;
},
clearSidebar(snippet: SidebarSnippet) {
if (sidebar === snippet) sidebar = null;
},
}); });
</script> </script>
@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte'; import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte'; import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte'; import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import {
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
type WorkspaceSidebarContentController,
} from '$lib/workspace/sidebar/workspace-content-context';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer'; import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css'; import '$lib/workspace/styles/workspace-pages.css';
@@ -11,6 +17,15 @@
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props(); let { data, children }: LayoutProps = $props();
let sidebarContent = $state<Snippet | null>(null);
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
registerContent: sidebarContentOverrides.register,
});
$effect(() => { $effect(() => {
const workspaceId = data.workspace?.workspace_id; const workspaceId = data.workspace?.workspace_id;
if (!workspaceId) return; if (!workspaceId) return;
@@ -27,6 +42,7 @@
workspace={data.workspace ?? null} workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null} workspaceError={data.workspaceError ?? null}
currentPath={page.url.pathname} currentPath={page.url.pathname}
content={sidebarContent}
/> />
{/snippet} {/snippet}
@@ -49,8 +49,8 @@
<small>Read typed Ticket records</small> <small>Read typed Ticket records</small>
</a> </a>
<a class="workspace-action-card" href={runtimeSettingsHref}> <a class="workspace-action-card" href={runtimeSettingsHref}>
<span>Runtime Inventory</span> <span>Runtimes</span>
<strong>Open admin runtime inventory</strong> <strong>Open admin Runtimes</strong>
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small> <small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
</a> </a>
<a class="workspace-action-card" href={workersHref}> <a class="workspace-action-card" href={workersHref}>
@@ -1,47 +1,22 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { workspaceRoute } from '$lib/workspace/api/http'; import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model'; import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
import '$lib/workspace/styles/settings.css'; import '$lib/workspace/styles/settings.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
function sectionHref(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(path: string): boolean {
const href = sectionHref(path);
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
</script> </script>
{#snippet settingsSidebarContent()}
<SettingsSidebarContent
workspaceId={page.params.workspaceId ?? ''}
currentPath={page.url.pathname}
/>
{/snippet}
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
<section class="settings-page"> <section class="settings-page">
<div class="settings-hero">
<p class="eyebrow">Settings / Admin</p>
<h1>Workspace settings</h1>
<p>
Configure workspace metadata, runtime connections, and Decodal profile sources through the Backend.
</p>
</div>
<nav class="settings-nav" aria-label="Settings sections">
<a class:active={page.url.pathname === settingsHref} href={settingsHref}>
<span>Overview</span>
<small>Settings map</small>
</a>
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a class:active={isActive(settingsSectionHref(section.id))} href={href}>
<span>{section.label}</span>
<small>{section.status}</small>
</a>
{/each}
</nav>
{@render children()} {@render children()}
</section> </section>
@@ -1,12 +0,0 @@
<section class="card settings-section" aria-labelledby="backend-config-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">read-only</p>
<h2 id="backend-config-title">Backend config</h2>
</div>
<span class="badge warning">planned</span>
</header>
<p>
Backend-owned config is exposed through typed settings APIs. Additional Backend config surfaces will be added as they become editable product settings.
</p>
</section>
@@ -1,371 +0,0 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace/api/http';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import type {
Diagnostic,
RemoteRuntimeTestResponse,
RuntimeConnectionMutationResponse,
RuntimeConnectionSettingsResponse
} from '$lib/workspace/settings/model';
import type { PageProps } from './$types';
type RemoteAddForm = {
runtime_id: string;
display_name: string;
endpoint: string;
};
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
let runtimeLoading = $state(true);
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 showAddRuntimeForm = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: '',
display_name: '',
endpoint: ''
});
function settingsApiPath(path: string): string {
return workspaceApiPath(workspaceId, path);
}
$effect(() => {
if (!workspaceId) {
runtimeLoading = false;
return;
}
let cancelled = false;
async function loadRuntimeSettings() {
runtimeLoading = true;
runtimeError = null;
try {
const response = await fetch(settingsApiPath('/settings/runtime-connections'));
if (!response.ok) {
throw new Error(`runtime settings request failed (${response.status})`);
}
const responseData = (await response.json()) as RuntimeConnectionSettingsResponse;
if (!cancelled) {
runtimeSettings = responseData;
}
} catch (err) {
if (!cancelled) {
runtimeError = err instanceof Error ? err.message : 'runtime settings request failed';
}
} finally {
if (!cancelled) {
runtimeLoading = false;
}
}
}
loadRuntimeSettings();
return () => {
cancelled = true;
};
});
async function submitRemoteRuntime() {
submitting = true;
mutationMessage = null;
mutationDiagnostics = [];
try {
const response = await fetch(settingsApiPath('/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 responseData = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(responseData);
remoteForm = { runtime_id: '', display_name: '', endpoint: '' };
showAddRuntimeForm = false;
} 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(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`), {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'delete remote Runtime failed'));
}
const responseData = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(responseData);
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(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`), {
method: 'POST'
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'test remote Runtime failed'));
}
const responseData = (await response.json()) as RemoteRuntimeTestResponse;
tests = { ...tests, [runtimeId]: responseData };
} 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 capabilityOperations(test: RemoteRuntimeTestResponse, state: 'available' | 'unknown' | 'incompatible'): string[] {
const suffix = `:${state}`;
return test.capabilities
.filter((capability) => capability.endsWith(suffix))
.map((capability) => capability.slice(0, -suffix.length));
}
function applyRuntimeMutation(responseData: RuntimeConnectionMutationResponse) {
runtimeSettings = runtimeSettings
? { ...runtimeSettings, remotes: responseData.remotes, diagnostics: responseData.diagnostics }
: {
workspace_id: responseData.workspace_id,
embedded: {
runtime_id: 'embedded-worker-runtime',
display_name: 'Embedded Runtime',
kind: 'embedded_worker_runtime',
built_in: true,
config_managed: false,
active: false,
worker_creation_available: false,
restart_required: false,
status: 'unknown',
diagnostics: []
},
remotes: responseData.remotes,
diagnostics: responseData.diagnostics
};
mutationDiagnostics = responseData.diagnostics;
mutationMessage = responseData.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>
<svelte:head>
<title>Runtime Connections · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" 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>Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.</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}
<div class="settings-action-row">
<button type="button" onclick={() => (showAddRuntimeForm = !showAddRuntimeForm)}>
{showAddRuntimeForm ? 'Cancel adding Runtime' : 'Add remote Runtime'}
</button>
</div>
{#if showAddRuntimeForm}
<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}
{#if mutationMessage}
<p class="status-message" class:error={mutationMessage.includes('failed')}>{mutationMessage}</p>
{/if}
<DiagnosticsList diagnostics={mutationDiagnostics} />
<div class="settings-runtime-list" aria-label="Runtime connections">
<h3>Runtimes</h3>
<div class="settings-runtime-table-wrap">
<table class="settings-runtime-table">
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Source</th>
<th scope="col">Connection</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr class:inactive={!runtimeSettings.embedded.active}>
<td>
<strong>{runtimeSettings.embedded.display_name}</strong>
<code>{runtimeSettings.embedded.runtime_id}</code>
</td>
<td>
<strong>embedded</strong>
<span>Workspace backend process</span>
</td>
<td>Local Backend runtime</td>
<td>
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
{#if runtimeSettings.embedded.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td><span class="settings-muted-action">Managed by backend</span></td>
</tr>
{#if runtimeSettings.embedded.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5"><DiagnosticsList diagnostics={runtimeSettings.embedded.diagnostics} /></td>
</tr>
{/if}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<tr class:inactive={!remote.active}>
<td>
<strong>{remote.display_name}</strong>
<code>{remote.runtime_id}</code>
</td>
<td>
<strong>remote</strong>
<span>Configured Runtime endpoint</span>
</td>
<td>
<span>Endpoint: {remote.endpoint_configured ? 'configured' : 'not configured'}</span>
{#if remote.endpoint_configured}<small>hidden</small>{/if}
<span>Token: {remote.token_ref_configured ? 'configured' : 'not configured'}</span>
</td>
<td>
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
{#if remote.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td>
<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>
</td>
</tr>
{#if remote.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5"><DiagnosticsList diagnostics={remote.diagnostics} /></td>
</tr>
{/if}
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
{@const available = capabilityOperations(test, 'available')}
{@const unchecked = capabilityOperations(test, 'unknown')}
<tr class="settings-runtime-detail-row">
<td colspan="5">
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{#if available.length > 0}
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
{/if}
{#if unchecked.length > 0}
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
{/if}
<DiagnosticsList diagnostics={test.diagnostics} />
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{/if}
</div>
{/if}
</section>
@@ -1,27 +1,161 @@
<script lang="ts"> <script lang="ts">
import type { Runtime } from '$lib/workspace/sidebar/types'; import { invalidateAll } from '$app/navigation';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type { Diagnostic, Runtime } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
type ConnectionTest = {
runtime_id: string;
checked_at: string;
state: string;
protocol_version?: string | null;
compatibility_basis: string;
capabilities: string[];
health_result: string;
diagnostics: Diagnostic[];
};
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let runtimeId = $state('');
let displayName = $state('');
let endpoint = $state('');
let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null);
let testResults = $state<Record<string, ConnectionTest>>({});
function runtimePlatform(runtime: Runtime): string { function runtimePlatform(runtime: Runtime): string {
return `${runtime.os} / ${runtime.arch}`; return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
}
function managementLabel(runtime: Runtime): string {
if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote';
return 'Observed';
}
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
}
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
busyRuntimeId = 'create';
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName || null,
endpoint,
}),
});
if (!response.ok) throw new Error(await responseError(response));
runtimeId = '';
displayName = '';
endpoint = '';
showAddRuntime = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function deleteRuntime(runtime: Runtime): Promise<void> {
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
const response = await fetch(
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error(await responseError(response));
const nextResults = { ...testResults };
delete nextResults[runtime.runtime_id];
testResults = nextResults;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function testRuntime(runtime: Runtime): Promise<void> {
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
const response = await fetch(
workspaceApiPath(
data.workspaceId,
`/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests`,
),
{ method: 'POST' },
);
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json() as ConnectionTest;
testResults = { ...testResults, [runtime.runtime_id]: result };
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Runtime Inventory · Settings · Yoi Workspace</title> <title>Runtimes · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime inventory" /> <meta name="description" content="Workspace Runtime resources" />
</svelte:head> </svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading"> <section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row"> <header class="page-header-row">
<div> <div>
<h1 id="runtimes-heading">Runtime Inventory</h1> <h1 id="runtimes-heading">Runtimes</h1>
<p>Admin view of execution backends available to this workspace.</p> <p>Register and inspect the execution backends available to this Workspace.</p>
</div> </div>
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
</header> </header>
{#if showAddRuntime}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<div class="settings-form-grid">
<label>
Runtime ID
<input bind:value={runtimeId} required autocomplete="off" />
</label>
<label>
Display name
<input bind:value={displayName} autocomplete="off" />
</label>
<label>
Endpoint
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<div class="settings-action-row">
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel
</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if data.runtimesError} {#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p> <p class="section-state error">{data.runtimesError}</p>
{:else if !data.runtimes} {:else if !data.runtimes}
@@ -29,20 +163,22 @@
{:else if data.runtimes.items.length === 0} {:else if data.runtimes.items.length === 0}
<p class="section-state">No Runtimes are visible.</p> <p class="section-state">No Runtimes are visible.</p>
{:else} {:else}
<div class="table-wrap"> <div class="settings-runtime-table-wrap">
<table class="runtimes-table"> <table class="settings-runtime-table">
<thead> <thead>
<tr> <tr>
<th>Runtime</th> <th>Runtime</th>
<th>Kind</th> <th>Kind</th>
<th>Status</th> <th>Status</th>
<th>Platform</th> <th>Platform</th>
<th>Management</th>
<th>Workdirs</th> <th>Workdirs</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.runtimes.items as runtime} {#each data.runtimes.items as runtime}
<tr> <tr class:inactive={runtime.status !== 'active'}>
<td> <td>
<strong>{runtime.label}</strong> <strong>{runtime.label}</strong>
<small><code>{runtime.runtime_id}</code></small> <small><code>{runtime.runtime_id}</code></small>
@@ -50,12 +186,58 @@
<td>{runtime.kind}</td> <td>{runtime.kind}</td>
<td>{runtime.status}</td> <td>{runtime.status}</td>
<td>{runtimePlatform(runtime)}</td> <td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td>
<td> <td>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}> <a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs Open workdirs
</a> </a>
</td> </td>
<td>
<div class="settings-action-row">
{#if runtime.management?.config_managed}
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if runtime.management?.removable}
<button
class="danger"
type="button"
disabled={busyRuntimeId !== null}
onclick={() => deleteRuntime(runtime)}
>Delete</button>
{:else}
<span class="settings-muted-action">Not removable</span>
{/if}
</div>
</td>
</tr> </tr>
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]}
<tr class="settings-runtime-detail-row">
<td colspan="7">
{#if runtime.diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{#if testResults[runtime.runtime_id]}
{@const result = testResults[runtime.runtime_id]}
<div class="settings-test-result">
<strong>Connection test: {result.state}</strong>
<span>{result.health_result}</span>
<small>{result.compatibility_basis} · {result.checked_at}</small>
</div>
{/if}
</td>
</tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
@@ -107,7 +107,7 @@
<section class="workdirs-page" aria-labelledby="workdirs-heading"> <section class="workdirs-page" aria-labelledby="workdirs-heading">
<header class="page-header-row"> <header class="page-header-row">
<div> <div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p> <p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1> <h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p> <p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if} {#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}