diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 99ce7440..1b354fdc 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -237,6 +237,7 @@ pub enum RuntimeSourceStatus { #[serde(rename_all = "snake_case")] pub enum RuntimeIdentityAuthority { RuntimeRegistryProjection, + ServerRuntimeConfiguration, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -263,6 +264,46 @@ pub struct RuntimeSummary { pub diagnostics: Vec, } +#[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, + pub endpoint: String, + pub token_ref: Option, +} + +#[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, + pub compatibility_basis: String, + #[serde(default)] + pub capabilities: Vec, + pub health_result: String, + #[serde(default)] + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerWorkspaceSummary { pub visibility: String, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ef2ec0ab..5f32a7cb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -58,12 +58,13 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ - CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, - DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, - ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, - RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust, - RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, + CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, + DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, + ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, + ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, + RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, + RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource, }; use crate::auth::{ @@ -2428,7 +2429,18 @@ fn build_inner_router(api: WorkspaceApi) -> Router { get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory), ) .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( "/api/workers", 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", 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( "/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", post(scoped_post_internal_runtime_resource_fetch), @@ -2958,66 +2938,6 @@ pub struct WorkerRetentionResponse { pub retention_state: String, } -#[derive(Debug, Serialize, Deserialize)] -pub struct RuntimeConnectionSettingsResponse { - pub workspace_id: String, - pub embedded: RuntimeConnectionSummary, - pub remotes: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RuntimeConnectionSummary { - pub runtime_id: String, - pub display_name: String, - pub kind: String, - pub built_in: bool, - pub config_managed: bool, - pub active: bool, - pub worker_creation_available: bool, - pub restart_required: bool, - pub status: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RemoteRuntimeConnectionSummary { - #[serde(flatten)] - pub summary: RuntimeConnectionSummary, - pub endpoint_configured: bool, - pub token_ref_configured: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RuntimeConnectionMutationResponse { - pub workspace_id: String, - pub restart_required: bool, - pub remotes: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AddRemoteRuntimeConnectionRequest { - pub runtime_id: String, - pub display_name: Option, - pub endpoint: String, - pub token_ref: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RemoteRuntimeTestResponse { - pub workspace_id: String, - pub runtime_id: String, - pub checked_at: String, - pub state: String, - pub protocol_version: Option, - pub compatibility_basis: String, - pub capabilities: Vec, - pub health_result: String, - pub diagnostics: Vec, -} - #[derive(Debug, Serialize, Deserialize)] pub struct WorkerLaunchOptionsResponse { pub workspace_id: String, @@ -7961,9 +7881,13 @@ async fn scoped_get_profile_source_archive( async fn scoped_list_runtimes( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { 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( @@ -9833,37 +9757,29 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError { .into() } -async fn scoped_get_runtime_connection_settings( +async fn scoped_create_remote_runtime( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { 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( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - add_remote_runtime_connection(State(api), Json(request)).await -} - -async fn scoped_delete_remote_runtime_connection( +async fn scoped_delete_remote_runtime( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult { 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, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { 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( @@ -11154,27 +11070,17 @@ async fn list_workers( workers_response(api).map(Json) } -async fn get_runtime_connection_settings( +async fn create_remote_runtime( State(api): State, -) -> ApiResult> { - let runtime_config = load_backend_runtimes_config_for_settings(&api)?; - Ok(Json(runtime_connection_settings_response( - &api, - &runtime_config, - ))) -} - -async fn add_remote_runtime_connection( - State(api): State, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { validate_runtime_connection_request(&request)?; let mut runtime_config = load_backend_runtimes_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", + "the embedded Runtime is built in and cannot be managed as a remote Runtime", )); } if request @@ -11184,7 +11090,7 @@ async fn add_remote_runtime_connection( { return Err(settings_bad_request( "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 @@ -11195,11 +11101,11 @@ async fn add_remote_runtime_connection( { return Err(settings_bad_request( "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 { - id, + id: id.clone(), endpoint: request.endpoint.trim().to_string(), display_name: request .display_name @@ -11232,31 +11138,19 @@ async fn add_remote_runtime_connection( runtime_config.runtimes.remote.push(remote_config); write_backend_runtimes_config_for_settings(&api, &runtime_config)?; api.runtime.register_or_replace(active_runtime); - let mut response = runtime_connection_mutation_response( - &api, - &runtime_config, - 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)) + let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id) + .ok_or_else(|| Error::UnknownRuntime(id.clone()))?; + Ok((StatusCode::CREATED, Json(resource))) } -async fn delete_remote_runtime_connection( +async fn delete_remote_runtime( State(api): State, AxumPath(runtime_id): AxumPath, -) -> ApiResult> { +) -> ApiResult { if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { return Err(settings_bad_request( "embedded_runtime_not_config_managed", - "the embedded Runtime is built in and cannot be deleted from remote Runtime config", + "the embedded Runtime is built in and cannot be deleted", )); } 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", DiagnosticSeverity::Error, 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( Error::RuntimeOperationFailed { runtime_id, 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, )); } } write_backend_runtimes_config_for_settings(&api, &runtime_config)?; - let mut response = runtime_connection_mutation_response( - &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)) + Ok(StatusCode::NO_CONTENT) } -async fn test_remote_runtime_connection( +async fn test_runtime_connection( State(api): State, AxumPath(runtime_id): AxumPath, -) -> ApiResult> { +) -> ApiResult> { let runtime_config = load_backend_runtimes_config_for_settings(&api)?; let remote = runtime_config .runtimes @@ -13218,142 +13098,112 @@ fn write_backend_runtimes_config_for_settings( }) } -fn runtime_connection_settings_response( +fn workspace_runtime_resources_response( api: &WorkspaceApi, runtime_config: &BackendRuntimesConfigFile, -) -> RuntimeConnectionSettingsResponse { - RuntimeConnectionSettingsResponse { - workspace_id: api.config.workspace_id.clone(), - embedded: embedded_runtime_connection_summary(api), - remotes: remote_runtime_connection_summaries(api, runtime_config, false), - diagnostics: Vec::new(), - } -} - -fn runtime_connection_mutation_response( - api: &WorkspaceApi, - runtime_config: &BackendRuntimesConfigFile, - diagnostics: Vec, -) -> 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)) +) -> workspace_api::ListResponse { + let limit = api.config.max_records.min(200); + let runtimes = api.runtime.list_runtimes(limit); + let mut items = runtimes .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", - 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 { - let live_runtimes = api - .runtime - .list_runtimes(api.config.max_records.min(200)) - .items; - runtime_config - .runtimes - .remote - .iter() - .map(|remote| { - let live = live_runtimes + .map(|runtime| { + let remote = runtime_config + .runtimes + .remote .iter() - .find(|runtime| runtime.runtime_id == remote.id); - let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live { - Some(runtime) => ( - runtime.label.clone(), - runtime.kind.clone(), - runtime.status == "active", - runtime.worker_creation_available, - 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, - worker_creation_available, - restart_required, - status, - diagnostics, + .find(|remote| remote.id == runtime.runtime_id); + let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; + WorkspaceRuntimeResource { + runtime: runtime.into(), + management: RuntimeManagementSummary { + built_in, + config_managed: remote.is_some(), + removable: remote.is_some() && !built_in, + endpoint_configured: remote + .is_some_and(|remote| !remote.endpoint.trim().is_empty()), + token_ref_configured: remote.is_some_and(|remote| { + remote + .token_ref + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }), }, + } + }) + .collect::>(); + + 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 + .clone() + .unwrap_or_else(|| remote.id.clone()), + kind: "remote_http".to_string(), + status: "unavailable".to_string(), + source: workspace_api::RuntimeSourceSummary { + kind: workspace_api::RuntimeSourceKind::RemoteHttp, + status: workspace_api::RuntimeSourceStatus::Reserved, + identity_authority: + workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, + note: "The configured Runtime is not present in the active Runtime registry." + .to_string(), + }, + host_ids: Vec::new(), + worker_creation_available: false, + os: String::new(), + arch: String::new(), + diagnostics: vec![ + settings_diagnostic( + "configured_runtime_unavailable", + DiagnosticSeverity::Warning, + "The configured Runtime is not present in the active Runtime registry.", + ) + .into(), + ], + }, + management: RuntimeManagementSummary { + built_in: false, + config_managed: true, + removable: true, endpoint_configured: !remote.endpoint.trim().is_empty(), token_ref_configured: remote .token_ref .as_deref() .is_some_and(|value| !value.trim().is_empty()), - } - }) - .collect() + }, + }); + } + + 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(), + } } -fn validate_runtime_connection_request( - request: &AddRemoteRuntimeConnectionRequest, -) -> ApiResult<()> { +fn workspace_runtime_resource_by_id( + api: &WorkspaceApi, + runtime_config: &BackendRuntimesConfigFile, + runtime_id: &str, +) -> Option { + 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())?; let endpoint = request.endpoint.trim(); 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( api: &WorkspaceApi, remote: &RemoteRuntimeConfigFile, -) -> RemoteRuntimeTestResponse { +) -> RuntimeConnectionTestResponse { 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 { + return RuntimeConnectionTestResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: remote.id.clone(), checked_at, @@ -13431,7 +13281,8 @@ async fn test_remote_runtime_config( "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.", - )], + ) + .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.", ); - RemoteRuntimeTestResponse { + RuntimeConnectionTestResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: remote.id.clone(), checked_at, @@ -13705,7 +13556,11 @@ async fn test_remote_runtime_config( observation.incompatible_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, code: impl Into, message: impl Into, -) -> RemoteRuntimeTestResponse { - RemoteRuntimeTestResponse { +) -> RuntimeConnectionTestResponse { + RuntimeConnectionTestResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: remote.id.clone(), checked_at, @@ -13725,11 +13580,7 @@ fn remote_runtime_test_failed( compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(), capabilities: Vec::new(), health_result: "failed".to_string(), - diagnostics: vec![settings_diagnostic( - code, - DiagnosticSeverity::Error, - message, - )], + diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()], } } @@ -17046,7 +16897,7 @@ mod tests { #[test] fn runtime_connection_request_validation_bounds_browser_input() { - let ok = AddRemoteRuntimeConnectionRequest { + let ok = CreateRemoteRuntimeRequest { runtime_id: "team-runtime_1".to_string(), display_name: Some("Team Runtime".to_string()), endpoint: "https://runtime.example".to_string(), @@ -17054,7 +16905,7 @@ mod tests { }; assert!(validate_runtime_connection_request(&ok).is_ok()); - let bad_endpoint = AddRemoteRuntimeConnectionRequest { + let bad_endpoint = CreateRemoteRuntimeRequest { endpoint: "/tmp/socket".to_string(), ..ok }; @@ -22698,34 +22549,52 @@ mod tests { } #[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 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; - assert_eq!(settings["embedded"]["built_in"], true); - assert_eq!(settings["embedded"]["config_managed"], false); - - let added = post_json( + let initial = get_json(app.clone(), &runtimes_uri).await; + let embedded = initial["items"] + .as_array() + .unwrap() + .iter() + .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(), - "/api/settings/runtime-connections/remotes", - serde_json::json!({ + "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["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() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "runtime_registry_applied") - ); + 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(); assert!(!projected.contains("runtime.example.invalid")); @@ -22756,13 +22625,12 @@ mod tests { let deleted = request_json( app.clone(), "DELETE", - "/api/settings/runtime-connections/remotes/team-runtime", + &format!("{runtimes_uri}/team-runtime"), None, - StatusCode::OK, + StatusCode::NO_CONTENT, ) .await; - assert_eq!(deleted["restart_required"], false); - assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0); + assert_eq!(deleted["message"], ""); let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; assert!( !launch_options["runtimes"] @@ -22794,17 +22662,19 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let app = test_app(dir.path()).await; - let added = post_json( + let added = request_json( app.clone(), - "/api/settings/runtime-connections/remotes", - serde_json::json!({ + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"), + Some(serde_json::json!({ "runtime_id": "busy-runtime", "display_name": "Busy Runtime", "endpoint": format!("http://{runtime_addr}") - }), + })), + StatusCode::CREATED, ) .await; - assert_eq!(added["restart_required"], false); + assert_eq!(added["runtime_id"], "busy-runtime"); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] @@ -22818,7 +22688,7 @@ mod tests { let response = request_json( app, "DELETE", - "/api/settings/runtime-connections/remotes/busy-runtime", + &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"), None, StatusCode::CONFLICT, ) @@ -22876,7 +22746,7 @@ mod tests { let response = post_json( app, - "/api/settings/runtime-connections/remotes/probe-runtime/test", + &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), serde_json::json!({}), ) .await; @@ -22940,7 +22810,7 @@ mod tests { let response = post_json( 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!({}), ) .await; diff --git a/web/workspace/deno.json b/web/workspace/deno.json index cafd203e..2d157d72 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "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", "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", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 785d9128..d2a22c88 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -107,7 +107,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () "workspaceRoute(workspaceId, '/settings/runtimes')", ) && 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( !workspacePage.includes("workerConsoleHref") && @@ -599,21 +599,24 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async assert( !sidebar.includes("RuntimesNavSection") && - settingsModel.includes('id: "runtime-inventory"') && + settingsModel.includes('id: "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( - runtimesPage.includes("Runtime Inventory") && + runtimesPage.includes("Add remote Runtime") && runtimesPage.includes("Open workdirs") && - runtimesPage.includes("runtimes-table") && + runtimesPage.includes("settings-runtime-table") && + runtimesPage.includes( + "/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests", + ) && runtimesPage.includes( "/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( - workdirsPage.includes("Runtime Inventory") && + workdirsPage.includes(">Runtimes") && workdirsPage.includes("workdirs-table") && workdirsLoad.includes("/working-directories"), "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", ); assert( - sidebarOverride.includes("controller.setSidebar(sidebar)") && - sidebarOverride.includes("controller.clearSidebar(sidebar)"), - "SidebarOverride should register and clean up the child-provided sidebar snippet", + sidebarOverride.includes("controller.registerSidebar(sidebar)") && + rootLayout.includes("createOverrideStack") && + rootLayout.includes("registerSidebar: sidebarOverrides.register"), + "SidebarOverride should register a nested sidebar whose cleanup restores the parent override", ); assert( rootLayoutLoad.includes("export const load") && diff --git a/web/workspace/src/lib/workspace/settings/model.test.ts b/web/workspace/src/lib/workspace/settings/model.test.ts index a8ef3082..493fda14 100644 --- a/web/workspace/src/lib/workspace/settings/model.test.ts +++ b/web/workspace/src/lib/workspace/settings/model.test.ts @@ -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) => - section.id === "runtime-connections" + section.id === "runtimes" ); assert( runtimeSection?.status === "editable", - "Runtime Connections should be editable", + "Runtimes should be editable", ); const allText = [ @@ -91,13 +91,13 @@ Deno.test("runtime connections are editable without advertising raw authority le ].join("\n"); assert( - allText.includes("restart_required=true") || - allText.includes("Restart-required"), - "restart-required pattern should be visible", + allText.includes("canonical") && allText.includes("REST resource"), + "Runtime settings should describe the canonical REST resource", ); assert( - allText.includes("not echoed back") || allText.includes("not echoed"), - "endpoint submission should not imply endpoint echoing", + !allText.includes("restart_required") && + !allText.includes("Restart-required"), + "Runtime settings should not retain obsolete restart-required semantics", ); for ( @@ -120,12 +120,12 @@ Deno.test("runtime connections are editable without advertising raw authority le Deno.test("diagnostic labels preserve severity and code", () => { const diagnostic = { severity: "warning", - code: "runtime_registry_restart_required", - message: "Restart required.", + code: "configured_runtime_unavailable", + message: "Configured Runtime unavailable.", } as const; assert( diagnosticLabel(diagnostic) === - "warning: runtime_registry_restart_required", + "warning: configured_runtime_unavailable", "diagnostic label should be bounded and stable", ); }); diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index f6571ba8..620ee05f 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -5,18 +5,16 @@ export type Diagnostic = { }; export type SettingsSectionId = - | "runtime-connections" - | "runtime-inventory" + | "runtimes" | "configuration-sources" | "repository-access" | "profile-sources" - | "backend-config" | "workspace-identity"; export type SettingsSection = { readonly id: SettingsSectionId; readonly label: string; - readonly status: "editable" | "placeholder" | "read-only"; + readonly status: "editable" | "read-only"; readonly summary: string; readonly bullets: readonly string[]; }; @@ -26,50 +24,6 @@ export type SettingsPattern = { readonly body: string; }; -export type RuntimeConnectionSummary = { - runtime_id: string; - display_name: string; - kind: string; - built_in: boolean; - config_managed: boolean; - active: boolean; - 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_PERMISSION_NOTICE = @@ -77,27 +31,15 @@ export const SETTINGS_PERMISSION_NOTICE = export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { - id: "runtime-connections", - label: "Runtime Connections", + id: "runtimes", + label: "Runtimes", status: "editable", 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: [ - "Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.", - "The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.", - "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", - ], - }, - { - 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.", + "Embedded and remote Runtimes share one canonical Workspace resource representation.", + "Remote Runtime creation, connection tests, and guarded deletion use the same REST collection.", + "Runtime status, worker creation availability, diagnostics, and Workdir inventory remain visible without exposing endpoints or credentials.", ], }, { @@ -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.", ], }, - { - 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", 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.", }, { - title: "Restart-required changes", + title: "Live Runtime resources", 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", 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 { switch (id) { - case "runtime-connections": - return `${SETTINGS_ROUTE}/runtime-connections`; - case "runtime-inventory": + case "runtimes": return `${SETTINGS_ROUTE}/runtimes`; case "configuration-sources": return `${SETTINGS_ROUTE}/configuration`; @@ -194,8 +122,6 @@ export function settingsSectionHref(id: SettingsSectionId): string { return `${SETTINGS_ROUTE}/profiles`; case "workspace-identity": return `${SETTINGS_ROUTE}/workspace`; - case "backend-config": - return `${SETTINGS_ROUTE}/backend`; } } diff --git a/web/workspace/src/lib/workspace/sidebar/SettingsSidebarContent.svelte b/web/workspace/src/lib/workspace/sidebar/SettingsSidebarContent.svelte new file mode 100644 index 00000000..7111a479 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/SettingsSidebarContent.svelte @@ -0,0 +1,45 @@ + + + diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte index cfaeb2b7..5c841532 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte @@ -8,8 +8,5 @@ const { sidebar }: Props = $props(); const controller = getSidebarController(); - $effect(() => { - controller.setSidebar(sidebar); - return () => controller.clearSidebar(sidebar); - }); + $effect(() => controller.registerSidebar(sidebar)); diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index febd1ff8..6e83d8be 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -1,4 +1,5 @@ @@ -38,11 +45,15 @@ {/if} - + {#if content} + {@render content()} + {:else} + + {/if} diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte new file mode 100644 index 00000000..005f77de --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte @@ -0,0 +1,15 @@ + diff --git a/web/workspace/src/lib/workspace/sidebar/context.ts b/web/workspace/src/lib/workspace/sidebar/context.ts index b535ef09..168a6d4e 100644 --- a/web/workspace/src/lib/workspace/sidebar/context.ts +++ b/web/workspace/src/lib/workspace/sidebar/context.ts @@ -4,8 +4,7 @@ import type { Snippet } from "svelte"; export type SidebarSnippet = Snippet<[]>; export type SidebarController = { - setSidebar(snippet: SidebarSnippet): void; - clearSidebar(snippet: SidebarSnippet): void; + registerSidebar(sidebar: SidebarSnippet): () => void; }; export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context"); diff --git a/web/workspace/src/lib/workspace/sidebar/override-stack.test.ts b/web/workspace/src/lib/workspace/sidebar/override-stack.test.ts new file mode 100644 index 00000000..13d9d45a --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/override-stack.test.ts @@ -0,0 +1,132 @@ +import { createOverrideStack } from "./override-stack.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; + readTextFile(path: string | URL): Promise; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEquals(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((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((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( + "", + ), + "settings layout should override the WorkspaceSidebar content slot", + ); + assert( + !layout.includes(" void; + +export type OverrideStack = { + register(value: T): OverrideDisposer; +}; + +export function createOverrideStack( + setActive: (value: T | null) => void, +): OverrideStack { + 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); + }; + }, + }; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 3e252b7a..0cf9cf23 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -39,6 +39,13 @@ export type Runtime = { os: string; arch: string; diagnostics: Diagnostic[]; + management?: { + built_in: boolean; + config_managed: boolean; + removable: boolean; + endpoint_configured: boolean; + token_ref_configured: boolean; + }; }; export type Host = { diff --git a/web/workspace/src/lib/workspace/sidebar/workspace-content-context.ts b/web/workspace/src/lib/workspace/sidebar/workspace-content-context.ts new file mode 100644 index 00000000..d332bfdc --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/workspace-content-context.ts @@ -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; +}; diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index b388174c..5ed3cba6 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -16,7 +16,6 @@ gap: var(--space-5); } - .settings-hero, .settings-notice, .settings-section-header, .settings-patterns { @@ -183,7 +182,6 @@ } @media (max-width: 760px) { - .settings-hero, .settings-notice, .settings-section-header, .settings-patterns { @@ -345,18 +343,11 @@ display: grid; gap: var(--space-5); } - .settings-nav { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - } - .settings-nav a, .settings-section-card, .button-link { color: inherit; text-decoration: none; } - .settings-nav a, .settings-section-card { display: grid; gap: var(--space-1); @@ -366,14 +357,10 @@ border-radius: var(--radius-panel); background: var(--bg-raised); } - .settings-nav a:hover, - .settings-nav a:focus-visible, - .settings-nav a.active, .settings-section-card:hover, .settings-section-card:focus-visible { background: var(--interactive-hover); } - .settings-nav span, .settings-section-card h3 { color: var(--text-strong); font-weight: 800; diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 75d9ee6d..2f49f127 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -5,22 +5,21 @@ import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.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 type { LayoutProps } from './$types'; let { children }: LayoutProps = $props(); let sidebar = $state(null); + const sidebarOverrides = createOverrideStack((activeSidebar) => { + sidebar = activeSidebar; + }); const headerController = $state({ content: null }); provideHeaderController(headerController); - setContext(SIDEBAR_CONTEXT, { - setSidebar(snippet: SidebarSnippet) { - sidebar = snippet; - }, - clearSidebar(snippet: SidebarSnippet) { - if (sidebar === snippet) sidebar = null; - }, + setContext(SIDEBAR_CONTEXT, { + registerSidebar: sidebarOverrides.register, }); diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte index c934fac2..d5c8a66d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte @@ -1,8 +1,14 @@ +{#snippet settingsSidebarContent()} + +{/snippet} + + +
-
-

Settings / Admin

-

Workspace settings

-

- Configure workspace metadata, runtime connections, and Decodal profile sources through the Backend. -

-
- - - {@render children()}
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/backend/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/backend/+page.svelte deleted file mode 100644 index 750a58d9..00000000 --- a/web/workspace/src/routes/w/[workspaceId]/settings/backend/+page.svelte +++ /dev/null @@ -1,12 +0,0 @@ -
-
-
-

read-only

-

Backend config

-
- planned -
-

- Backend-owned config is exposed through typed settings APIs. Additional Backend config surfaces will be added as they become editable product settings. -

-
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtime-connections/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtime-connections/+page.svelte deleted file mode 100644 index 70b4fe10..00000000 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtime-connections/+page.svelte +++ /dev/null @@ -1,371 +0,0 @@ - - - - Runtime Connections · Yoi Workspace - - -
-
-
-

editable

-

Runtime Connections

-
- typed API -
-

Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.

- - {#if runtimeLoading} -

Loading Runtime connections…

- {:else if runtimeError} -

Runtime connection settings unavailable: {runtimeError}

- {:else if runtimeSettings} -
- -
- - {#if showAddRuntimeForm} -
{ event.preventDefault(); void submitRemoteRuntime(); }}> -

Add remote Runtime

-

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

- - - - -
- {/if} - - {#if mutationMessage} -

{mutationMessage}

- {/if} - - -
-

Runtimes

-
- - - - - - - - - - - - - - - - - - - {#if runtimeSettings.embedded.diagnostics.length > 0} - - - - {/if} - {#each runtimeSettings.remotes as remote (remote.runtime_id)} - - - - - - - - {#if remote.diagnostics.length > 0} - - - - {/if} - {#if tests[remote.runtime_id]} - {@const test = tests[remote.runtime_id]} - {@const available = capabilityOperations(test, 'available')} - {@const unchecked = capabilityOperations(test, 'unknown')} - - - - {/if} - {/each} - -
RuntimeSourceConnectionStatusActions
- {runtimeSettings.embedded.display_name} - {runtimeSettings.embedded.runtime_id} - - embedded - Workspace backend process - Local Backend runtime - {runtimeSettings.embedded.status} - {#if runtimeSettings.embedded.restart_required} - restart required - {/if} - Managed by backend
- {remote.display_name} - {remote.runtime_id} - - remote - Configured Runtime endpoint - - Endpoint: {remote.endpoint_configured ? 'configured' : 'not configured'} - {#if remote.endpoint_configured}hidden{/if} - Token: {remote.token_ref_configured ? 'configured' : 'not configured'} - - {remote.status} - {#if remote.restart_required} - restart required - {/if} - -
- - -
-
-
- Test: {test.state} - {test.health_result} · {test.checked_at} -

{test.compatibility_basis}

- {#if available.length > 0} -

Verified areas: {available.join(', ')}

- {/if} - {#if unchecked.length > 0} -

Unchecked warning areas: {unchecked.join(', ')}

- {/if} - -
-
-
- {#if runtimeSettings.remotes.length === 0} -

No remote Runtime connections configured.

- {/if} -
- {/if} -
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 66f4aa59..f1e3e25d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -1,27 +1,161 @@ - Runtime Inventory · Settings · Yoi Workspace - + Runtimes · Settings · Yoi Workspace +
-

Runtime Inventory

-

Admin view of execution backends available to this workspace.

+

Runtimes

+

Register and inspect the execution backends available to this Workspace.

+
+ {#if showAddRuntime} +
+

Add remote Runtime

+
+ + + +
+
+ + +
+
+ {/if} + + {#if requestError} +

{requestError}

+ {/if} + {#if data.runtimesError}

{data.runtimesError}

{:else if !data.runtimes} @@ -29,20 +163,22 @@ {:else if data.runtimes.items.length === 0}

No Runtimes are visible.

{:else} -
- +
+
+ + {#each data.runtimes.items as runtime} - + + + + {#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]} + + + + {/if} {/each}
Runtime Kind Status PlatformManagement WorkdirsActions
{runtime.label} {runtime.runtime_id} @@ -50,12 +186,58 @@ {runtime.kind} {runtime.status} {runtimePlatform(runtime)}{managementLabel(runtime)} Open workdirs +
+ {#if runtime.management?.config_managed} + + {/if} + {#if runtime.management?.removable} + + {:else} + Not removable + {/if} +
+
+ {#if runtime.diagnostics.length > 0} +
    + {#each runtime.diagnostics as diagnostic} +
  • + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+ {/if} + {#if testResults[runtime.runtime_id]} + {@const result = testResults[runtime.runtime_id]} +
+ Connection test: {result.state} + {result.health_result} + {result.compatibility_basis} · {result.checked_at} +
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index 62d58bb5..ad722bf2 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -107,7 +107,7 @@
- +

Workdirs

Workdirs owned by {data.runtimeId}.

{#if data.cleanupPlanError}

{data.cleanupPlanError}

{/if}