diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 07eed62e..aa389376 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -590,7 +590,7 @@ pub struct WorkspaceResponse { pub extension_points: WorkspaceExtensionPoints, } -/// Workspace identity metadata exposed by the current settings resource. +/// Workspace display metadata exposed from the Server DB settings authority. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index ec7ce820..27ced7db 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -5,8 +5,8 @@ use std::{fs, io}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::identity::WorkspaceIdentity; use crate::server::{AuthConfig, ServerConfig}; +use crate::store::WorkspaceRecord; use crate::{Error, Result}; pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml"; @@ -100,15 +100,15 @@ impl ServerHostConfigFile { impl ResolvedWorkspaceBackendConfig { pub fn local_dev( workspace_root: impl AsRef, - identity: WorkspaceIdentity, + workspace: WorkspaceRecord, host_config: &ServerHostConfigFile, ) -> Result { let workspace_root = workspace_root.as_ref(); - let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id); + let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.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); + let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace); server.database_path = database_path.clone(); server.embedded_runtime_store_root = data_root.join("embedded-runtime"); server.max_records = DEFAULT_MAX_RECORDS; @@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> { mod tests { use super::*; - fn identity() -> WorkspaceIdentity { - WorkspaceIdentity { + fn workspace() -> WorkspaceRecord { + WorkspaceRecord { workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(), + owner_account_id: "018f6a2c-1111-7000-8000-000000000002".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), display_name: "Workspace".to_string(), + state: "active".to_string(), } } @@ -197,7 +200,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); ResolvedWorkspaceBackendConfig::local_dev( dir.path(), - identity(), + workspace(), &ServerHostConfigFile::default(), ) .unwrap() @@ -250,7 +253,7 @@ mod tests { .unwrap(); let resolved = ResolvedWorkspaceBackendConfig::local_dev( tempfile::tempdir().unwrap().path(), - identity(), + workspace(), &host_config, ) .unwrap(); @@ -280,7 +283,7 @@ mod tests { }; let result = ResolvedWorkspaceBackendConfig::local_dev( tempfile::tempdir().unwrap().path(), - identity(), + workspace(), &host_config, ); let error = match result { diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs deleted file mode 100644 index 25b56bb7..00000000 --- a/crates/workspace-server/src/identity.rs +++ /dev/null @@ -1,355 +0,0 @@ -use std::fs::{self, OpenOptions}; -use std::io::{ErrorKind, Write}; -use std::path::{Path, PathBuf}; - -use chrono::{SecondsFormat, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{Error, Result}; - -pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml"; - -/// Stable local Workspace identity persisted as a tracked, safe project record. -/// -/// The v0 TOML schema contains identity metadata plus optional tracked project -/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected -/// here because this loader cannot safely round-trip future local runtime settings -/// without risking accidental path or secret persistence. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkspaceIdentity { - pub workspace_id: String, - pub created_at: String, - pub display_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct WorkspaceIdentityFile { - workspace_id: String, - created_at: String, - display_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - ticket: Option, -} - -impl WorkspaceIdentity { - pub fn load_or_init(workspace_root: impl AsRef) -> Result { - Self::load_or_init_with_clock(workspace_root.as_ref(), || { - Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) - }) - } - - pub fn load_required(workspace_root: impl AsRef) -> Result { - let path = Self::path(workspace_root.as_ref()); - match fs::read_to_string(&path) { - Ok(raw) => Self::parse_str(&raw, &path), - Err(error) if error.kind() == ErrorKind::NotFound => { - Err(Error::WorkspaceIdentity(format!( - "workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing", - workspace_root.as_ref().display() - ))) - } - Err(error) => Err(Error::Io(error)), - } - } - - pub fn path(workspace_root: impl AsRef) -> PathBuf { - workspace_root - .as_ref() - .join(WORKSPACE_IDENTITY_RELATIVE_PATH) - } - - pub fn parse_str(raw: &str, path: impl AsRef) -> Result { - let path = path.as_ref(); - let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| { - workspace_identity_error(path, format!("failed to parse TOML: {error}")) - })?; - Self::from_file(parsed, path) - } - - fn load_or_init_with_clock( - workspace_root: &Path, - now_utc_rfc3339: impl FnOnce() -> String, - ) -> Result { - let path = Self::path(workspace_root); - match fs::read_to_string(&path) { - Ok(raw) => Self::parse_str(&raw, &path), - Err(error) if error.kind() == ErrorKind::NotFound => { - Self::init(workspace_root, &path, now_utc_rfc3339()) - } - Err(error) => Err(Error::Io(error)), - } - } - - fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result { - validate_created_at(&created_at, path)?; - let display_name = workspace_display_name_from_root(workspace_root, path)?; - let workspace_id = Uuid::now_v7().to_string(); - let identity = Self { - workspace_id, - created_at, - display_name, - }; - identity.write_new_or_read_existing(path) - } - - fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result { - let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?; - validate_created_at(&parsed.created_at, path)?; - validate_display_name(&parsed.display_name, path)?; - Ok(Self { - workspace_id, - created_at: parsed.created_at, - display_name: parsed.display_name, - }) - } - - fn write_new_or_read_existing(&self, path: &Path) -> Result { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(&WorkspaceIdentityFile { - workspace_id: self.workspace_id.clone(), - created_at: self.created_at.clone(), - display_name: self.display_name.clone(), - ticket: None, - }) - .map_err(|error| { - workspace_identity_error(path, format!("failed to encode TOML: {error}")) - })?; - - match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(mut file) => { - file.write_all(raw.as_bytes())?; - file.sync_all()?; - Ok(self.clone()) - } - Err(error) if error.kind() == ErrorKind::AlreadyExists => { - let raw = fs::read_to_string(path)?; - Self::parse_str(&raw, path) - } - Err(error) => Err(Error::Io(error)), - } - } -} - -fn validate_workspace_id(value: &str, path: &Path) -> Result { - let uuid = Uuid::parse_str(value).map_err(|error| { - workspace_identity_error(path, format!("workspace_id is not a UUID: {error}")) - })?; - if uuid.get_version_num() != 7 { - return Err(workspace_identity_error( - path, - "workspace_id must be a UUIDv7 canonical string".to_string(), - )); - } - let canonical = uuid.to_string(); - if value != canonical { - return Err(workspace_identity_error( - path, - "workspace_id must use lowercase hyphenated UUID canonical form".to_string(), - )); - } - Ok(canonical) -} - -fn validate_created_at(value: &str, path: &Path) -> Result<()> { - let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| { - workspace_identity_error(path, format!("created_at is not RFC3339: {error}")) - })?; - if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') { - return Err(workspace_identity_error( - path, - "created_at must be a UTC RFC3339 timestamp ending in Z".to_string(), - )); - } - Ok(()) -} - -fn validate_display_name(value: &str, path: &Path) -> Result<()> { - if value.trim().is_empty() { - return Err(workspace_identity_error( - path, - "display_name must not be empty".to_string(), - )); - } - if value.contains('\0') || value.chars().any(|ch| ch.is_control()) { - return Err(workspace_identity_error( - path, - "display_name must not contain control characters".to_string(), - )); - } - Ok(()) -} - -fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result { - let display_name = workspace_root - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - workspace_identity_error( - path, - "workspace root must have a UTF-8 final path component".to_string(), - ) - })? - .to_string(); - validate_display_name(&display_name, path)?; - Ok(display_name) -} - -fn workspace_identity_error(path: &Path, message: String) -> Error { - Error::WorkspaceIdentity(format!("{}: {message}", path.display())) -} - -#[cfg(test)] -mod tests { - use super::*; - - const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; - const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z"; - - #[test] - fn load_required_rejects_uninitialized_workspace_without_creating_identity() { - let temp = tempfile::tempdir().unwrap(); - let workspace_root = temp.path().join("uninitialized-workspace"); - fs::create_dir_all(&workspace_root).unwrap(); - - let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); - - assert!( - error.to_string().contains("workspace identity is missing"), - "unexpected error: {error}" - ); - assert!(!WorkspaceIdentity::path(&workspace_root).exists()); - } - - #[test] - fn missing_identity_file_is_created_with_safe_fields() { - let temp = tempfile::tempdir().unwrap(); - let workspace_root = temp.path().join("example-workspace"); - fs::create_dir_all(&workspace_root).unwrap(); - - let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { - FIXED_CREATED_AT.to_string() - }) - .unwrap(); - - assert_eq!(identity.display_name, "example-workspace"); - assert_eq!(identity.created_at, FIXED_CREATED_AT); - validate_workspace_id( - &identity.workspace_id, - &WorkspaceIdentity::path(&workspace_root), - ) - .unwrap(); - - let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap(); - assert!(raw.contains("workspace_id")); - assert!(raw.contains("display_name")); - assert!(raw.contains("created_at")); - assert!(!raw.contains(&workspace_root.to_string_lossy().to_string())); - - let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { - "2026-06-24T00:00:00Z".to_string() - }) - .unwrap(); - assert_eq!(reloaded, identity); - } - - #[test] - fn existing_identity_file_is_stable() { - let temp = tempfile::tempdir().unwrap(); - let workspace_root = temp.path().join("moved-workspace"); - let yoi_dir = workspace_root.join(".yoi"); - fs::create_dir_all(&yoi_dir).unwrap(); - let path = yoi_dir.join("workspace.toml"); - let raw = format!( - "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n" - ); - fs::write(&path, &raw).unwrap(); - - let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { - "2026-06-24T00:00:00Z".to_string() - }) - .unwrap(); - - assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID); - assert_eq!(identity.created_at, FIXED_CREATED_AT); - assert_eq!(identity.display_name, "Stable Project"); - assert_eq!(fs::read_to_string(path).unwrap(), raw); - } - - #[test] - fn create_new_race_returns_existing_persisted_identity() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join(".yoi/workspace.toml"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let persisted_raw = format!( - "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n" - ); - fs::write(&path, &persisted_raw).unwrap(); - let generated = WorkspaceIdentity { - workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(), - created_at: "2026-06-24T00:00:00Z".to_string(), - display_name: "Generated Project".to_string(), - }; - - let returned = generated.write_new_or_read_existing(&path).unwrap(); - - assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID); - assert_eq!(returned.created_at, FIXED_CREATED_AT); - assert_eq!(returned.display_name, "Persisted Project"); - assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw); - } - - #[test] - fn invalid_identity_file_fails_closed_without_rewriting() { - let temp = tempfile::tempdir().unwrap(); - let workspace_root = temp.path().join("invalid-workspace"); - let yoi_dir = workspace_root.join(".yoi"); - fs::create_dir_all(&yoi_dir).unwrap(); - let path = yoi_dir.join("workspace.toml"); - let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n"; - fs::write(&path, raw).unwrap(); - - let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { - FIXED_CREATED_AT.to_string() - }) - .unwrap_err(); - - assert!(error.to_string().contains("workspace_id is not a UUID")); - assert_eq!(fs::read_to_string(path).unwrap(), raw); - } - - #[test] - fn generated_identity_does_not_leak_parent_paths() { - let temp = tempfile::tempdir().unwrap(); - let secret_parent = temp.path().join("user-secret-parent"); - let workspace_root = secret_parent.join("public-project-name"); - fs::create_dir_all(&workspace_root).unwrap(); - - WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || { - FIXED_CREATED_AT.to_string() - }) - .unwrap(); - let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap(); - - assert!(raw.contains("public-project-name")); - assert!(!raw.contains(&secret_parent.to_string_lossy().to_string())); - assert!(!raw.contains("user-secret-parent")); - assert!(!raw.contains("/")); - } - - #[test] - fn unknown_fields_are_rejected() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("workspace.toml"); - let raw = format!( - "workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n" - ); - - let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err(); - - assert!(error.to_string().contains("unknown field")); - } -} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index fe50e5a0..03c3e496 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -10,7 +10,6 @@ pub mod companion; pub mod config; pub mod config_source; pub mod hosts; -pub mod identity; pub mod memory_backend; pub mod memory_staging; pub mod observation; @@ -43,7 +42,6 @@ pub use authority::{ WorkspaceAuthority, }; pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; -pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader}; pub use server::{ @@ -137,8 +135,6 @@ pub enum Error { RegistryInconsistency(String), #[error("Worker source identity is invalid: {0}")] WorkerSourceIdentity(String), - #[error("workspace identity error: {0}")] - WorkspaceIdentity(String), #[error("Workspace signing identity error ({code}): {message}")] WorkspaceSigningIdentity { code: String, message: String }, #[error("store error: {0}")] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 1ceb38a5..08a3d9c3 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -14,7 +14,7 @@ use yoi_workspace_server::store::{ }; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, - WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, + WorkspaceRecord, serve_workspace_catalog, }; #[derive(Debug)] @@ -235,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result<(), Box ServerHostConfigFile::load_from_path(path)?, None => ServerHostConfigFile::load_default()?, }; - let mut resolved = - ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?; + let mut resolved = ResolvedWorkspaceBackendConfig::local_dev( + &workspace_execution_root, + workspace, + &host_config, + )?; resolved.database_path = database_path.clone(); resolved.server.database_path = database_path.clone(); append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?; @@ -322,7 +325,9 @@ fn append_workspace_runtime_sources( Ok(()) } -fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result { +fn workspace_execution_root_from_server_data( + workspace: &WorkspaceRecord, +) -> Result { Ok(ServerConfig::default_workspace_backend_data_root( &workspace.workspace_id, )) diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index b7f01cbd..0e37f4a9 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -1,11 +1,9 @@ use std::collections::BTreeMap; -use std::fs; use std::path::{Component, Path, PathBuf}; -use std::time::UNIX_EPOCH; use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sha2::{Digest, Sha256}; use worker::EffectivePromptCatalog; use worker_runtime::config_bundle::{ @@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{ }; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; use workspace_api::{ - Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest, - WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance, + ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance, WorkspaceProfileSourceSummary, WorkspaceProfileSummary, }; use crate::config_source::{ WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, }; +use crate::store::WorkspaceRecord; use crate::{Error, Result}; const PROFILE_SCHEMA_SOURCE: &str = r#"{ @@ -467,103 +465,29 @@ fn build_virtual_profile_archive( .map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string())) } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct WorkspaceIdentityFile { - workspace_id: String, - created_at: String, - display_name: String, -} - pub fn workspace_metadata_settings( - workspace_root: &Path, - fallback_workspace_id: &str, - fallback_created_at: &str, - fallback_display_name: &str, + workspace: &WorkspaceRecord, ) -> WorkspaceMetadataSettingsResponse { - let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); - let mut diagnostics = Vec::new(); - let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) { - Ok(raw) => match toml::from_str::(&raw) { - Ok(file) => (file.workspace_id, file.created_at, file.display_name), - Err(err) => { - diagnostics.push(diagnostic( - "workspace_identity_parse_failed", - DiagnosticSeverity::Error, - format!("Workspace identity could not be parsed: {err}"), - )); - ( - fallback_workspace_id.to_string(), - fallback_created_at.to_string(), - fallback_display_name.to_string(), - ) - } - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - diagnostics.push(diagnostic( - "workspace_identity_missing", - DiagnosticSeverity::Warning, - "Workspace identity record is missing; showing active backend metadata.", - )); - ( - fallback_workspace_id.to_string(), - fallback_created_at.to_string(), - fallback_display_name.to_string(), - ) - } - Err(err) => { - diagnostics.push(diagnostic( - "workspace_identity_read_failed", - DiagnosticSeverity::Error, - format!( - "Workspace identity could not be read: {}", - sanitize_error(&err.to_string()) - ), - )); - ( - fallback_workspace_id.to_string(), - fallback_created_at.to_string(), - fallback_display_name.to_string(), - ) - } - }; WorkspaceMetadataSettingsResponse { - workspace_id, - display_name, - created_at, - revision: file_revision(&path), - source: "workspace_identity".to_string(), - diagnostics, + workspace_id: workspace.workspace_id.clone(), + display_name: workspace.display_name.clone(), + created_at: workspace.created_at.clone(), + revision: workspace.updated_at.clone(), + source: "server_db".to_string(), + diagnostics: Vec::new(), } } -pub fn update_workspace_metadata( - workspace_root: &Path, - request: UpdateWorkspaceMetadataRequest, -) -> Result { - let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); - let current_revision = file_revision(&path); - if request.revision != current_revision { +pub fn sanitize_workspace_display_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 { return Err(Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), - code: "workspace_metadata_revision_conflict".to_string(), - message: "Workspace metadata changed before this update was applied".to_string(), + code: "workspace_display_name_invalid".to_string(), + message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(), }); } - let raw = fs::read_to_string(&path)?; - let mut file: WorkspaceIdentityFile = toml::from_str(&raw) - .map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?; - let display_name = sanitize_display_name(&request.display_name)?; - file.display_name = display_name; - let encoded = toml::to_string_pretty(&file) - .map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?; - fs::write(&path, encoded)?; - Ok(workspace_metadata_settings( - workspace_root, - &file.workspace_id, - &file.created_at, - &file.display_name, - )) + Ok(trimmed.to_string()) } fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { @@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec { specifiers } -fn sanitize_display_name(value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "workspace_display_name_invalid".to_string(), - message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(), - }); - } - Ok(trimmed.to_string()) -} pub fn selector_for_builtin_candidate( id: &str, ) -> Option { @@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate( _ => None, } } -fn file_revision(path: &Path) -> String { - let Ok(metadata) = fs::metadata(path) else { - return "missing".to_string(); - }; - let modified = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - format!("rev:{modified}:{}", metadata.len()) -} -fn diagnostic( - code: impl Into, - severity: DiagnosticSeverity, - message: impl Into, -) -> Diagnostic { - Diagnostic { - code: code.into(), - severity, - message: message.into(), - } -} -fn sanitize_error(value: &str) -> String { - value - .split_whitespace() - .map(|token| { - if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") - { - "" - } else { - token - } - }) - .collect::>() - .join(" ") -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() { + let workspace = WorkspaceRecord { + workspace_id: "workspace-a".to_string(), + owner_account_id: "owner-account".to_string(), + display_name: "Workspace A".to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }; + + let settings = workspace_metadata_settings(&workspace); + + assert_eq!(settings.workspace_id, workspace.workspace_id); + assert_eq!(settings.display_name, workspace.display_name); + assert_eq!(settings.created_at, workspace.created_at); + assert_eq!(settings.revision, workspace.updated_at); + assert_eq!(settings.source, "server_db"); + assert!(settings.diagnostics.is_empty()); + } + + #[test] + fn workspace_display_name_validation_is_bounded() { + assert_eq!( + sanitize_workspace_display_name(" Workspace A ").unwrap(), + "Workspace A" + ); + assert!(sanitize_workspace_display_name("\n").is_err()); + assert!(sanitize_workspace_display_name(&"a".repeat(121)).is_err()); + } + fn valid_decodal(slug: &str) -> String { format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e3b63f75..7e23fae1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -142,7 +142,6 @@ use crate::hosts::{ WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address, is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary, }; -use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_staging::{ list_memory_staging_from_authority, memory_staging_backlog_from_authority, @@ -211,7 +210,7 @@ pub struct ServerConfig { pub workspace_id: String, pub workspace_display_name: String, pub workspace_created_at: String, - pub workspace_root: PathBuf, + pub workspace_execution_root: PathBuf, pub database_path: PathBuf, pub embedded_runtime_store_root: PathBuf, pub static_assets_dir: Option, @@ -224,16 +223,18 @@ pub struct ServerConfig { } impl ServerConfig { - pub fn local_dev(workspace_root: impl Into, identity: WorkspaceIdentity) -> Self { - let workspace_root = workspace_root.into(); - let workspace_id = identity.workspace_id; + pub fn local_dev( + workspace_execution_root: impl Into, + workspace: WorkspaceRecord, + ) -> Self { + let workspace_id = workspace.workspace_id.clone(); let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id); let database_path = Self::default_server_database_path(); Self { - workspace_id, - workspace_display_name: identity.display_name, - workspace_created_at: identity.created_at, - workspace_root, + workspace_id: workspace.workspace_id, + workspace_display_name: workspace.display_name, + workspace_created_at: workspace.created_at, + workspace_execution_root: workspace_execution_root.into(), database_path, embedded_runtime_store_root, static_assets_dir: None, @@ -320,9 +321,8 @@ impl ServerConfig { workspace.workspace_id ))); } - let workspace_data_root = + let workspace_execution_root = Self::default_workspace_backend_data_root(&workspace.workspace_id); - let workspace_root = workspace_data_root.clone(); let repositories = repositories .into_iter() .map(|repository| ConfiguredRepository { @@ -346,7 +346,7 @@ impl ServerConfig { scoped .workspace_created_at .clone_from(&workspace.created_at); - scoped.workspace_root = workspace_root; + scoped.workspace_execution_root = workspace_execution_root; scoped.embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace.workspace_id); scoped.repositories = repositories; @@ -2138,7 +2138,7 @@ impl WorkspaceApi { ), ); let execution_backend = WorkerRuntimeExecutionBackend::new( - ProfileRuntimeWorkerFactory::new(config.workspace_root.clone()) + ProfileRuntimeWorkerFactory::new(config.workspace_execution_root.clone()) .with_embedded_worker_mutation_dispatcher( EMBEDDED_RUNTIME_ID, worker_remove_dispatcher.clone(), @@ -2985,14 +2985,11 @@ fn load_configured_repositories_from_store( store .list_repositories(&config.workspace_id)? .into_iter() - .map(|record| configured_repository_from_record(&config.workspace_root, record)) + .map(configured_repository_from_record) .collect() } -fn configured_repository_from_record( - _workspace_root: &Path, - record: RepositoryRecord, -) -> Result { +fn configured_repository_from_record(record: RepositoryRecord) -> Result { let provider = record.provider.unwrap_or_else(|| record.kind.clone()); let path = repository_local_path(&record.source); Ok(ConfiguredRepository { @@ -3983,7 +3980,7 @@ struct TranscriptQuery { limit: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct ScopedWorkspacePath { workspace_id: String, } @@ -4212,11 +4209,13 @@ async fn scoped_get_workspace_settings( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = api + .store + .get_workspace(&path.workspace_id) + .await? + .ok_or_else(|| Error::InvalidRecordId(path.workspace_id))?; Ok(Json(crate::profile_settings::workspace_metadata_settings( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - &api.config.workspace_display_name, + &workspace, ))) } @@ -4226,8 +4225,31 @@ async fn scoped_update_workspace_settings( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let workspace = - crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; + let display_name = + crate::profile_settings::sanitize_workspace_display_name(&request.display_name)?; + let current = api + .store + .get_workspace(&path.workspace_id) + .await? + .ok_or_else(|| Error::InvalidRecordId(path.workspace_id.clone()))?; + if request.revision != current.updated_at { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_metadata_revision_conflict".to_string(), + message: "Workspace metadata changed before this update was applied".to_string(), + } + .into()); + } + let workspace = api + .store + .update_workspace_display_name(&path.workspace_id, ¤t.updated_at, &display_name) + .await? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_metadata_revision_conflict".to_string(), + message: "Workspace metadata changed before this update was applied".to_string(), + })?; + let workspace = crate::profile_settings::workspace_metadata_settings(&workspace); Ok(Json(WorkspaceMetadataMutationResponse { workspace, diagnostics: vec![workspace_api::Diagnostic { @@ -13531,22 +13553,20 @@ async fn get_workspace( let cookie_name = auth_public_config(&api.config).cookie_name; let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?; let schema_version = api.store.schema_version().await?; - let stored = api.store.get_workspace(api.workspace_id()).await?; - let is_owner = actor.as_ref().is_some_and(|actor| { - stored - .as_ref() - .is_some_and(|workspace| workspace.owner_account_id == actor.account_id) - }); - let display_name = stored + let stored = api + .store + .get_workspace(api.workspace_id()) + .await? + .ok_or_else(|| Error::InvalidRecordId(api.workspace_id().to_string()))?; + let is_owner = actor .as_ref() - .map(|record| record.display_name.clone()) - .unwrap_or_else(|| api.config.workspace_display_name.clone()); + .is_some_and(|actor| stored.owner_account_id == actor.account_id); let companion_status = api.companion.status(); let companion_console = companion_console_extension_point(&companion_status); Ok(Json(WorkspaceResponse { - workspace_id: api.config.workspace_id.clone(), - display_name, - record_authority: "local_yoi_project_records".to_string(), + workspace_id: stored.workspace_id, + display_name: stored.display_name, + record_authority: "server_db".to_string(), schema_version, auth: api.config.auth.clone(), permissions: WorkspacePermissionSummary { @@ -21945,11 +21965,14 @@ mod tests { } } - fn test_identity() -> WorkspaceIdentity { - WorkspaceIdentity { + fn test_workspace() -> WorkspaceRecord { + WorkspaceRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), + owner_account_id: "owner-account".to_string(), display_name: "Test Workspace".to_string(), created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + state: "active".to_string(), } } @@ -21993,7 +22016,7 @@ mod tests { workspace_api::RepositorySourceKind::Https ); assert_eq!( - scoped.workspace_root, + scoped.workspace_execution_root, ServerConfig::default_workspace_backend_data_root("remote-workspace") ); } @@ -22048,7 +22071,7 @@ mod tests { fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); - let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity()) + let mut config = ServerConfig::local_dev(workspace_root.clone(), test_workspace()) .with_embedded_runtime_store_root(store_root); config.database_path = workspace_root.join(".test-yoi-server.db"); config.backend_base_url = Some("http://127.0.0.1:8787".to_string()); @@ -25939,6 +25962,69 @@ mod tests { assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); } + #[tokio::test] + async fn workspace_metadata_settings_ignore_repository_identity_file_and_update_server_db() { + let temp = tempfile::tempdir().unwrap(); + let local_identity_path = temp.path().join(".yoi/workspace.toml"); + fs::create_dir_all(local_identity_path.parent().unwrap()).unwrap(); + fs::write(&local_identity_path, "not valid toml = [").unwrap(); + let api = test_api(temp.path()).await; + let path = ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }; + + let current = scoped_get_workspace_settings(State(api.clone()), AxumPath(path.clone())) + .await + .unwrap() + .0; + assert_eq!(current.workspace_id, TEST_WORKSPACE_ID); + assert_eq!(current.display_name, "Test Workspace"); + assert_eq!(current.source, "server_db"); + assert!(current.diagnostics.is_empty()); + + let updated = scoped_update_workspace_settings( + State(api.clone()), + AxumPath(path), + Json(UpdateWorkspaceMetadataRequest { + display_name: " Renamed Workspace ".to_string(), + revision: current.revision.clone(), + }), + ) + .await + .unwrap() + .0 + .workspace; + assert_eq!(updated.display_name, "Renamed Workspace"); + assert_ne!(updated.revision, current.revision); + assert_eq!( + fs::read_to_string(&local_identity_path).unwrap(), + "not valid toml = [" + ); + assert_eq!( + api.store + .get_workspace(TEST_WORKSPACE_ID) + .await + .unwrap() + .unwrap() + .display_name, + "Renamed Workspace" + ); + + let stale = scoped_update_workspace_settings( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(UpdateWorkspaceMetadataRequest { + display_name: "Stale Workspace".to_string(), + revision: current.revision, + }), + ) + .await + .unwrap_err(); + assert_eq!(stale.into_response().status(), StatusCode::CONFLICT); + } + async fn test_api_with_recording_backend( workspace_root: impl Into, ) -> (WorkspaceApi, Arc) { @@ -26908,7 +26994,7 @@ mod tests { provider: Some("git".to_string()), source: workspace_api::RepositorySource { kind: workspace_api::RepositorySourceKind::LocalPath, - uri: api.config.workspace_root.display().to_string(), + uri: api.config.workspace_execution_root.display().to_string(), }, default_ref: Some("HEAD".to_string()), source_revision: 1, @@ -30158,7 +30244,7 @@ mod tests { let typed_workspace: workspace_api::WorkspaceResponse = serde_json::from_value(workspace.clone()).unwrap(); assert!(!typed_workspace.permissions.manage_repositories); - assert_eq!(workspace["record_authority"], "local_yoi_project_records"); + assert_eq!(workspace["record_authority"], "server_db"); assert_eq!( workspace["extension_points"]["host_worker_bridge"]["status"], "runtime_registry" @@ -30774,7 +30860,7 @@ mod tests { ); assert!(!default_root.starts_with(workspace_root.join(".yoi"))); - let mut config = ServerConfig::local_dev(workspace_root, test_identity()) + let mut config = ServerConfig::local_dev(workspace_root, test_workspace()) .with_embedded_runtime_store_root(default_root.clone()); config.database_path = ServerConfig::server_database_path_for_data_dir(&data_dir); let store = test_control_store(&config); @@ -31496,7 +31582,7 @@ mod tests { async fn scoped_flow_source_route_persists_compiled_dcdl() { let temp = tempfile::tempdir().unwrap(); let app = test_app(temp.path()).await; - let workspace_id = test_identity().workspace_id; + let workspace_id = test_workspace().workspace_id; let source = r#"{ schema_version = 1; name = "route-flow"; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 5833be3c..553cea95 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -840,6 +840,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; + async fn update_workspace_display_name( + &self, + workspace_id: &str, + expected_updated_at: &str, + display_name: &str, + ) -> Result>; fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, @@ -3305,6 +3311,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + async fn update_workspace_display_name( + &self, + workspace_id: &str, + expected_updated_at: &str, + display_name: &str, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + validate_non_empty("expected_updated_at", expected_updated_at)?; + validate_non_empty("display_name", display_name)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = tx + .query_row( + r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at + FROM workspaces WHERE workspace_id = ?1"#, + params![workspace_id], + read_workspace_record, + ) + .optional()?; + let Some(current) = current else { + tx.commit()?; + return Ok(None); + }; + if current.updated_at != expected_updated_at { + tx.commit()?; + return Ok(None); + } + if current.display_name == display_name { + tx.commit()?; + return Ok(Some(current)); + } + + let now = chrono::Utc::now(); + let updated_at = chrono::DateTime::parse_from_rfc3339(¤t.updated_at) + .ok() + .map(|previous| previous.with_timezone(&chrono::Utc)) + .filter(|previous| *previous >= now) + .map(|previous| previous + chrono::Duration::nanoseconds(1)) + .unwrap_or(now) + .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); + let changed = tx.execute( + r#"UPDATE workspaces + SET display_name = ?3, updated_at = ?4 + WHERE workspace_id = ?1 AND updated_at = ?2"#, + params![workspace_id, expected_updated_at, display_name, updated_at], + )?; + if changed != 1 { + tx.commit()?; + return Ok(None); + } + let updated = WorkspaceRecord { + display_name: display_name.to_string(), + updated_at, + ..current + }; + tx.commit()?; + Ok(Some(updated)) + }) + } + fn create_workspace_bootstrap( &self, record: &WorkspaceBootstrapRecord, @@ -12336,6 +12402,49 @@ mod tests { ); } + #[tokio::test] + async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + let record = WorkspaceRecord { + workspace_id: "workspace-a".to_string(), + owner_account_id: "owner-account".to_string(), + display_name: "Before".to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + store.upsert_workspace(&record).await.unwrap(); + + let updated = store + .update_workspace_display_name(&record.workspace_id, &record.updated_at, "After") + .await + .unwrap() + .unwrap(); + assert_eq!(updated.workspace_id, record.workspace_id); + assert_eq!(updated.owner_account_id, record.owner_account_id); + assert_eq!(updated.created_at, record.created_at); + assert_eq!(updated.state, record.state); + assert_eq!(updated.display_name, "After"); + assert_ne!(updated.updated_at, record.updated_at); + + assert!( + store + .update_workspace_display_name(&record.workspace_id, &record.updated_at, "Stale",) + .await + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_workspace(&record.workspace_id) + .await + .unwrap() + .unwrap(), + updated + ); + } + #[tokio::test] async fn objective_creation_allocates_and_resolves_workspace_resource_key() { let dir = tempfile::tempdir().unwrap();