diff --git a/compose.yaml b/compose.yaml index 436f0e1e..164d42eb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -21,7 +21,7 @@ services: - "8787" volumes: - server-data:/server-data - - ./docker/workspace:/workspace:ro + - /etc/yoi/server.toml:/server-config/server.toml:ro webui: image: yoi-webui:latest diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 46207451..599344e0 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions { #[derive(Debug, Deserialize)] struct BackendWorkerLaunchRuntime { runtime_id: String, - can_spawn_worker: bool, + worker_creation_available: bool, working_directory_required: bool, } @@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient { let runtime = options .runtimes .iter() - .find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required) + .find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required) .ok_or_else(|| { BackendWorkspaceClientError::InvalidTarget( "Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker" @@ -777,7 +777,7 @@ mod tests { let (base_url, requests, handle) = response_sequence_server(vec![ ( "200 OK", - r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#, + r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#, ), ( "200 OK", diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 6156af44..5d7d82bf 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1405,7 +1405,7 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["event"], "completions"); assert_eq!(parsed["data"]["kind"], "file"); - assert_eq!(parsed["data"]["entries"][0]["value"], "clear"); + assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs"); // is_dir defaults to false on inbound payloads that omit it. let inbound = diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 60a402fa..6e139d07 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() { assert!( msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") + || msg.contains("outside allowed scope") || msg.contains("has not been read"), "symlink escape not rejected: {msg}" ); - if !msg.contains("has not been read") { + if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") { assert!( msg.contains("add the symlink target"), "symlink escape diagnostic should include remediation: {msg}" @@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() { ) .await .unwrap_err(); - assert!(format!("{err}").contains("invalid Workdir path")); + let msg = format!("{err}"); + assert!( + msg.contains("invalid logical filesystem path"), + "absolute path was not rejected as invalid: {msg}" + ); } #[tokio::test] async fn directory_target_is_rejected_for_read() { - let (dir, _spill, reg) = setup(); + let (_dir, _spill, reg) = setup(); let read = reg.get("Read"); let err = read .execute(&json!({ "file_path": "." }).to_string(), Default::default()) diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index 4f8e8aa6..f8761aa8 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() { #[tokio::test] async fn glob_finds_written_files() { - let (dir, _spill, reg) = setup(); + let (_dir, _spill, reg) = setup(); let write = reg.get("Write"); let glob = reg.get("Glob"); @@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() { .await; // Absolute paths are rejected at the logical WorkdirSession boundary. let msg = format!("{err}"); - assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}"); + assert!( + msg.contains("invalid logical filesystem path"), + "unexpected: {msg}" + ); } #[tokio::test] @@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() { )); let a = dir.path().join("a.txt"); - let b = dir.path().join("b.txt"); + let _b = dir.path().join("b.txt"); std::fs::write(&a, "one\n").unwrap(); // Read `a` — should appear in recency. diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 8e538bb3..11570500 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -983,6 +983,14 @@ where if feature_config.sub_worker.enabled { worker.register_worker_orchestration_instruction(); + if !feature_config.worker.enabled { + feature_registry.add_module( + crate::feature::builtin::manage_worker::sub_worker_control_feature( + worker.workspace_client_handle(), + spawned_registry.clone(), + ), + ); + } } let host_worker_observation_provider = worker.worker_observation_provider(); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index df165038..0b0306e9 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2494,7 +2494,10 @@ impl Worker { .map(ToOwned::to_owned) }) .unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string()); - return Err(WorkerError::FlowInput(message)); + return Err(WorkerError::FlowInput(format!( + "{message} (HTTP {})", + response.status + ))); } let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body) .map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?; diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2323cf03..3110fd60 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -587,6 +587,9 @@ model_id = "test-model" max_tokens = 100 [memory] +workspace_id = "test-workspace" +settings_revision = 1 +language = "English" extract_threshold = 1 [compaction] @@ -749,6 +752,9 @@ model_id = "test-model" max_tokens = 100 [memory] +workspace_id = "test-workspace" +settings_revision = 1 +language = "English" extract_threshold = 1 [[scope.allow]] diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 8e2d51c8..99ce7440 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -247,24 +247,6 @@ pub struct RuntimeSourceSummary { pub note: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilitySummary { - pub can_list_hosts: bool, - pub can_list_workers: bool, - pub can_get_worker: bool, - pub can_spawn_worker: bool, - pub can_stop_worker: bool, - pub has_workspace_fs: bool, - pub has_shell: bool, - pub has_git: bool, - pub supports_worktrees: bool, - pub supports_backend_internal_tools: bool, - pub workspace_scope: String, - pub max_workers: usize, - pub os: String, - pub arch: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeSummary { pub runtime_id: String, @@ -274,7 +256,9 @@ pub struct RuntimeSummary { pub source: RuntimeSourceSummary, #[serde(default)] pub host_ids: Vec, - pub capabilities: RuntimeCapabilitySummary, + pub worker_creation_available: bool, + pub os: String, + pub arch: String, #[serde(default)] pub diagnostics: Vec, } diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index de441fd4..af52b7d0 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -3,53 +3,52 @@ use std::path::{Path, PathBuf}; use std::{fs, io}; use serde::{Deserialize, Serialize}; +use url::Url; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; -use crate::repositories::ConfiguredRepository; use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; -pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; -pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = - include_str!("../../../resources/workspace-backend.default.toml"); +pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; -const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; -const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787"; -const DEFAULT_AUTH_RP_ID: &str = "localhost"; +const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173"; const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_MAX_RECORDS: usize = 200; -fn default_auth_rp_id() -> String { - DEFAULT_AUTH_RP_ID.to_string() -} - -fn default_auth_origin() -> String { - DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() -} - -fn default_auth_public_base_url() -> String { - DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() -} - -fn default_auth_cookie_name() -> String { - DEFAULT_AUTH_COOKIE_NAME.to_string() -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] -pub struct WorkspaceBackendConfigFile { +pub struct ServerHostConfigFile { #[serde(default)] - pub server: WorkspaceBackendServerConfig, - #[serde(default)] - pub data: WorkspaceBackendDataConfig, - #[serde(default)] - pub limits: WorkspaceBackendLimitsConfig, - #[serde(default)] - pub auth: WorkspaceBackendAuthConfig, - #[serde(default)] - pub repositories: Vec, + pub browser: ServerBrowserConfig, +} + +impl Default for ServerHostConfigFile { + fn default() -> Self { + Self { + browser: ServerBrowserConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ServerBrowserConfig { + #[serde(default = "default_browser_public_url")] + pub public_url: String, +} + +impl Default for ServerBrowserConfig { + fn default() -> Self { + Self { + public_url: default_browser_public_url(), + } + } +} + +fn default_browser_public_url() -> String { + DEFAULT_BROWSER_PUBLIC_URL.to_string() } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile { pub runtimes: WorkspaceBackendRuntimesConfig, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendServerConfig { - #[serde(default)] - pub listen: Option, - #[serde(default)] - pub frontend_url: Option, - #[serde(default)] - pub static_assets_dir: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendDataConfig { - #[serde(default)] - pub root: Option, - #[serde(default)] - pub workspace_database_path: Option, - #[serde(default)] - pub embedded_runtime_store_root: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendLimitsConfig { - #[serde(default)] - pub max_records: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendAuthConfig { - #[serde(default = "default_auth_rp_id")] - pub rp_id: String, - #[serde(default = "default_auth_origin")] - pub origin: String, - #[serde(default = "default_auth_public_base_url")] - pub public_base_url: String, - #[serde(default = "default_auth_cookie_name")] - pub cookie_name: String, -} - -impl Default for WorkspaceBackendAuthConfig { - fn default() -> Self { - Self { - rp_id: default_auth_rp_id(), - origin: default_auth_origin(), - public_base_url: default_auth_public_base_url(), - cookie_name: default_auth_cookie_name(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceRepositoryConfigFile { - pub id: String, - pub provider: String, - pub uri: String, - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub default_selector: Option, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { @@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile { pub token_ref: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigDiff { - pub differs: bool, - pub text: String, -} - -impl ConfigDiff { - fn new(default: &str, local: &str) -> Self { - if default == local { - return Self { - differs: false, - text: "workspace backend local config matches the packaged default\n".to_string(), - }; - } - - let mut text = String::from("--- packaged default\n+++ workspace local\n"); - let default_lines = default.lines().collect::>(); - let local_lines = local.lines().collect::>(); - let max = default_lines.len().max(local_lines.len()); - for index in 0..max { - match (default_lines.get(index), local_lines.get(index)) { - (Some(left), Some(right)) if left == right => { - text.push(' '); - text.push_str(left); - text.push('\n'); - } - (Some(left), Some(right)) => { - text.push('-'); - text.push_str(left); - text.push('\n'); - text.push('+'); - text.push_str(right); - text.push('\n'); - } - (Some(left), None) => { - text.push('-'); - text.push_str(left); - text.push('\n'); - } - (None, Some(right)) => { - text.push('+'); - text.push_str(right); - text.push('\n'); - } - (None, None) => {} - } - } - - Self { - differs: true, - text, - } - } -} - #[derive(Clone)] pub struct ResolvedWorkspaceBackendConfig { pub server: ServerConfig, @@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig { pub database_path: PathBuf, } +impl ServerHostConfigFile { + pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { + config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME) + } + + pub fn default_path() -> Option { + manifest::paths::config_dir().map(Self::path_for_config_dir) + } + + pub fn load_default() -> Result { + let Some(path) = Self::default_path() else { + return Ok(Self::default()); + }; + match fs::read_to_string(&path) { + Ok(raw) => Self::parse_str(&raw, &path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(Error::Io(error)), + } + } + + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = fs::read_to_string(path).map_err(|error| { + Error::Config(format!( + "failed to read Server host config `{}`: {error}", + path.display() + )) + })?; + Self::parse_str(&raw, path) + } + + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { + toml::from_str(raw).map_err(|error| { + Error::Config(format!( + "failed to parse Server host config `{}`: {error}", + path.as_ref().display() + )) + }) + } +} + impl BackendRuntimesConfigFile { pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) @@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile { } } -impl WorkspaceBackendConfigFile { - pub fn path_for_workspace(workspace_root: impl AsRef) -> PathBuf { - workspace_root - .as_ref() - .join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH) - } - - pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef) -> Result<()> { - let path = Self::path_for_workspace(workspace_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut file) => { - use std::io::Write; - file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?; - file.sync_all()?; - Ok(()) - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn local_config_diff_for_workspace(workspace_root: impl AsRef) -> Result { - let workspace_root = workspace_root.as_ref(); - let path = Self::path_for_workspace(workspace_root); - match fs::read_to_string(&path) { - Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)), - Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!( - "workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first", - path.display(), - workspace_root.display() - ))), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn load_for_workspace(workspace_root: impl AsRef) -> Result { - let path = Self::path_for_workspace(workspace_root); - match fs::read_to_string(&path) { - Ok(raw) => Self::parse_str(&raw, &path), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn write_for_workspace(&self, workspace_root: impl AsRef) -> Result<()> { - let path = Self::path_for_workspace(workspace_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self).map_err(|error| { - Error::Config(format!( - "failed to serialize workspace backend config: {error}" - )) - })?; - fs::write(path, raw)?; - Ok(()) - } - - pub fn parse_str(raw: &str, path: impl AsRef) -> Result { - toml::from_str(raw).map_err(|error| { - Error::Config(format!( - "failed to parse workspace backend config `{}`: {error}", - path.as_ref().display() - )) - }) - } - - pub fn resolve( - &self, - workspace_root: impl AsRef, - identity: WorkspaceIdentity, - ) -> Result { - self.resolve_with_runtime_config( - workspace_root, - identity, - &BackendRuntimesConfigFile::default(), - ) - } - - pub fn resolve_with_runtime_config( - &self, +impl ResolvedWorkspaceBackendConfig { + pub fn local_dev( workspace_root: impl AsRef, identity: WorkspaceIdentity, + host_config: &ServerHostConfigFile, runtime_config: &BackendRuntimesConfigFile, - ) -> Result { + ) -> Result { let workspace_root = workspace_root.as_ref(); - let data_root = self - .data - .root - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(|| { - ServerConfig::default_workspace_backend_data_root(&identity.workspace_id) - }); - let database_path = self - .data - .workspace_database_path - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(ServerConfig::default_server_database_path); - let embedded_runtime_store_root = self - .data - .embedded_runtime_store_root - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(|| data_root.join("embedded-runtime")); - let listen = self - .server - .listen - .as_deref() - .unwrap_or(DEFAULT_LISTEN) - .parse::() - .map_err(|_| { - Error::Config(format!( - "invalid workspace backend server.listen `{}`", - self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN) - )) - })?; - + let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id); + let database_path = ServerConfig::default_server_database_path(); + let (browser_public_url, browser_rp_id) = + resolve_browser_public_url(&host_config.browser.public_url)?; let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); server.database_path = database_path.clone(); - server.frontend_url = self - .server - .frontend_url - .clone() - .unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string()); - server.static_assets_dir = self - .server - .static_assets_dir - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)); - server.embedded_runtime_store_root = embedded_runtime_store_root; - server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS); - server.repositories = self - .repositories - .iter() - .map(|repository| resolve_repository(workspace_root, repository)) - .collect::>>()?; + server.embedded_runtime_store_root = data_root.join("embedded-runtime"); + server.max_records = DEFAULT_MAX_RECORDS; server.remote_runtime_sources = runtime_config .runtimes .remote @@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile { .map(resolve_remote_runtime) .collect::>>()?; server.auth = AuthConfig::Passkey { - rp_id: self.auth.rp_id.trim().to_string(), - origin: self.auth.origin.trim().to_string(), - public_base_url: self.auth.public_base_url.trim().to_string(), - cookie_name: self.auth.cookie_name.trim().to_string(), + rp_id: browser_rp_id, + origin: browser_public_url.clone(), + public_base_url: browser_public_url, + cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(), }; + let listen = DEFAULT_LISTEN.parse::().map_err(|error| { + Error::Config(format!("invalid built-in Server listen address: {error}")) + })?; - Ok(ResolvedWorkspaceBackendConfig { + Ok(Self { server, listen, database_path, @@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile { } impl ResolvedWorkspaceBackendConfig { - pub fn with_database_path(mut self, path: impl Into) -> Self { - let path = path.into(); - self.database_path = path.clone(); - self.server.database_path = path; - self - } - - pub fn with_static_assets_dir(mut self, path: Option) -> Self { - self.server.static_assets_dir = path; - self - } - pub fn with_backend_base_url(mut self, base_url: impl Into) -> Self { self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string()); self @@ -462,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig { } } -fn resolve_repository( - workspace_root: &Path, - config: &WorkspaceRepositoryConfigFile, -) -> Result { - let id = normalize_required_string("repository id", &config.id)?; - validate_repository_id(&id)?; - let provider = - normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase(); - let uri = normalize_required_string("repository uri", &config.uri)?; - let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?; - let display_name = normalize_optional_string(config.display_name.as_deref()); - let default_selector = normalize_optional_string(config.default_selector.as_deref()); - - Ok(ConfiguredRepository { - id, - provider, - source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), - source, - source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, - observed_at: None, - path, - display_name, - default_selector, - }) -} - fn normalize_required_string(field: &str, value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -497,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result { Ok(trimmed.to_string()) } -fn normalize_optional_string(value: Option<&str>) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn validate_repository_id(id: &str) -> Result<()> { - if id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) - { - Ok(()) - } else { - Err(Error::Config(format!( - "repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`" - ))) - } -} - -fn resolve_repository_source( - workspace_root: &Path, - id: &str, - uri: &str, -) -> Result<(workspace_api::RepositorySource, Option)> { - match crate::repository_source::parse_repository_source(uri) { - Ok(source) => { - let path = match source.kind { - workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), - workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri) - .ok() - .and_then(|uri| uri.to_file_path().ok()), - workspace_api::RepositorySourceKind::Ssh - | workspace_api::RepositorySourceKind::Http - | workspace_api::RepositorySourceKind::Https => None, - workspace_api::RepositorySourceKind::Invalid => { - return Err(Error::Config(format!( - "repository `{id}` has an invalid source" - ))); - } - }; - Ok((source, path)) - } - Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => { - let path = resolve_workspace_path(workspace_root, Path::new(uri)); - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, - uri: path.to_string_lossy().into_owned(), - }; - Ok((source, Some(path))) - } - Err(error) => Err(Error::Config(format!( - "repository `{id}` has an invalid source: {error}" - ))), - } -} - pub(crate) fn resolve_remote_runtime( config: &RemoteRuntimeConfigFile, ) -> Result { if let Some(token_ref) = config.token_ref.as_deref() { return Err(Error::Config(format!( - "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", + "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet", config.id ))); } @@ -578,12 +272,34 @@ pub(crate) fn resolve_remote_runtime( )) } -fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - workspace_root.join(path) +fn resolve_browser_public_url(value: &str) -> Result<(String, String)> { + let value = normalize_required_string("browser.public_url", value)?; + let url = Url::parse(&value).map_err(|error| { + Error::Config(format!( + "browser.public_url must be an absolute http(s) URL: {error}" + )) + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::Config( + "browser.public_url must use the http or https scheme".to_string(), + )); } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::Config( + "browser.public_url must not contain user information".to_string(), + )); + } + if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(Error::Config( + "browser.public_url must contain only an origin without a path, query, or fragment" + .to_string(), + )); + } + let rp_id = url + .host_str() + .ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))? + .to_string(); + Ok((url.origin().ascii_serialization(), rp_id)) } #[cfg(test)] @@ -598,14 +314,33 @@ mod tests { } } - #[test] - fn missing_config_path_uses_defaults() { + fn resolved_with_runtimes( + runtimes: &BackendRuntimesConfigFile, + ) -> ResolvedWorkspaceBackendConfig { let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); + ResolvedWorkspaceBackendConfig::local_dev( + dir.path(), + identity(), + &ServerHostConfigFile::default(), + runtimes, + ) + .unwrap() + } + + #[test] + fn default_settings_resolve_without_a_repository_file() { + let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); - assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &resolved.server.auth; + assert_eq!(rp_id, "localhost"); + assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL); + assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert!(resolved.database_path.ends_with("server.db")); assert!( @@ -618,12 +353,8 @@ mod tests { #[test] fn backend_base_url_is_explicit_and_normalized() { - let dir = tempfile::tempdir().unwrap(); let listen = "127.0.0.1:48787".parse().unwrap(); - let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path()) - .unwrap() - .resolve(dir.path(), identity()) - .unwrap() + let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()) .with_listen(listen) .with_backend_base_url("http://127.0.0.1:48787/"); @@ -635,171 +366,83 @@ mod tests { } #[test] - fn rejects_unknown_fields() { - let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") - .unwrap_err(); - assert!( - error.to_string().contains("unknown field"), - "unexpected error: {error}" - ); - } - - #[test] - fn resolves_relative_paths_against_workspace_root() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[server] -static_assets_dir = "web/build" - -[data] -root = ".yoi/backend-data" -workspace_database_path = ".yoi/custom.db" -embedded_runtime_store_root = ".yoi/runtime-store" -"#, - "test", + fn browser_public_url_from_host_config_drives_all_browser_auth_settings() { + let host_config = ServerHostConfigFile::parse_str( + "[browser]\npublic_url = \"https://Yoi.Example:443/\"\n", + "server.toml", ) .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - - assert_eq!( - resolved.server.static_assets_dir, - Some(dir.path().join("web/build")) - ); - assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db")); - assert_eq!( - resolved.server.embedded_runtime_store_root, - dir.path().join(".yoi/runtime-store") - ); - } - - #[test] - fn absolute_paths_are_preserved() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[data] -workspace_database_path = "/tmp/yoi-workspace.db" -embedded_runtime_store_root = "/tmp/yoi-runtime" -"#, - "test", + let resolved = ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), + identity(), + &host_config, + &BackendRuntimesConfigFile::default(), ) .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - assert_eq!( - resolved.database_path, - PathBuf::from("/tmp/yoi-workspace.db") - ); - assert_eq!( - resolved.server.embedded_runtime_store_root, - PathBuf::from("/tmp/yoi-runtime") - ); + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &resolved.server.auth; + assert_eq!(rp_id, "yoi.example"); + assert_eq!(origin, "https://yoi.example"); + assert_eq!(public_base_url, "https://yoi.example"); } #[test] - fn data_root_derives_runtime_store_path_only() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[data] -root = ".local-data" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - - assert!(resolved.database_path.ends_with("server.db")); - assert_eq!( - resolved.server.embedded_runtime_store_root, - dir.path().join(".local-data/embedded-runtime") - ); + fn browser_public_url_rejects_non_origin_urls() { + for value in [ + "https://example.test/path", + "https://example.test?query=true", + "file:///tmp/web", + ] { + let host_config = ServerHostConfigFile { + browser: ServerBrowserConfig { + public_url: value.to_string(), + }, + }; + let result = ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), + identity(), + &host_config, + &BackendRuntimesConfigFile::default(), + ); + let error = match result { + Ok(_) => panic!("expected {value} to be rejected"), + Err(error) => error, + }; + assert!( + error.to_string().contains("browser.public_url"), + "unexpected error for {value}: {error}" + ); + } } #[test] - fn copies_local_config_without_overwriting() { + fn server_host_config_loads_only_from_the_explicit_host_path() { let dir = tempfile::tempdir().unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path()); - let raw = fs::read_to_string(&path).unwrap(); - assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE); - WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap(); - - fs::write(&path, "# custom local config\n").unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - assert_eq!( - fs::read_to_string(&path).unwrap(), - "# custom local config\n" - ); - } - - #[test] - fn local_config_diff_reports_match_and_difference() { - let dir = tempfile::tempdir().unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - let matched = - WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap(); - assert!(!matched.differs); - + let path = ServerHostConfigFile::path_for_config_dir(dir.path()); fs::write( - WorkspaceBackendConfigFile::path_for_workspace(dir.path()), - "[server]\nlisten = \"127.0.0.1:9999\"\n", + &path, + "[browser]\npublic_url = \"https://deploy.example.test\"\n", ) .unwrap(); - let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap(); - assert!(diff.differs); - assert!(diff.text.contains("+++ workspace local")); - assert!(diff.text.contains("127.0.0.1:9999")); + + let loaded = ServerHostConfigFile::load_from_path(&path).unwrap(); + assert_eq!(loaded.browser.public_url, "https://deploy.example.test"); + assert_eq!(path, dir.path().join("server.toml")); } #[test] - fn resolves_repository_uri_relative_to_workspace_root() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[[repositories]] -id = "main" -provider = "git" -uri = "." -display_name = "Main" -default_selector = "HEAD" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - let repository = resolved.server.repositories.first().unwrap(); - - assert_eq!(repository.id, "main"); - assert_eq!(repository.provider, "git"); - assert_eq!(repository.path.as_deref(), Some(dir.path())); - assert_eq!(repository.display_name.as_deref(), Some("Main")); - assert_eq!(repository.default_selector.as_deref(), Some("HEAD")); - } - - #[test] - fn remote_repository_source_is_preserved_without_a_local_path() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[[repositories]] -id = "main" -provider = "git" -uri = "https://example.com/org/repo.git" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - let repository = &resolved.server.repositories[0]; - - assert_eq!( - repository.source.kind, - workspace_api::RepositorySourceKind::Https + fn explicit_missing_server_host_config_fails_closed() { + let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err(); + assert!( + error + .to_string() + .contains("failed to read Server host config") ); - assert_eq!(repository.source.uri, "https://example.com/org/repo.git"); - assert!(repository.path.is_none()); } #[test] @@ -824,28 +467,8 @@ uri = "https://example.com/org/repo.git" ); } - #[test] - fn workspace_backend_config_rejects_runtime_entries() { - let error = WorkspaceBackendConfigFile::parse_str( - r#" -[[runtimes.remote]] -id = "arc" -endpoint = "http://legacy.example.test" -display_name = "legacy arc" -"#, - "test", - ) - .unwrap_err(); - assert!( - error.to_string().contains("unknown field `runtimes`"), - "unexpected error: {error}" - ); - } - #[test] fn backend_runtimes_config_is_the_only_runtime_source() { - let dir = tempfile::tempdir().unwrap(); - let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); let runtime_config = BackendRuntimesConfigFile::parse_str( r#" [[runtimes.remote]] @@ -856,9 +479,7 @@ display_name = "xdg arc" "runtimes.toml", ) .unwrap(); - let resolved = workspace_config - .resolve_with_runtime_config(dir.path(), identity(), &runtime_config) - .unwrap(); + let resolved = resolved_with_runtimes(&runtime_config); assert_eq!(resolved.server.remote_runtime_sources.len(), 1); assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); assert_eq!( @@ -887,8 +508,6 @@ token = "secret" #[test] fn token_ref_fails_closed_until_secret_resolution_exists() { - let dir = tempfile::tempdir().unwrap(); - let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); let runtime_config = BackendRuntimesConfigFile::parse_str( r#" [[runtimes.remote]] @@ -899,9 +518,10 @@ token_ref = "local:remote-token" "runtimes.toml", ) .unwrap(); - let error = match workspace_config.resolve_with_runtime_config( - dir.path(), + let error = match ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), identity(), + &ServerHostConfigFile::default(), &runtime_config, ) { Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 87cf4261..7eab485b 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -177,26 +177,6 @@ impl RuntimeSourceSummary { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCapabilitySummary { - pub can_list_hosts: bool, - pub can_list_workers: bool, - pub can_get_worker: bool, - pub can_spawn_worker: bool, - pub can_stop_worker: bool, - pub has_workspace_fs: bool, - pub has_shell: bool, - pub has_git: bool, - pub supports_worktrees: bool, - pub supports_backend_internal_tools: bool, - pub workspace_scope: String, - pub max_workers: usize, - pub os: String, - pub arch: String, -} - -pub type HostCapabilitySummary = RuntimeCapabilitySummary; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeSummary { pub runtime_id: String, @@ -205,7 +185,9 @@ pub struct RuntimeSummary { pub status: String, pub source: RuntimeSourceSummary, pub host_ids: Vec, - pub capabilities: RuntimeCapabilitySummary, + pub worker_creation_available: bool, + pub os: String, + pub arch: String, pub diagnostics: Vec, } @@ -218,7 +200,8 @@ pub struct HostSummary { pub status: String, pub observed_at: String, pub last_seen_at: Option, - pub capabilities: HostCapabilitySummary, + pub os: String, + pub arch: String, pub diagnostics: Vec, } @@ -310,27 +293,6 @@ impl From for workspace_api::RuntimeSourceSummary { } } -impl From for workspace_api::RuntimeCapabilitySummary { - fn from(capabilities: RuntimeCapabilitySummary) -> Self { - Self { - can_list_hosts: capabilities.can_list_hosts, - can_list_workers: capabilities.can_list_workers, - can_get_worker: capabilities.can_get_worker, - can_spawn_worker: capabilities.can_spawn_worker, - can_stop_worker: capabilities.can_stop_worker, - has_workspace_fs: capabilities.has_workspace_fs, - has_shell: capabilities.has_shell, - has_git: capabilities.has_git, - supports_worktrees: capabilities.supports_worktrees, - supports_backend_internal_tools: capabilities.supports_backend_internal_tools, - workspace_scope: capabilities.workspace_scope, - max_workers: capabilities.max_workers, - os: capabilities.os, - arch: capabilities.arch, - } - } -} - impl From for workspace_api::RuntimeSummary { fn from(runtime: RuntimeSummary) -> Self { Self { @@ -340,7 +302,9 @@ impl From for workspace_api::RuntimeSummary { status: runtime.status, source: runtime.source.into(), host_ids: runtime.host_ids, - capabilities: runtime.capabilities.into(), + worker_creation_available: runtime.worker_creation_available, + os: runtime.os, + arch: runtime.arch, diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(), } } @@ -1890,7 +1854,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { status: "unavailable".to_string(), source: RuntimeSourceSummary::embedded_worker_runtime(), host_ids: Vec::new(), - capabilities: embedded_runtime_capabilities(limit, false, false), + worker_creation_available: false, + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), diagnostics, }; } @@ -1910,7 +1876,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } else { vec![self.host_id.clone()] }, - capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), + worker_creation_available: true, + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), diagnostics, } } @@ -1928,7 +1896,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { status: "available".to_string(), observed_at: Utc::now().to_rfc3339(), last_seen_at: None, - capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), diagnostics: vec![diagnostic( "embedded_runtime_host_boundary", DiagnosticSeverity::Info, @@ -2576,7 +2545,9 @@ pub struct RemoteRuntimeConfig { pub base_url: String, pub bearer_token: Option, pub auth: Option, - pub cached_capabilities: RuntimeCapabilitySummary, + pub cached_worker_creation_available: bool, + pub cached_os: String, + pub cached_arch: String, pub cached_status: String, pub timeout: Duration, } @@ -2598,7 +2569,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig { &self.bearer_token.as_ref().map(|_| ""), ) .field("auth", &self.auth.as_ref().map(|_| "")) - .field("cached_capabilities", &self.cached_capabilities) + .field( + "cached_worker_creation_available", + &self.cached_worker_creation_available, + ) + .field("cached_os", &self.cached_os) + .field("cached_arch", &self.cached_arch) .field("cached_status", &self.cached_status) .field("timeout", &self.timeout) .finish() @@ -2619,9 +2595,9 @@ impl RemoteRuntimeConfig { base_url: base_url.into(), bearer_token, auth: None, - cached_capabilities: remote_runtime_capabilities( - 200, false, false, "unknown", "unknown", - ), + cached_worker_creation_available: false, + cached_os: "unknown".to_string(), + cached_arch: "unknown".to_string(), cached_status: "configured".to_string(), timeout: Duration::from_secs(10), } @@ -2632,11 +2608,6 @@ impl RemoteRuntimeConfig { self } - pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self { - self.cached_capabilities = capabilities; - self - } - pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self { self.auth = Some(auth); self @@ -2708,7 +2679,9 @@ pub struct RemoteWorkerRuntime { workspace_id: String, bearer_token: Option, auth: Option, - cached_capabilities: RuntimeCapabilitySummary, + cached_worker_creation_available: bool, + cached_os: String, + cached_arch: String, cached_status: String, host_id: String, resource_broker: BackendResourceBroker, @@ -2768,7 +2741,9 @@ impl RemoteWorkerRuntime { workspace_id, bearer_token: config.bearer_token, auth: config.auth, - cached_capabilities: config.cached_capabilities, + cached_worker_creation_available: config.cached_worker_creation_available, + cached_os: config.cached_os, + cached_arch: config.cached_arch, cached_status: config.cached_status, resource_broker: BackendResourceBroker::default(), http, @@ -3045,13 +3020,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } else { vec![self.host_id.clone()] }, - capabilities: remote_runtime_capabilities( - limit, - true, - response.runtime.worker_creation_available, - response.runtime.os, - response.runtime.arch, - ), + worker_creation_available: response.runtime.worker_creation_available, + os: response.runtime.os, + arch: response.runtime.arch, diagnostics: Vec::new(), }, Err(diagnostic) => RuntimeSummary { @@ -3065,7 +3036,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } else { vec![self.host_id.clone()] }, - capabilities: self.cached_capabilities.clone(), + worker_creation_available: self.cached_worker_creation_available, + os: self.cached_os.clone(), + arch: self.cached_arch.clone(), diagnostics: vec![diagnostic], }, } @@ -3084,7 +3057,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { status: "configured".to_string(), observed_at: Utc::now().to_rfc3339(), last_seen_at: None, - capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"), + os: self.cached_os.clone(), + arch: self.cached_arch.clone(), diagnostics: Vec::new(), }], Vec::new(), @@ -3553,29 +3527,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } -fn embedded_runtime_capabilities( - limit: usize, - available: bool, - execution_enabled: bool, -) -> RuntimeCapabilitySummary { - RuntimeCapabilitySummary { - can_list_hosts: true, - can_list_workers: available, - can_get_worker: available, - can_spawn_worker: available, - can_stop_worker: available && execution_enabled, - has_workspace_fs: false, - has_shell: false, - has_git: false, - supports_worktrees: false, - supports_backend_internal_tools: true, - workspace_scope: "backend_internal".to_string(), - max_workers: limit, - os: std::env::consts::OS.to_string(), - arch: std::env::consts::ARCH.to_string(), - } -} - fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str { match status { RuntimeStatus::Running => "running", @@ -4122,31 +4073,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String { encoded } -fn remote_runtime_capabilities( - limit: usize, - available: bool, - worker_creation_available: bool, - os: impl Into, - arch: impl Into, -) -> RuntimeCapabilitySummary { - RuntimeCapabilitySummary { - can_list_hosts: true, - can_list_workers: available, - can_get_worker: available, - can_spawn_worker: available && worker_creation_available, - can_stop_worker: available, - has_workspace_fs: false, - has_shell: false, - has_git: false, - supports_worktrees: false, - supports_backend_internal_tools: false, - workspace_scope: "remote_runtime_backend_private".to_string(), - max_workers: limit, - os: os.into(), - arch: arch.into(), - } -} - fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic { if err.is_timeout() { diagnostic( @@ -4800,22 +4726,9 @@ mod tests { status: "available".to_string(), source: RuntimeSourceSummary::embedded_worker_runtime_reserved(), host_ids: vec![self.host_id.clone()], - capabilities: RuntimeCapabilitySummary { - can_list_hosts: true, - can_list_workers: true, - can_get_worker: true, - can_spawn_worker: false, - can_stop_worker: false, - has_workspace_fs: false, - has_shell: false, - has_git: false, - supports_worktrees: false, - supports_backend_internal_tools: false, - workspace_scope: "none".to_string(), - max_workers: self.workers.len(), - os: "test".to_string(), - arch: "test".to_string(), - }, + worker_creation_available: false, + os: "test".to_string(), + arch: "test".to_string(), diagnostics: Vec::new(), } } @@ -4830,7 +4743,8 @@ mod tests { status: "available".to_string(), observed_at: "unknown".to_string(), last_seen_at: None, - capabilities: self.runtime_summary(1).capabilities, + os: "test".to_string(), + arch: "test".to_string(), diagnostics: Vec::new(), }], Vec::new(), @@ -5234,7 +5148,7 @@ mod tests { RuntimeSourceKind::EmbeddedWorkerRuntime ); assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active); - assert!(embedded_summary.capabilities.can_spawn_worker); + assert!(embedded_summary.worker_creation_available); let spawned = registry .spawn_worker( diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs index 2067ef0f..25b56bb7 100644 --- a/crates/workspace-server/src/identity.rs +++ b/crates/workspace-server/src/identity.rs @@ -46,8 +46,7 @@ impl WorkspaceIdentity { Ok(raw) => Self::parse_str(&raw, &path), Err(error) if error.kind() == ErrorKind::NotFound => { Err(Error::WorkspaceIdentity(format!( - "workspace is not initialized at {}; run `yoi-server init --workspace {}` first", - workspace_root.as_ref().display(), + "workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing", workspace_root.as_ref().display() ))) } @@ -219,7 +218,7 @@ mod tests { let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); assert!( - error.to_string().contains("workspace is not initialized"), + error.to_string().contains("workspace identity is missing"), "unexpected error: {error}" ); assert!(!WorkspaceIdentity::path(&workspace_root).exists()); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index ddab7e34..3c17be14 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -39,11 +39,7 @@ pub use authority::{ ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, WorkspaceAuthority, }; -pub use config::{ - BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig, - WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, - WorkspaceBackendConfigFile, -}; +pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use repositories::{ diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index be3249ca..18d8f439 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord}; use yoi_workspace_server::{ - BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig, - WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService, - WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, + BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, + ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, }; #[derive(Debug)] enum Command { Serve(ServeOptions), - Init(InitOptions), - ConfigDefault, - ConfigDiff(WorkspacePathOptions), Identity(Vec), TrustRuntime(Vec), MigrateDryRun { database: Option }, @@ -32,16 +28,7 @@ enum Command { #[derive(Debug)] struct ServeOptions { listen: Option, -} - -#[derive(Debug)] -struct InitOptions { - workspace: PathBuf, -} - -#[derive(Debug)] -struct WorkspacePathOptions { - workspace: PathBuf, + config: Option, } #[derive(Debug)] @@ -82,9 +69,6 @@ async fn run() -> Result<(), Box> { let args = std::env::args().skip(1).collect::>(); match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, - Command::Init(options) => run_init(options).await, - Command::ConfigDefault => run_config_default(), - Command::ConfigDiff(options) => run_config_diff(options), Command::Identity(args) => run_identity_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::MigrateDryRun { database } => { @@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result { }; match command.as_str() { - "init" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_init_help(); - return Ok(Command::Help); - } - Ok(Command::Init(parse_init_options(rest)?)) - } - "config" => parse_config_command(rest), "identity" => Ok(Command::Identity(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "migrate" => parse_migrate_command(rest), @@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" ))), } } -async fn run_init(options: InitOptions) -> Result<(), Box> { - run_init_with_database_path(options, ServerConfig::default_server_database_path()).await -} - -async fn run_init_with_database_path( - options: InitOptions, - database_path: PathBuf, -) -> Result<(), Box> { - let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?; - - if let Some(parent) = database_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?); - let service = WorkspaceCatalogService::new(store); - service.create_with_workspace_id( - WorkspaceCreateRequest { - operation_key: format!("cli-init:{}", identity.workspace_id), - display_name: identity.display_name.clone(), - repository: InitialRepositoryIntent { - uri: options.workspace.display().to_string(), - display_name: Some("Main repository".to_string()), - default_ref: Some("HEAD".to_string()), - }, - }, - None, - Some(identity.workspace_id.clone()), - )?; - - eprintln!( - "yoi-server: initialized workspace `{}` ({}) in server DB `{}`", - options.workspace.display(), - identity.workspace_id, - database_path.display() - ); - Ok(()) -} - -fn run_config_default() -> Result<(), Box> { - print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}"); - Ok(()) -} - -fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box> { - let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?; - print!("{}", diff.text); - Ok(()) -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct ServerIdentityFile { identity: RuntimeIdentityMaterial, @@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box ServerHostConfigFile::load_from_path(path)?, + None => ServerHostConfigFile::load_default()?, + }; let runtime_config = BackendRuntimesConfigFile::load_default()?; - let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config( + let mut resolved = ResolvedWorkspaceBackendConfig::local_dev( &workspace_root, identity, + &host_config, &runtime_config, )?; resolved.database_path = database_path.clone(); @@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result { - let Some((subcommand, rest)) = args.split_first() else { - print_config_help(); - return Ok(Command::Help); - }; - match subcommand.as_str() { - "default" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_config_help(); - return Ok(Command::Help); - } - if !rest.is_empty() { - return Err(CliError( - "config default does not accept options".to_string(), - )); - } - Ok(Command::ConfigDefault) - } - "diff" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_config_help(); - return Ok(Command::Help); - } - Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?)) - } - "--help" | "-h" => { - print_config_help(); - Ok(Command::Help) - } - other => Err(CliError(format!( - "unknown config subcommand `{other}`; expected `default` or `diff`" - ))), - } -} - fn parse_migrate_command(args: &[String]) -> Result { let mut dry_run = false; let mut database = None; @@ -840,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--workspace" => { - let value = iter - .next() - .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; - workspace = PathBuf::from(value); - } - value if value.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); - } - other => return Err(CliError(format!("unknown workspace option `{other}`"))), - } - } - let workspace = workspace - .canonicalize() - .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; - Ok(WorkspacePathOptions { workspace }) -} - -fn parse_init_options(args: &[String]) -> Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--workspace" => { - let value = iter - .next() - .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; - workspace = PathBuf::from(value); - } - value if value.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); - } - other => return Err(CliError(format!("unknown init option `{other}`"))), - } - } - - let workspace = workspace - .canonicalize() - .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; - Ok(InitOptions { workspace }) -} - fn parse_serve_options(args: &[String]) -> Result { let mut listen = None; + let mut config = None; let mut index = 0; while index < args.len() { @@ -906,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result { _ if arg.starts_with("--listen=") => { listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); } + "--config" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--config requires a path".to_string()))?; + config = Some(PathBuf::from(value)); + } + _ if arg.starts_with("--config=") => { + config = Some(PathBuf::from(value_after_equals(arg, "--config")?)); + } _ if arg.starts_with('-') => { return Err(CliError(format!("unknown serve option `{arg}`"))); } @@ -918,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result { index += 1; } - Ok(ServeOptions { listen }) + Ok(ServeOptions { listen, config }) } fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { @@ -940,23 +797,11 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config [OPTIONS]\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] + "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } -fn print_init_help() { - println!( - "yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace Workspace root to initialize (defaults to cwd)\n -h, --help Print help" - ); -} - -fn print_config_help() { - println!( - "yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" - ); -} - fn print_skills_help() { println!( "yoi-server skills\n\nUsage:\n yoi-server skills list --workspace \n yoi-server skills lint --workspace \n yoi-server skills show --workspace \n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace id in the Server DB (required)\n -h, --help Print help" @@ -966,24 +811,23 @@ fn print_skills_help() { fn print_serve_help() { println!( "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database ] - yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n -h, --help Print help" + yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" ); } #[cfg(test)] mod tests { use super::*; - use yoi_workspace_server::{ - WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, - WORKSPACE_IDENTITY_RELATIVE_PATH, - }; #[test] - fn parse_init_defaults_workspace_to_cwd_or_flag() { - let temp = tempfile::tempdir().unwrap(); - let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; - let options = parse_init_options(&args).unwrap(); - assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); + fn removed_repository_local_commands_are_rejected() { + for command in ["init", "config"] { + let error = parse_command(&[command.to_string()]).unwrap_err(); + assert!( + error.to_string().contains("unknown command"), + "unexpected error for {command}: {error}" + ); + } } #[test] @@ -1023,10 +867,18 @@ mod tests { } #[test] - fn parse_serve_accepts_listen_only() { - let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; + fn parse_serve_accepts_listen_and_host_config() { + let args = vec![ + "--listen".to_string(), + "127.0.0.1:0".to_string(), + "--config=/etc/yoi/server.toml".to_string(), + ]; let options = parse_serve_options(&args).unwrap(); assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap()); + assert_eq!( + options.config.unwrap(), + PathBuf::from("/etc/yoi/server.toml") + ); } #[test] @@ -1081,49 +933,4 @@ mod tests { ); ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap(); } - - #[tokio::test] - async fn init_creates_identity_local_config_and_server_records() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("data").join("server").join("server.db"); - std::fs::create_dir(temp.path().join(".git")).unwrap(); - run_init_with_database_path( - InitOptions { - workspace: temp.path().canonicalize().unwrap(), - }, - database_path.clone(), - ) - .await - .unwrap(); - - assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); - let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH); - assert!(local_config_path.exists()); - assert_eq!( - std::fs::read_to_string(local_config_path).unwrap(), - WORKSPACE_BACKEND_CONFIG_TEMPLATE - ); - assert!( - !temp - .path() - .join(".yoi/workspace-backend.default.toml") - .exists() - ); - assert!(!temp.path().join(".yoi/workspace.db").exists()); - assert!(!temp.path().join(".yoi/embedded-runtime").exists()); - assert!(database_path.exists()); - - let store = SqliteWorkspaceStore::open(&database_path).unwrap(); - let workspaces = store.list_workspaces().unwrap(); - assert_eq!(workspaces.len(), 1); - let repositories = store - .list_repositories(&workspaces[0].workspace_id) - .unwrap(); - assert_eq!(repositories.len(), 1); - assert_eq!(repositories[0].repository_id, "main"); - assert_eq!( - repositories[0].source.uri, - temp.path().canonicalize().unwrap().display().to_string() - ); - } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f7c9aacd..f04ab2d0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -163,7 +163,6 @@ pub struct ServerConfig { pub workspace_created_at: String, pub workspace_root: PathBuf, pub database_path: PathBuf, - pub frontend_url: String, pub embedded_runtime_store_root: PathBuf, pub static_assets_dir: Option, pub auth: AuthConfig, @@ -173,9 +172,6 @@ pub struct ServerConfig { pub remote_runtime_sources: Vec, pub runtime_config_path: Option, pub backend_base_url: Option, - /// Allows the first ownerless Workspace to be created without a session. - /// This must only be enabled for a loopback-bound local Server. - pub allow_local_workspace_bootstrap: bool, } impl ServerConfig { @@ -190,13 +186,12 @@ impl ServerConfig { workspace_created_at: identity.created_at, workspace_root, database_path, - frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, auth: AuthConfig::Passkey { rp_id: "localhost".to_string(), - origin: "http://localhost:8787".to_string(), - public_base_url: "http://localhost:8787".to_string(), + origin: "http://localhost:5173".to_string(), + public_base_url: "http://localhost:5173".to_string(), cookie_name: "yoi_workspace_session".to_string(), }, max_records: 200, @@ -205,7 +200,6 @@ impl ServerConfig { remote_runtime_sources: Vec::new(), runtime_config_path: BackendRuntimesConfigFile::default_path(), backend_base_url: None, - allow_local_workspace_bootstrap: false, } } @@ -262,11 +256,6 @@ impl ServerConfig { Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime") } - pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self { - self.allow_local_workspace_bootstrap = enabled; - self - } - pub fn with_embedded_runtime_store_root(mut self, root: impl Into) -> Self { self.embedded_runtime_store_root = root.into(); self @@ -853,9 +842,9 @@ async fn list_server_workspaces( ) -> Response { let owner = match resolve_server_actor(&api, &headers).await { Ok(Some(actor)) => Some(actor.account_id), - Ok(None) => match api.catalog.list(None, 1) { - Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(), - Ok(_) => return StatusCode::UNAUTHORIZED.into_response(), + Ok(None) => match api.catalog.is_empty() { + Ok(true) => return Json(Vec::::new()).into_response(), + Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), Err(error) => return server_error_response(error), }, Err(error) => return server_error_response(error), @@ -874,19 +863,14 @@ async fn create_server_workspace( headers: HeaderMap, Json(request): Json, ) -> Response { - let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => (Some(actor.account_id), false), - Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true), + let owner_account_id = match resolve_server_actor(&api, &headers).await { + Ok(Some(actor)) => actor.account_id, Ok(None) => { return forbidden_server_response("Workspace creation requires an authenticated owner"); } Err(error) => return server_error_response(error), }; - let created = match if local_bootstrap { - api.catalog.create_first_ownerless(request) - } else { - api.catalog.create(request, owner_account_id) - } { + let created = match api.catalog.create(request, owner_account_id) { Ok(created) => created, Err(error) => return server_error_response(error), }; @@ -1225,6 +1209,33 @@ pub async fn build_workspace_server_router( ))) } +#[cfg(test)] +async fn seed_test_registered_workspace( + store: &dyn ControlPlaneStore, + config: &ServerConfig, +) -> Result<()> { + let account_id = format!("account-{}", config.workspace_id); + store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_owned(), + handle: format!("owner-{}", config.workspace_id), + display_name: "Workspace Owner".to_owned(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + })?; + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: config.workspace_id.clone(), + owner_account_id: Some(account_id), + display_name: config.workspace_display_name.clone(), + state: "active".to_owned(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + }) + .await?; + Ok(()) +} + impl WorkspaceApi { pub fn with_config_schema_provider( mut self, @@ -1288,6 +1299,7 @@ impl WorkspaceApi { store: Arc, execution_backend: Arc, ) -> Result { + seed_test_registered_workspace(store.as_ref(), &config).await?; Self::new_with_execution_backend_and_broker( config, store, @@ -1307,16 +1319,12 @@ impl WorkspaceApi { Arc, >, ) -> Result { - store - .upsert_workspace(&WorkspaceRecord { - workspace_id: config.workspace_id.clone(), - owner_account_id: None, - display_name: config.workspace_display_name.clone(), - state: "active".to_string(), - created_at: config.workspace_created_at.clone(), - updated_at: config.workspace_created_at.clone(), - }) - .await?; + if store.get_workspace(&config.workspace_id).await?.is_none() { + return Err(crate::Error::Config(format!( + "Workspace {} is not registered in the Server DB", + config.workspace_id + ))); + } import_configured_repositories(store.as_ref(), &config)?; config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?; let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend( @@ -2764,7 +2772,7 @@ pub struct RuntimeConnectionSummary { pub built_in: bool, pub config_managed: bool, pub active: bool, - pub can_spawn_worker: bool, + pub worker_creation_available: bool, pub restart_required: bool, pub status: String, pub diagnostics: Vec, @@ -2824,7 +2832,7 @@ pub struct WorkerLaunchRuntimeOption { pub runtime_id: String, pub display_name: String, pub built_in: bool, - pub can_spawn_worker: bool, + pub worker_creation_available: bool, pub working_directory_required: bool, pub status: String, pub diagnostics: Vec, @@ -4517,9 +4525,9 @@ async fn scoped_queue_ticket( State(api): State, AxumPath(path): AxumPath, Json(_request): Json, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let _ = execute_ticket_rest_operation( + let result = execute_ticket_rest_operation( &api, &path.workspace_id, HeaderMap::new(), @@ -4529,8 +4537,10 @@ async fn scoped_queue_ticket( }, ) .await?; - let Json(ticket) = browser_ticket_detail(&api, &path.id)?; - Ok(Json(ticket)) + ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::QueueOutcome(outcome) => Some(outcome), + _ => None, + }) } async fn scoped_close_ticket( @@ -7227,14 +7237,13 @@ fn select_memory_consolidation_runtime(api: &WorkspaceApi) -> ApiResult .items .iter() .find(|runtime| { - runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID - && runtime.capabilities.can_spawn_worker + runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && runtime.worker_creation_available }) .or_else(|| { runtimes .items .iter() - .find(|runtime| runtime.capabilities.can_spawn_worker) + .find(|runtime| runtime.worker_creation_available) }) { return Ok(runtime.runtime_id.clone()); @@ -8573,29 +8582,6 @@ async fn create_workspace_working_directory( )); } - if !runtime.capabilities.supports_worktrees { - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, - &request_fingerprint, - false, - Some("runtime_workdir_unsupported"), - &now_registry_timestamp(), - )?; - return Err(ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id: reserved.resolved_runtime_id, - code: "runtime_workdir_unsupported".to_string(), - message: "Selected Runtime does not support Workdir creation".to_string(), - }, - vec![RuntimeDiagnostic { - code: "runtime_workdir_unsupported".to_string(), - severity: DiagnosticSeverity::Error, - message: "Selected Runtime does not support Workdir creation".to_string(), - }], - )); - } - working_directory_request.backend_workdir_id = Some(reserved.working_directory_id.clone()); let existing = match api.runtime.working_directory( &reserved.resolved_runtime_id, @@ -12719,7 +12705,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS built_in: true, config_managed: false, active: runtime.status == "active", - can_spawn_worker: runtime.capabilities.can_spawn_worker, + worker_creation_available: runtime.worker_creation_available, restart_required: false, status: runtime.status, diagnostics: runtime.diagnostics, @@ -12731,7 +12717,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS built_in: true, config_managed: false, active: false, - can_spawn_worker: false, + worker_creation_available: false, restart_required: false, status: "unavailable".to_string(), diagnostics: vec![settings_diagnostic( @@ -12760,12 +12746,12 @@ fn remote_runtime_connection_summaries( let live = live_runtimes .iter() .find(|runtime| runtime.runtime_id == remote.id); - let (display_name, kind, active, can_spawn_worker, status, diagnostics) = match live { + let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live { Some(runtime) => ( runtime.label.clone(), runtime.kind.clone(), runtime.status == "active", - runtime.capabilities.can_spawn_worker, + runtime.worker_creation_available, runtime.status.clone(), runtime.diagnostics.clone(), ), @@ -12797,7 +12783,7 @@ fn remote_runtime_connection_summaries( built_in: false, config_managed: true, active, - can_spawn_worker, + worker_creation_available, restart_required, status, diagnostics, @@ -13312,7 +13298,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult Vec diagnostics.append(&mut runtime_diagnostics), - Err(err) => diagnostics.extend(err.diagnostics), - } + match sync_runtime_workdir_observations(api, runtime.runtime_id.as_str()) { + Ok(mut runtime_diagnostics) => diagnostics.append(&mut runtime_diagnostics), + Err(err) => diagnostics.extend(err.diagnostics), } } diagnostics @@ -14484,8 +14468,8 @@ mod tests { use worker_runtime::working_directory::WorkingDirectoryMaterializer; use crate::hosts::{ - RemoteRuntimeAuthConfig, RuntimeCapabilitySummary, TicketWorkerRole, WorkerInputKind, - WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, + RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState, + WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, }; use crate::store::{ AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord, @@ -14547,22 +14531,9 @@ mod tests { server_id: "server-test".to_owned(), server_private_key: "unused".to_owned(), }), - cached_capabilities: RuntimeCapabilitySummary { - can_list_hosts: true, - can_list_workers: true, - can_get_worker: true, - can_spawn_worker: true, - can_stop_worker: true, - has_workspace_fs: false, - has_shell: false, - has_git: false, - supports_worktrees: false, - supports_backend_internal_tools: false, - workspace_scope: api.workspace_id().to_owned(), - max_workers: 1, - os: "test".to_owned(), - arch: "test".to_owned(), - }, + cached_worker_creation_available: true, + cached_os: "test".to_owned(), + cached_arch: "test".to_owned(), cached_status: "connected".to_owned(), timeout: std::time::Duration::from_secs(1), }); @@ -15422,13 +15393,34 @@ mod tests { )); } + #[tokio::test] + async fn workspace_api_does_not_register_a_missing_workspace() { + let dir = tempfile::tempdir().unwrap(); + let config = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + + let error = match WorkspaceApi::new(config, store.clone()).await { + Ok(_) => panic!("missing Workspace registration must fail closed"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("is not registered in the Server DB") + ); + assert!(store.list_workspaces().unwrap().is_empty()); + } + #[tokio::test] async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let config = test_server_config(workspace.path()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); - let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); + let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap()); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + let api = WorkspaceApi::new(config, store).await.unwrap(); let workspace_id = api.config.workspace_id.clone(); let result = scoped_start_workspace_orchestrator( @@ -15980,13 +15972,8 @@ mod tests { #[test] fn backend_errors_preserve_operation_details() { - let sanitized = sanitize_backend_error( - "failed to open /home/example/.yoi/workspace-backend.local.toml", - ); - assert_eq!( - sanitized, - "failed to open /home/example/.yoi/workspace-backend.local.toml" - ); + let sanitized = sanitize_backend_error("failed to open server database"); + assert_eq!(sanitized, "failed to open server database"); } #[test] @@ -16425,6 +16412,16 @@ mod tests { } = &config.auth; let expected_origin = expected_origin.clone(); let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + store + .upsert_account(&AccountRecord { + account_id: "account-auth".to_owned(), + kind: "user".to_owned(), + handle: "auth-user".to_owned(), + display_name: "Auth User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); let catalog = WorkspaceCatalogService::new(store.clone()); let repository = temp.path().join("repository"); std::fs::create_dir_all(&repository).unwrap(); @@ -16447,19 +16444,9 @@ mod tests { default_ref: None, }, }, - None, + "account-auth".to_owned(), ) .unwrap(); - store - .upsert_account(&AccountRecord { - account_id: "account-auth".to_owned(), - kind: "user".to_owned(), - handle: "auth-user".to_owned(), - display_name: "Auth User".to_owned(), - created_at: "2026-01-01T00:00:00Z".to_owned(), - updated_at: "2026-01-01T00:00:00Z".to_owned(), - }) - .unwrap(); store .upsert_user(&UserRecord { user_id: "user-auth".to_owned(), @@ -16811,6 +16798,7 @@ mod tests { let mut template = test_server_config(dir.path()); template.static_assets_dir = Some(static_dir); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let catalog = WorkspaceCatalogService::new(store.clone()); let workspace_a = catalog .create( @@ -16823,7 +16811,7 @@ mod tests { default_ref: None, }, }, - None, + "account-two-workspaces".to_owned(), ) .unwrap(); let workspace_b = catalog @@ -16837,10 +16825,9 @@ mod tests { default_ref: None, }, }, - None, + "account-two-workspaces".to_owned(), ) .unwrap(); - let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -16941,13 +16928,12 @@ mod tests { } #[tokio::test] - async fn local_bootstrap_create_activates_workspace_without_server_restart() { + async fn local_workspace_creation_requires_an_authenticated_owner() { let dir = tempfile::tempdir().unwrap(); let repository = dir.path().join("repository"); std::fs::create_dir_all(repository.join(".git")).unwrap(); - let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true); + let template = test_server_config(dir.path()); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); - let token = seed_test_api_token(store.as_ref(), "bootstrap"); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -16961,8 +16947,7 @@ mod tests { } }); - let created = app - .clone() + let response = app .oneshot( Request::builder() .method(Method::POST) @@ -16973,54 +16958,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(created.status(), StatusCode::CREATED); - let body = to_bytes(created.into_body(), usize::MAX).await.unwrap(); - let body: Value = serde_json::from_slice(&body).unwrap(); - let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap(); - - let workspace = get_json_authenticated( - app.clone(), - &format!("/api/w/{workspace_id}/workspace"), - &token, - ) - .await; - assert_eq!(workspace["display_name"], "Created Workspace"); - - let replayed = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/workspaces") - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(replayed.status(), StatusCode::OK); - - let second_payload = json!({ - "operation_key": "bootstrap-2", - "display_name": "Second Ownerless Workspace", - "repository": { - "uri": repository, - "display_name": "Repository", - "default_ref": "HEAD" - } - }); - let second = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/workspaces") - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(Body::from(second_payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(second.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[test] @@ -19254,13 +19192,20 @@ mod tests { assert_eq!(ready_detail.relations.blockers.len(), 1); assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on"); - let Json(queued) = scoped_queue_ticket( + let Json(queue_outcome) = scoped_queue_ticket( State(api.clone()), AxumPath(path()), Json(BrowserQueueTicketRequest {}), ) .await .unwrap(); + assert_eq!(queue_outcome.requested_ticket, ticket_id); + assert_eq!(queue_outcome.queued_tickets.len(), 2); + assert!(queue_outcome.queued_tickets.contains(&related_ticket_id)); + assert!(queue_outcome.queued_tickets.contains(&ticket_id)); + let Json(queued) = scoped_get_ticket(State(api.clone()), AxumPath(path())) + .await + .unwrap(); assert_eq!(queued.state, "queued"); assert_eq!(queued.queued_by.as_deref(), Some("workspace-web")); assert_eq!(queued.relations.blockers.len(), 1); @@ -19916,22 +19861,9 @@ mod tests { server_id: "server-main".to_string(), server_private_key: identity.private_key.clone(), }), - cached_capabilities: RuntimeCapabilitySummary { - can_list_hosts: true, - can_list_workers: true, - can_get_worker: true, - can_spawn_worker: true, - can_stop_worker: true, - has_workspace_fs: false, - has_shell: false, - has_git: false, - supports_worktrees: false, - supports_backend_internal_tools: false, - workspace_scope: TEST_WORKSPACE_ID.to_string(), - max_workers: 1, - os: "test".to_string(), - arch: "test".to_string(), - }, + cached_worker_creation_available: true, + cached_os: "test".to_string(), + cached_arch: "test".to_string(), cached_status: "connected".to_string(), timeout: std::time::Duration::from_secs(1), }); @@ -20765,6 +20697,81 @@ mod tests { )); } + #[tokio::test] + async fn runtime_request_proof_accepts_active_worker_create_reservation() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let memory_settings = store + .get_workspace_memory_settings(TEST_WORKSPACE_ID) + .unwrap(); + let reserved = store + .reserve_worker_create( + TEST_WORKSPACE_ID, + "runtime-test", + "spawn-flow-race", + &"f".repeat(64), + &memory_settings, + ) + .unwrap(); + let worker_id = reserved.worker_id.to_string(); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/flows/resolve"); + let signer = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity); + let issue = || { + signer + .issue( + "server-test", + TEST_WORKSPACE_ID, + Some(worker_id.as_str()), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + &path, + b"{}", + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .unwrap() + }; + + let proof = issue(); + let source = crate::worker_source::verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + &proof, + TEST_WORKSPACE_ID, + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + &path, + &worker_runtime::auth::request_body_digest(b"{}"), + ) + .await + .expect("active create reservation should establish provisional Worker membership"); + assert_eq!(source.worker_id, Some(worker_id.clone())); + + store + .complete_worker_create_reservation(TEST_WORKSPACE_ID, reserved.worker_id) + .unwrap(); + let proof = issue(); + let result = crate::worker_source::verify_runtime_request_source_proof_with_store( + api.store.as_ref(), + &api.config, + &proof, + TEST_WORKSPACE_ID, + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + &path, + &worker_runtime::auth::request_body_digest(b"{}"), + ) + .await; + assert!(matches!( + result, + Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership) + )); + } + #[tokio::test] async fn runtime_request_proof_verifies_path_and_query_for_ticket_search() { let workspace = tempfile::tempdir().unwrap(); @@ -21232,7 +21239,7 @@ mod tests { } #[tokio::test] - async fn browser_workspace_workdir_create_records_failed_default_resolution() { + async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; @@ -21257,7 +21264,7 @@ mod tests { assert_eq!(response["error"], "Bad Request"); assert_eq!( response["diagnostics"][0]["code"], - "runtime_workdir_unsupported" + "embedded_worker_workdir_unsupported" ); set_test_default_runtime(&api, "not-a-registered-runtime"); request_json_authenticated( @@ -21283,7 +21290,7 @@ mod tests { assert_eq!(operation.state, "failed"); assert_eq!( operation.failure.as_deref(), - Some("runtime_workdir_unsupported") + Some("embedded_worker_workdir_unsupported") ); assert_eq!(operation.config_revision, 2); assert!(!operation.config_projection_digest.is_empty()); @@ -22331,10 +22338,8 @@ mod tests { assert_eq!(hosts["items"][0]["runtime_id"], "embedded-worker-runtime"); let host_id = hosts["items"][0]["host_id"].as_str().unwrap().to_string(); assert_eq!(hosts["items"][0]["kind"], "embedded-worker-runtime-host"); - assert_eq!( - hosts["items"][0]["capabilities"]["workspace_scope"], - "backend_internal" - ); + assert_eq!(hosts["items"][0]["os"], std::env::consts::OS); + assert!(hosts["items"][0].get("capabilities").is_none()); assert!(!hosts.to_string().contains("metadata.json")); let runtimes = get_json(app.clone(), "/api/runtimes").await; @@ -22807,11 +22812,8 @@ mod tests { "embedded_worker_runtime" ); assert_eq!(embedded_summary["source"]["status"], "active"); - assert_eq!( - embedded_summary["capabilities"]["workspace_scope"], - "backend_internal" - ); - assert_eq!(embedded_summary["capabilities"]["has_workspace_fs"], false); + assert_eq!(embedded_summary["worker_creation_available"], true); + assert!(embedded_summary.get("capabilities").is_none()); let spawned = post_json( app.clone(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 694ebf24..dac00c94 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1007,6 +1007,11 @@ pub trait ControlPlaneStore: Send + Sync { workspace_id: &str, worker: &RuntimeWorkerRef, ) -> Result>; + fn has_active_worker_create_reservation( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result; fn list_worker_registry( &self, workspace_id: &str, @@ -3273,6 +3278,28 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn has_active_worker_create_reservation( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result { + self.with_conn(|conn| { + conn.query_row( + r#"SELECT EXISTS( + SELECT 1 + FROM worker_create_reservations + WHERE workspace_id = ?1 + AND runtime_id = ?2 + AND worker_id = ?3 + AND state = 'reserved' + )"#, + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| row.get::<_, bool>(0), + ) + .map_err(Error::from) + }) + } + fn list_worker_registry( &self, workspace_id: &str, @@ -10585,6 +10612,12 @@ INSERT INTO workdir_registry ( ); assert_eq!(reserved.memory_settings.settings_revision, 1); assert_eq!(reserved.memory_settings.language, "English"); + let reserved_worker = RuntimeWorkerRef::new("arcadia", reserved.worker_id.to_string()); + assert!( + store + .has_active_worker_create_reservation("workspace-a", &reserved_worker) + .unwrap() + ); let unchanged_memory_settings = store .update_workspace_memory_settings("workspace-a", 1, " English ") .unwrap(); @@ -10658,6 +10691,11 @@ INSERT INTO workdir_registry ( store .complete_worker_create_reservation("workspace-a", reserved.worker_id) .unwrap(); + assert!( + !store + .has_active_worker_create_reservation("workspace-a", &reserved_worker) + .unwrap() + ); let state: String = store .with_conn(|conn| { conn.query_row( diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index 2affde46..1144a132 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -106,7 +106,10 @@ pub async fn verify_runtime_request_source_proof_with_store( let member = store .get_worker_registry(workspace_id, &worker) .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - if member.is_none() { + let reserved = store + .has_active_worker_create_reservation(workspace_id, &worker) + .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; + if member.is_none() && !reserved { return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); } } diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index 4bcafefd..ed70673a 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -54,6 +54,10 @@ impl WorkspaceCatalogService { Self { store } } + pub fn is_empty(&self) -> Result { + Ok(self.store.list_workspaces()?.is_empty()) + } + pub fn list( &self, owner_account_id: Option<&str>, @@ -76,33 +80,16 @@ impl WorkspaceCatalogService { pub fn create( &self, request: WorkspaceCreateRequest, - owner_account_id: Option, + owner_account_id: String, ) -> Result { - self.create_internal(request, owner_account_id, None, false) - } - - pub fn create_first_ownerless( - &self, - request: WorkspaceCreateRequest, - ) -> Result { - self.create_internal(request, None, None, true) - } - - pub fn create_with_workspace_id( - &self, - request: WorkspaceCreateRequest, - owner_account_id: Option, - requested_workspace_id: Option, - ) -> Result { - self.create_internal(request, owner_account_id, requested_workspace_id, false) + self.create_internal(request, owner_account_id, None) } fn create_internal( &self, request: WorkspaceCreateRequest, - owner_account_id: Option, + owner_account_id: String, requested_workspace_id: Option, - require_empty_catalog: bool, ) -> Result { let operation_key = normalize_required( "operation_key", @@ -142,7 +129,7 @@ impl WorkspaceCatalogService { let fingerprint = workspace_create_fingerprint( requested_workspace_id.as_deref(), &display_name, - owner_account_id.as_deref(), + Some(&owner_account_id), &repository_uri, &repository_name, &default_ref, @@ -153,10 +140,10 @@ impl WorkspaceCatalogService { .create_workspace_bootstrap(&WorkspaceBootstrapRecord { operation_key, request_fingerprint: fingerprint.clone(), - require_empty_catalog, + require_empty_catalog: false, workspace: WorkspaceRecord { workspace_id: workspace_id.clone(), - owner_account_id, + owner_account_id: Some(owner_account_id), display_name, state: "active".to_string(), created_at: now.clone(), @@ -235,7 +222,7 @@ fn workspace_create_fingerprint( #[cfg(test)] mod tests { use super::*; - use crate::store::SqliteWorkspaceStore; + use crate::store::{AccountRecord, SqliteWorkspaceStore}; use workspace_api::RepositorySourceKind; fn git_repository() -> tempfile::TempDir { @@ -244,6 +231,22 @@ mod tests { dir } + fn owner_account(store: &SqliteWorkspaceStore) -> String { + let account_id = Uuid::now_v7().to_string(); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + store + .upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_string(), + handle: format!("owner-{}", &account_id[..8]), + display_name: "Workspace Owner".to_string(), + created_at: now.clone(), + updated_at: now, + }) + .unwrap(); + account_id + } + #[tokio::test] async fn create_is_atomic_and_exact_retries_converge() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); @@ -259,8 +262,11 @@ mod tests { }, }; - let created = service.create(request.clone(), None).unwrap(); - let replayed = service.create(request, None).unwrap(); + let owner_account_id = owner_account(store.as_ref()); + let created = service + .create(request.clone(), owner_account_id.clone()) + .unwrap(); + let replayed = service.create(request, owner_account_id).unwrap(); assert!(!created.replayed); assert!(replayed.replayed); @@ -284,64 +290,10 @@ mod tests { ); } - #[test] - fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); - let service = WorkspaceCatalogService::new(store.clone()); - let repository_a = git_repository(); - let repository_b = git_repository(); - let requests = [ - WorkspaceCreateRequest { - operation_key: "bootstrap-a".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository_a.path().display().to_string(), - display_name: None, - default_ref: None, - }, - }, - WorkspaceCreateRequest { - operation_key: "bootstrap-b".to_string(), - display_name: "Workspace B".to_string(), - repository: InitialRepositoryIntent { - uri: repository_b.path().display().to_string(), - display_name: None, - default_ref: None, - }, - }, - ]; - let barrier = Arc::new(std::sync::Barrier::new(2)); - let results = std::thread::scope(|scope| { - requests - .into_iter() - .map(|request| { - let service = service.clone(); - let barrier = barrier.clone(); - scope.spawn(move || { - barrier.wait(); - service.create_first_ownerless(request) - }) - }) - .collect::>() - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>() - }); - - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); - assert_eq!(store.list_workspaces().unwrap().len(), 1); - let error = results - .into_iter() - .find_map(Result::err) - .unwrap() - .to_string(); - assert!(error.contains("catalog is empty"), "{error}"); - } - #[tokio::test] async fn idempotency_key_reuse_with_different_payload_is_rejected() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new(store); let repository = git_repository(); let mut request = WorkspaceCreateRequest { @@ -353,10 +305,15 @@ mod tests { default_ref: None, }, }; - service.create(request.clone(), None).unwrap(); + service + .create(request.clone(), owner_account_id.clone()) + .unwrap(); request.display_name = "Workspace B".to_string(); - let error = service.create(request, None).unwrap_err().to_string(); + let error = service + .create(request, owner_account_id) + .unwrap_err() + .to_string(); assert!(error.contains("different input"), "{error}"); } @@ -378,17 +335,21 @@ mod tests { #[test] fn remote_repository_creation_persists_typed_source_without_auth_metadata() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new(store.clone()); let result = service - .create_first_ownerless(WorkspaceCreateRequest { - operation_key: "remote-create".to_string(), - display_name: "Remote Workspace".to_string(), - repository: InitialRepositoryIntent { - uri: "ssh://git@example.test/org/repository.git".to_string(), - display_name: Some("Remote Repository".to_string()), - default_ref: Some("main".to_string()), + .create( + WorkspaceCreateRequest { + operation_key: "remote-create".to_string(), + display_name: "Remote Workspace".to_string(), + repository: InitialRepositoryIntent { + uri: "ssh://git@example.test/org/repository.git".to_string(), + display_name: Some("Remote Repository".to_string()), + default_ref: Some("main".to_string()), + }, }, - }) + owner_account_id, + ) .unwrap(); let persisted = store diff --git a/docker.nix b/docker.nix index ce56e548..792d85d6 100644 --- a/docker.nix +++ b/docker.nix @@ -264,6 +264,8 @@ in "serve" "--listen" "0.0.0.0:8787" + "--config" + "/server-config/server.toml" ]; Env = [ "PATH=/bin" @@ -274,7 +276,7 @@ in }; Volumes = { "/server-data" = { }; - "/workspace" = { }; + "/server-config" = { }; }; WorkingDir = "/server-data"; }; diff --git a/docs/design/workspace-runtime-docker.md b/docs/design/workspace-runtime-docker.md index 1b01f37c..2f8a2d5d 100644 --- a/docs/design/workspace-runtime-docker.md +++ b/docs/design/workspace-runtime-docker.md @@ -58,12 +58,19 @@ The Compose files live at: ```text compose.yaml -docker/workspace/.yoi/workspace.toml -docker/workspace/.yoi/workspace-backend.local.toml ``` The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime. +The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks: + +```toml +[browser] +public_url = "https://yoi.example.com" +``` + +Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration. + Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly. ## Worker launch path diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index 5345e560..fd30919c 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -153,11 +153,7 @@ For repository builds: cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787 ``` -If the Server DB has no workspace record yet, initialize it first: - -```bash -yoi-server init --workspace -``` +An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration. ## Smoke checks diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml deleted file mode 100644 index 4502ae1d..00000000 --- a/resources/workspace-backend.default.toml +++ /dev/null @@ -1,65 +0,0 @@ -# Workspace Backend local config template. -# -# `yoi-server init` copies this packaged template to -# `.yoi/workspace-backend.local.toml` without overwriting an existing file. -# The `.local` file is intentionally git-ignored. -# -# Print the latest packaged template with: -# yoi-server config default -# -# Compare the local config with the latest packaged template with: -# yoi-server config diff -# -# Omit a key to use the built-in fallback. TOML has no `null`, so optional -# settings are represented by leaving the key commented out. - -[server] -# Backend HTTP/WebSocket listen address. -listen = "127.0.0.1:8787" - -# Browser-facing frontend URL used by local tooling/display. -frontend_url = "http://127.0.0.1:5173" - -# Static SPA build directory override. Leave commented for dev/API-only mode. -# Relative paths are resolved from the workspace root. -# static_assets_dir = "web/workspace/dist" - -[data] -# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback: -# /server/workspaces// -# Relative paths are resolved from the workspace root. -# root = ".yoi/workspace-backend.data" - -# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi -# server DB at `/server/server.db`; keep this commented unless a -# local test needs a custom DB path. -# workspace_database_path = ".yoi/workspace-backend.data/server.db" - -# Explicit embedded Runtime fs-store root override. -# If omitted, this falls back to `/embedded-runtime`. -# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime" - -[limits] -max_records = 200 - -[auth] -# WebAuthn / Passkey relying-party settings. For local development keep rp_id -# aligned with the browser host in public_base_url/origin. -rp_id = "localhost" -origin = "http://localhost:8787" -public_base_url = "http://localhost:8787" -cookie_name = "yoi_workspace_session" - -# Repository registry. Browser/API repository projection reads only configured -# entries and never falls back to the backend process cwd. Relative URI values -# are resolved from this workspace config root. Git is the v0 supported provider. -# -# [[repositories]] -# id = "main" -# provider = "git" -# uri = "." -# display_name = "Main repository" -# default_selector = "HEAD" - -# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml` -# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config. 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 02ada805..2cb0fffe 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 @@ -115,14 +115,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () "top workspace page should not own the Worker list", ); assert( - workersPage.includes("workerConsoleHref(worker, data.workspaceId)") && - workersPage.includes('') && - workersPage.includes( - "workerDisplayName = worker.display_name || worker.label", - ) && - workersPage.includes("worker {worker.worker_id}") && + workersPage.includes("workerHref") && + workersPage.includes("workers-table") && + workersPage.includes("workerDisplayName") && + workersPage.includes("worker.resource_key") && workersPage.includes("Delete ${workerDisplayName}"), - "dedicated Workers page should expose a table, console link target, and icon actions per Worker", + "dedicated Workers page should expose a table, canonical Worker link target, and icon actions per Worker", ); assert( workersNav.includes("href={`/w/${workspaceId}/workers`}") && @@ -218,14 +216,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets and Objectives should each be a single sidebar link", ); assert( - ticketsLoad.includes("?limit=1000") && + ticketsLoad.includes("Object.entries(LANE_STATES)") && + ticketsLoad.includes('limit: "30"') && + ticketsLoad.includes('states: states.join(",")') && + ticketsLoad.includes("/tickets?${search}") && !ticketsLoad.includes("/tickets/query") && ticketsPage.includes('class="ticket-kanban"') && - ticketsPage.includes('class="ticket-lane-cards"') && - ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") && - ticketsPage.includes("handleLaneScroll") && - ticketsPage.includes("revealNextTickets"), - "Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane", + ticketsPage.includes("laneState") && + ticketsPage.includes("loadMore(lane.id)") && + ticketsPage.includes("handleLaneScroll(event, lane.id)"), + "Tickets list should fetch lightweight paginated summaries for each Kanban lane", ); assert( ticketPanelModel.includes('label: "Ready + Planning"') && @@ -250,16 +250,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as assert( ticketDetailLoad.includes("/repositories") && ticketDetailPage.includes('mutate("state", "/state"') && - ticketDetailPage.includes('mutate("queue", "/queue"') && + ticketDetailPage.includes("async function queueTicket") && + ticketDetailPage.includes("`${ticketPath}/queue`") && !ticketDetailPage.includes("/merge-request/merge") && ticketDetailPage.includes("mergeRequest.selector_from") && - ticketDetailPage.includes("currentReview?.kind") && - ticketDetailPage.includes("mergeEvent?.kind") && - !ticketDetailPage.includes('mutate("review", "/review"') && + ticketDetailPage.includes("mergeRequest.review_status") && + ticketDetailPage.includes("mergeRequestPagePath") && ticketDetailPage.includes('mutate("close", "/close"') && - ticketDetailPage.includes("ticketWorkerLaunchHref") && + ticketDetailPage.includes("mutateAssignment") && + ticketDetailPage.includes("can_start_manual_coder") && ticketDetailPage.includes("ticket.relations.outgoing"), - "Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch", + "Ticket detail should expose typed lifecycle actions, relations, target selection, assignments, and Merge Request navigation", ); }); @@ -319,17 +320,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () ); }); -Deno.test("root layout does not keep legacy unscoped route compatibility", async () => { +Deno.test("root layout keeps Workspace selection explicit", async () => { const layoutLoad = await Deno.readTextFile( new URL("./../../../routes/+layout.ts", import.meta.url), ); assert( - !layoutLoad.includes("scopedCompatibilityRoute") && - !layoutLoad.includes('pathname === "/runtimes"') && - !layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") && - layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"), - "root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes", + layoutLoad.includes("export const load") && + layoutLoad.includes("() => ({})") && + !layoutLoad.includes("scopedCompatibilityRoute") && + !layoutLoad.includes("/api/workspace") && + !layoutLoad.includes("workspaceRoute") && + !layoutLoad.includes("redirect("), + "root layout should not infer, bootstrap, or redirect through a singleton Workspace", ); }); @@ -811,9 +814,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho "SidebarOverride should register and clean up the child-provided sidebar snippet", ); assert( - rootLayoutLoad.includes('"/account"') && - rootLayoutLoad.includes('"/login/device"'), - "Root layout should not redirect account and device-login public routes to a workspace", + rootLayoutLoad.includes("export const load") && + rootLayoutLoad.includes("() => ({})") && + !rootLayoutLoad.includes("workspaceRoute") && + !rootLayoutLoad.includes("redirect("), + "Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely", ); }); diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index ed76fbf6..f6571ba8 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -33,7 +33,7 @@ export type RuntimeConnectionSummary = { built_in: boolean; config_managed: boolean; active: boolean; - can_spawn_worker: boolean; + worker_creation_available: boolean; restart_required: boolean; status: string; diagnostics: Diagnostic[]; diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 3f99df9b..3e252b7a 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -29,30 +29,15 @@ export type Diagnostic = { message: string; }; -export type RuntimeCapabilities = { - can_list_hosts: boolean; - can_list_workers: boolean; - can_get_worker: boolean; - can_spawn_worker: boolean; - can_stop_worker: boolean; - has_workspace_fs: boolean; - has_shell: boolean; - has_git: boolean; - supports_worktrees: boolean; - supports_backend_internal_tools: boolean; - workspace_scope: string; - os: string; - arch: string; - max_workers: number; -}; - export type Runtime = { runtime_id: string; label: string; kind: string; status: string; host_ids: string[]; - capabilities: RuntimeCapabilities; + worker_creation_available: boolean; + os: string; + arch: string; diagnostics: Diagnostic[]; }; @@ -64,7 +49,8 @@ export type Host = { status: string; observed_at: string; last_seen_at: string | null; - capabilities: RuntimeCapabilities; + os: string; + arch: string; diagnostics: Diagnostic[]; }; @@ -100,7 +86,7 @@ export type WorkerLaunchRuntimeOption = { runtime_id: string; display_name: string; built_in: boolean; - can_spawn_worker: boolean; + worker_creation_available: boolean; working_directory_required: boolean; status: string; diagnostics: Diagnostic[]; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 4e76efaf..12c8d1ac 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -23,7 +23,7 @@ const options: WorkerLaunchOptionsResponse = { runtime_id: "remote", display_name: "Remote", status: "active", - can_spawn_worker: true, + worker_creation_available: true, built_in: false, working_directory_required: true, diagnostics: [], @@ -32,7 +32,7 @@ const options: WorkerLaunchOptionsResponse = { runtime_id: "embedded", display_name: "Embedded", status: "active", - can_spawn_worker: true, + worker_creation_available: true, built_in: true, working_directory_required: false, diagnostics: [], diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index e6068cb0..ba23c3e6 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -30,9 +30,9 @@ export function defaultWorkerLaunchForm( ): WorkerLaunchFormState { const preferredRuntime = options?.runtimes.find((runtime) => - runtime.can_spawn_worker && runtime.status === "active" + runtime.worker_creation_available && runtime.status === "active" ) ?? - options?.runtimes.find((runtime) => runtime.can_spawn_worker) ?? + options?.runtimes.find((runtime) => runtime.worker_creation_available) ?? options?.runtimes[0]; const preferredProfile = options?.profiles.find((candidate) => candidate.id === options.default_profile diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index d790fc4c..3433f0b0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -88,7 +88,7 @@
Platform
-
{host.capabilities.os} / {host.capabilities.arch}
+
{host.os} / {host.arch}
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 index 257cb237..70b4fe10 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtime-connections/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtime-connections/+page.svelte @@ -183,7 +183,7 @@ built_in: true, config_managed: false, active: false, - can_spawn_worker: false, + worker_creation_available: false, restart_required: false, status: 'unknown', diagnostics: [] 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 f2a714b7..66f4aa59 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -5,7 +5,7 @@ let { data }: PageProps = $props(); function runtimePlatform(runtime: Runtime): string { - return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`; + return `${runtime.os} / ${runtime.arch}`; } @@ -37,7 +37,6 @@ - @@ -51,7 +50,6 @@ -
Kind Status PlatformCapacity Workdirs
{runtime.kind} {runtime.status} {runtimePlatform(runtime)}{runtime.capabilities.max_workers} workers Open workdirs diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 87750c61..80b43799 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -313,7 +313,7 @@