diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 6f97f26e..74b1a0df 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -392,6 +392,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 978a10b1..5334be9b 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -307,6 +307,8 @@ pub struct WorkerSpawnRequest { pub resolved_working_directory_request: Option, #[serde(skip, default)] pub resolved_working_directory: Option, + #[serde(skip, default)] + pub resolved_config_bundle: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1348,17 +1350,23 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); - let config_bundle = match default_embedded_config_bundle( - &profile, - &self.host_id, - runtime_id.as_ref(), - &self.resource_broker, - ) - .and_then(|bundle| { - self.runtime - .store_config_bundle(bundle) - .map_err(|err| err.to_string()) - }) { + let config_bundle = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) + .and_then(|bundle| { + self.runtime + .store_config_bundle(bundle) + .map_err(|err| err.to_string()) + }) { Ok(availability) => availability.reference, Err(error) => { diagnostics.push(diagnostic( @@ -2048,12 +2056,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); - let sync = match default_embedded_config_bundle( - &profile, - &self.host_id, - runtime_id.as_ref(), - &self.resource_broker, - ) { + let sync = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) { Ok(bundle) => self.sync_config_bundle(bundle), Err(error) => ConfigBundleSyncResult { state: WorkerOperationState::Rejected, @@ -3398,6 +3412,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, } } @@ -3530,6 +3545,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3632,6 +3648,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3663,6 +3680,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 5507755d..5c8b11b2 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod hosts; pub mod identity; pub mod observation; +pub mod profile_settings; pub mod records; pub mod repositories; pub mod resource_broker; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs new file mode 100644 index 00000000..f1113064 --- /dev/null +++ b/crates/workspace-server/src/profile_settings.rs @@ -0,0 +1,1478 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; +use worker_runtime::config_bundle::{ + ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, +}; +use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; + +use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; +use crate::{Error, Result}; + +const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; +const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; +const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; +const BUILTIN_PROFILE_IDS: &[&str] = &[ + "builtin:default", + "builtin:companion", + "builtin:intake", + "builtin:orchestrator", + "builtin:coder", + "builtin:reviewer", +]; +const BUILTIN_PROFILE_SLUGS: &[&str] = &[ + "default", + "companion", + "intake", + "orchestrator", + "coder", + "reviewer", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataSettingsResponse { + pub workspace_id: String, + pub display_name: String, + pub created_at: String, + pub revision: String, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceMetadataRequest { + pub display_name: String, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataMutationResponse { + pub workspace: WorkspaceMetadataSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsResponse { + pub workspace_id: String, + pub registry_revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_profile: Option, + pub profiles: Vec, + pub sources: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSummary { + pub profile_id: String, + pub selector: String, + pub label: String, + pub source_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub editable: bool, + pub is_default: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceSummary { + pub profile_source_id: String, + pub display_path: String, + pub kind: String, + pub editable: bool, + pub revision: String, + pub size_bytes: u64, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceDetailResponse { + pub workspace_id: String, + pub profile: WorkspaceProfileSummary, + pub source: WorkspaceProfileSourceSummary, + pub content: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceProfileSourceRequest { + pub name: String, + #[serde(default)] + pub description: Option, + pub content: String, + pub registry_revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileRegistryRequest { + pub registry_revision: String, + #[serde(default)] + pub default_profile: Option, + pub profiles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceProfileRegistryEntryUpdate { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub profile_source_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileSourceRequest { + pub content: String, + pub revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteWorkspaceProfileSourceRequest { + pub registry_revision: String, + pub source_revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsMutationResponse { + pub workspace_id: String, + pub settings: ProfileSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceIdentityFile { + workspace_id: String, + created_at: String, + display_name: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileRegistryDocument { + #[serde(default)] + default: Option, + #[serde(default)] + profile: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum ProfileEntryFile { + Path(String), + Table(ProfileEntryTable), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileEntryTable { + path: String, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone)] +struct ProjectProfileEntry { + name: String, + description: Option, + relative_path: PathBuf, +} + +pub fn workspace_metadata_settings( + workspace_root: &Path, + fallback_workspace_id: &str, + fallback_created_at: &str, + fallback_display_name: &str, +) -> 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, + } +} + +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 { + 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(), + }); + } + 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, + )) +} + +pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> ProfileSettingsResponse { + let mut diagnostics = Vec::new(); + let registry = match read_registry(workspace_root) { + Ok(registry) => registry, + Err(err) => { + diagnostics.push(diagnostic( + "profile_registry_schema_invalid", + DiagnosticSeverity::Error, + err, + )); + ProfileRegistryDocument::default() + } + }; + let registry_revision = file_revision(®istry_path(workspace_root)); + let mut profiles = builtin_profile_summaries(registry.default.as_deref()); + let mut sources = Vec::new(); + let mut seen_selectors = BTreeSet::new(); + for profile in &profiles { + seen_selectors.insert(profile.selector.clone()); + } + for entry in project_entries(®istry, &mut diagnostics) { + let selector = project_selector(&entry.name); + let source_id = project_source_id(&entry.name); + let mut entry_diagnostics = Vec::new(); + if !seen_selectors.insert(selector.clone()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Profile selector '{selector}' is duplicated."), + )); + } + let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path); + entry_diagnostics.extend(source_summary.diagnostics.clone()); + entry_diagnostics.extend(validate_project_profile_entry(workspace_root, &entry)); + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!( + "Project profile '{}' conflicts with a builtin selector.", + entry.name + ), + )); + } + profiles.push(WorkspaceProfileSummary { + profile_id: selector.clone(), + selector, + label: entry.name.clone(), + source_kind: "project".to_string(), + profile_source_id: Some(source_id.clone()), + description: entry.description.clone(), + editable: true, + is_default: registry.default.as_deref() == Some(project_selector(&entry.name).as_str()), + diagnostics: entry_diagnostics, + }); + sources.push(source_summary); + } + let mut default_diagnostics = validate_registry_default(®istry) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default(); + diagnostics.extend( + profiles + .iter() + .filter(|profile| profile.source_kind == "project") + .flat_map(|profile| profile.diagnostics.clone()), + ); + diagnostics.append(&mut default_diagnostics); + ProfileSettingsResponse { + workspace_id: workspace_id.to_string(), + registry_revision, + default_profile: registry.default, + profiles, + sources, + diagnostics, + } +} + +pub fn read_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, +) -> Result { + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let metadata = source_metadata(&full)?; + if metadata.len() > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source is too large for browser editing".to_string(), + }); + } + let content = fs::read_to_string(&full)?; + let source = summarize_source(workspace_root, source_id, &entry.relative_path); + let selector = project_selector(&name); + let profile = WorkspaceProfileSummary { + profile_id: selector.clone(), + selector: selector.clone(), + label: name, + source_kind: "project".to_string(), + profile_source_id: Some(source_id.to_string()), + description: entry.description, + editable: true, + is_default: registry.default.as_deref() == Some(selector.as_str()), + diagnostics: source.diagnostics.clone(), + }; + Ok(WorkspaceProfileSourceDetailResponse { + workspace_id: workspace_id.to_string(), + profile, + source, + content, + diagnostics: Vec::new(), + }) +} + +pub fn create_profile_source( + workspace_id: &str, + workspace_root: &Path, + request: CreateWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let name = validate_profile_name(&request.name)?; + if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector already exists".to_string(), + }); + } + let relative_path = PathBuf::from(".yoi") + .join("profiles") + .join(format!("{name}.dcdl")); + validate_source_content(workspace_root, &name, &relative_path, &request.content)?; + let full = checked_source_path(workspace_root, &relative_path)?; + if let Some(parent) = full.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&full, request.content)?; + registry.profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: request + .description + .and_then(|value| optional_trim(value.as_str())), + }), + ); + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_settings_updated", + DiagnosticSeverity::Info, + "Profile source was created and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_registry( + workspace_id: &str, + workspace_root: &Path, + request: UpdateWorkspaceProfileRegistryRequest, +) -> Result { + let path = registry_path(workspace_root); + ensure_revision( + &path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut profile = BTreeMap::new(); + let mut seen = BTreeSet::new(); + for update in request.profiles { + let name = validate_profile_name(&update.name)?; + if !seen.insert(name.clone()) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector duplicate in registry update".to_string(), + }); + } + let source_id = update + .profile_source_id + .as_deref() + .unwrap_or(project_source_id(&name).as_str()) + .to_string(); + let (source_name, _) = parse_project_source_id(&source_id)?; + if source_name != name { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_id_mismatch".to_string(), + message: "Profile source id must match its registry selector".to_string(), + }); + } + profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: update + .description + .and_then(|value| optional_trim(value.as_str())), + }), + ); + } + let registry = ProfileRegistryDocument { + default: request.default_profile, + profile, + }; + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_registry_updated", + DiagnosticSeverity::Info, + "Profile registry was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: UpdateWorkspaceProfileSourceRequest, +) -> Result { + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; + validate_source_content( + workspace_root, + &name, + &entry.relative_path, + &request.content, + )?; + fs::write(&full, request.content)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_updated", + DiagnosticSeverity::Info, + "Profile source was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn delete_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: DeleteWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision( + &full, + &request.source_revision, + "profile_source_revision_conflict", + )?; + registry.profile.remove(&name); + if registry.default.as_deref() == Some(project_selector(&name).as_str()) { + registry.default = None; + } + if full.exists() { + fs::remove_file(&full)?; + } + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_deleted", + DiagnosticSeverity::Info, + "Profile source and registry entry were deleted and profile discovery was refreshed.", + )], + }) +} + +pub fn build_workspace_profile_archive( + workspace_root: &Path, + selector: &str, +) -> Result> { + if !selector.starts_with("project:") { + return Ok(None); + } + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let mut entrypoints = BTreeMap::new(); + let mut sources = BTreeMap::new(); + for entry in project_entries(®istry, &mut Vec::new()) { + let path = archive_path_for_entry(&entry.name); + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let content = fs::read_to_string(&full)?; + sources.insert(path.clone(), content); + entrypoints.insert(project_selector(&entry.name), path); + } + if !entrypoints.contains_key(selector) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_selector".to_string(), + message: "Selected project profile is not present in the workspace profile registry" + .to_string(), + }); + } + if let Some(default) = registry + .default + .as_deref() + .filter(|value| value.starts_with("project:")) + { + if let Some(path) = entrypoints.get(default).cloned() { + entrypoints.insert("default".to_string(), path); + } + } + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "workspace-project-decodal-profiles-v1".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + archive + .verify() + .and_then(|verified| { + verified + .resolve_profile(selector, workspace_root, "workspace-settings-validation") + .map(|_| ()) + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_invalid".to_string(), + message: err.to_string(), + })?; + Ok(Some(archive)) +} + +pub fn build_workspace_profile_config_bundle( + workspace_root: &Path, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, +) -> Result> { + let Some(archive) = build_workspace_profile_archive(workspace_root, selector)? else { + return Ok(None); + }; + let bundle = ConfigBundle { + metadata: ConfigBundleMetadata { + id: "workspace-project-profile-settings-v1".to_string(), + digest: String::new(), + revision: file_revision(®istry_path(workspace_root)), + workspace_id: workspace_id.to_string(), + created_at: workspace_created_at.to_string(), + provenance: ConfigBundleProvenance { + source: "workspace_profile_settings".to_string(), + detail: Some("workspace Decodal profile registry".to_string()), + }, + }, + profiles: vec![ConfigProfileDescriptor { + selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()), + label: Some(selector.to_string()), + }], + declarations: Vec::new(), + profile_source_archive: Some(archive), + profile_source_archive_handle: None, + } + .with_computed_digest(); + Ok(Some(bundle)) +} + +pub fn project_profile_candidates(workspace_root: &Path) -> Vec { + load_profile_settings("workspace", workspace_root) + .profiles + .into_iter() + .filter(|profile| profile.source_kind == "project" && !has_error(&profile.diagnostics)) + .collect() +} + +pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool { + BUILTIN_PROFILE_IDS.contains(&profile_id) + || profile_id == "runtime_default" + || project_profile_candidates(workspace_root) + .into_iter() + .any(|profile| profile.profile_id == profile_id) +} + +fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { + let labels = [ + ("builtin:default", "Default", "Bundled default Yoi profile"), + ( + "builtin:companion", + "Companion", + "Bundled Companion role profile", + ), + ("builtin:intake", "Intake", "Bundled Intake role profile"), + ( + "builtin:orchestrator", + "Orchestrator", + "Bundled Orchestrator role profile", + ), + ("builtin:coder", "Coder", "Bundled Coder role profile"), + ( + "builtin:reviewer", + "Reviewer", + "Bundled Reviewer role profile", + ), + ]; + labels + .into_iter() + .map(|(id, label, description)| WorkspaceProfileSummary { + profile_id: id.to_string(), + selector: id.to_string(), + label: label.to_string(), + source_kind: "builtin".to_string(), + profile_source_id: None, + description: Some(description.to_string()), + editable: false, + is_default: default_profile == Some(id), + diagnostics: Vec::new(), + }) + .collect() +} + +fn validate_project_profile_entry( + workspace_root: &Path, + entry: &ProjectProfileEntry, +) -> Vec { + let full = match checked_source_path(workspace_root, &entry.relative_path) { + Ok(path) => path, + Err(err) => return vec![diagnostic_from_error(&err)], + }; + match fs::read_to_string(&full) { + Ok(content) => { + validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default() + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + format!("Profile source '{}' is missing.", entry.name), + )], + Err(err) => vec![diagnostic( + "profile_source_read_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )], + } +} + +fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic { + match err { + Error::RuntimeOperationFailed { code, message, .. } => diagnostic( + code.clone(), + DiagnosticSeverity::Error, + sanitize_error(message), + ), + other => diagnostic( + "profile_settings_failed", + DiagnosticSeverity::Error, + sanitize_error(&other.to_string()), + ), + } +} + +fn has_error(diagnostics: &[RuntimeDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) +} + +fn validate_all_project_profiles( + workspace_root: &Path, + registry: &ProfileRegistryDocument, +) -> Result<()> { + let mut diagnostics = Vec::new(); + let entries = project_entries(registry, &mut diagnostics); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, + )); + } + for entry in entries { + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + return Err(profile_validation_error( + "profile_selector_duplicate", + "Project profile conflicts with a builtin selector", + )); + } + if let Some(diagnostic) = validate_project_profile_entry(workspace_root, &entry) + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, + )); + } + } + Ok(()) +} + +fn profile_validation_error(code: impl Into, message: impl Into) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.into(), + message: message.into(), + } +} + +fn profile_registry_error(message: String) -> Error { + profile_validation_error("profile_registry_schema_invalid", sanitize_error(&message)) +} + +fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> { + let Some(value) = registry.default.as_deref() else { + return Ok(()); + }; + if BUILTIN_PROFILE_IDS.contains(&value) { + return Ok(()); + } + if let Some(name) = value.strip_prefix("project:") { + let name = validate_profile_name(name)?; + if registry.profile.contains_key(&name) { + return Ok(()); + } + return Err(profile_validation_error( + "profile_default_unknown", + "Default project profile selector is not present in the workspace profile registry", + )); + } + Err(profile_validation_error( + "profile_default_invalid", + "Default profile must be a Backend-published builtin or project selector", + )) +} + +fn validate_source_content( + workspace_root: &Path, + name: &str, + relative_path: &Path, + content: &str, +) -> Result<()> { + if content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source exceeds the browser editing size limit".to_string(), + }); + } + let archive_path = archive_path_for_entry(name); + let selector = project_selector(name); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert(selector.clone(), archive_path.clone()); + entrypoints.insert("default".to_string(), archive_path.clone()); + let mut sources = BTreeMap::new(); + sources.insert(archive_path, content.to_string()); + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: format!("workspace-profile-validation-{name}"), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + let verified = archive + .verify() + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + checked_source_path(workspace_root, relative_path)?; + verified + .resolve_profile(&selector, workspace_root, "workspace-settings-validation") + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_syntax_invalid".to_string(), + message: err.to_string(), + })?; + Ok(()) +} + +fn summarize_source( + workspace_root: &Path, + source_id: &str, + relative_path: &Path, +) -> WorkspaceProfileSourceSummary { + let mut diagnostics = Vec::new(); + let display_path = display_source_path(relative_path); + let mut revision = "missing".to_string(); + let mut size_bytes = 0; + match checked_source_path(workspace_root, relative_path) { + Ok(full) => match source_metadata(&full) { + Ok(metadata) => { + size_bytes = metadata.len(); + revision = file_revision(&full); + if size_bytes > MAX_PROFILE_SOURCE_BYTES { + diagnostics.push(diagnostic( + "profile_source_too_large", + DiagnosticSeverity::Error, + "Profile source is too large for browser editing.", + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + "Profile source file is missing.", + )), + Err(err) => diagnostics.push(diagnostic( + "profile_source_metadata_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + }, + Err(err) => diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + } + WorkspaceProfileSourceSummary { + profile_source_id: source_id.to_string(), + display_path, + kind: "decodal".to_string(), + editable: diagnostics + .iter() + .all(|d| d.severity != DiagnosticSeverity::Error), + revision, + size_bytes, + diagnostics, + } +} + +fn read_registry(workspace_root: &Path) -> std::result::Result { + let path = registry_path(workspace_root); + match fs::read_to_string(&path) { + Ok(raw) => { + toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(ProfileRegistryDocument::default()) + } + Err(err) => Err(format!( + "failed to read profile registry: {}", + sanitize_error(&err.to_string()) + )), + } +} + +fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Result<()> { + let path = registry_path(workspace_root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(registry) + .map_err(|err| Error::Config(format!("failed to serialize profile registry: {err}")))?; + fs::write(path, raw)?; + Ok(()) +} + +fn project_entries( + registry: &ProfileRegistryDocument, + diagnostics: &mut Vec, +) -> Vec { + registry + .profile + .iter() + .filter_map(|(name, entry)| match validate_profile_name(name) { + Ok(name) => { + if BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Project profile '{name}' conflicts with a builtin selector."), + )); + } + let (path, description) = match entry { + ProfileEntryFile::Path(path) => (path.clone(), None), + ProfileEntryFile::Table(table) => { + (table.path.clone(), table.description.clone()) + } + }; + match registry_relative_source_path(&path) { + Ok(relative_path) => Some(ProjectProfileEntry { + name, + description, + relative_path, + }), + Err(err) => { + diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + err, + )); + None + } + } + } + Err(err) => { + diagnostics.push(diagnostic( + "profile_selector_invalid", + DiagnosticSeverity::Error, + err.to_string(), + )); + None + } + }) + .collect() +} + +fn entry_for_source_id( + registry: &ProfileRegistryDocument, + source_id: &str, +) -> Result<(String, ProjectProfileEntry)> { + let (name, _) = parse_project_source_id(source_id)?; + let mut diagnostics = Vec::new(); + let entry = project_entries(registry, &mut diagnostics) + .into_iter() + .find(|entry| entry.name == name) + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_source".to_string(), + message: "Unknown profile source id".to_string(), + })?; + Ok((name, entry)) +} + +fn registry_relative_source_path(raw: &str) -> std::result::Result { + let path = Path::new(raw); + if path.is_absolute() { + return Err("Profile source path must be workspace-relative and safe.".to_string()); + } + let path = if path.starts_with(".yoi") { + path.to_path_buf() + } else { + PathBuf::from(".yoi").join(path) + }; + validate_relative_source_path(&path)?; + Ok(path) +} + +fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> { + if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) { + return Err( + "Profile source path must be under the workspace profile source root.".to_string(), + ); + } + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err( + "Profile source path must not contain absolute, parent, or prefix components." + .to_string(), + ); + } + } + if path.extension().and_then(|value| value.to_str()) != Some("dcdl") { + return Err("Profile source path must use the .dcdl extension.".to_string()); + } + Ok(()) +} + +fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result { + validate_relative_source_path(relative_path).map_err(|message| { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message, + } + })?; + let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + fs::create_dir_all(&source_root)?; + let canonical_root = fs::canonicalize(&source_root)?; + let full = workspace_root.join(relative_path); + if let Ok(canonical_full) = fs::canonicalize(&full) { + if !canonical_full.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_symlink_escape".to_string(), + message: "Profile source resolves outside the workspace profile source root" + .to_string(), + }); + } + } else if let Some(parent) = full.parent() { + let canonical_parent = fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message: "Profile source parent resolves outside the workspace profile source root" + .to_string(), + }); + } + } + Ok(full) +} + +fn validate_profile_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_invalid".to_string(), + message: "Profile selector must contain only ASCII letters, digits, '-' or '_'" + .to_string(), + }); + } + Ok(trimmed.to_string()) +} + +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()) +} + +fn parse_project_source_id(source_id: &str) -> Result<(String, String)> { + let Some(name) = source_id.strip_prefix("project:") else { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unsupported_profile_source_id".to_string(), + message: "Profile source id is not a project profile source".to_string(), + }); + }; + let name = validate_profile_name(name)?; + Ok((name.clone(), project_source_id(&name))) +} + +pub fn project_selector(name: &str) -> String { + format!("project:{name}") +} + +pub fn project_source_id(name: &str) -> String { + format!("project:{name}") +} + +pub fn selector_for_builtin_candidate( + id: &str, +) -> Option { + match id { + "runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault), + "builtin:default" + | "builtin:companion" + | "builtin:intake" + | "builtin:orchestrator" + | "builtin:coder" + | "builtin:reviewer" => Some(worker_runtime::catalog::ProfileSelector::Builtin( + id.to_string(), + )), + value if value.starts_with("project:") => Some( + worker_runtime::catalog::ProfileSelector::Named(value.to_string()), + ), + _ => None, + } +} + +fn archive_path_for_entry(name: &str) -> String { + format!("profiles/{name}.dcdl") +} + +fn display_source_path(relative_path: &Path) -> String { + relative_path + .strip_prefix(".yoi") + .unwrap_or(relative_path) + .to_string_lossy() + .replace('\\', "/") +} + +fn registry_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) +} + +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 source_metadata(path: &Path) -> std::io::Result { + fs::symlink_metadata(path) +} + +fn ensure_revision(path: &Path, expected: &str, code: &'static str) -> Result<()> { + let actual = file_revision(path); + if expected != actual { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.to_string(), + message: "Settings changed before this update was applied".to_string(), + }); + } + Ok(()) +} + +fn optional_trim(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + 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::*; + + fn valid_decodal(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + #[test] + fn profile_settings_create_update_and_discover_project_profile() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let created = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "alpha".to_string(), + description: Some("Alpha".to_string()), + content: valid_decodal("alpha"), + registry_revision: revision, + }, + ) + .unwrap(); + assert!( + created + .settings + .profiles + .iter() + .any(|profile| profile.profile_id == "project:alpha") + ); + assert!( + build_workspace_profile_archive(dir.path(), "project:alpha") + .unwrap() + .is_some() + ); + } + + #[test] + fn profile_source_rejects_path_escape_and_revision_conflict() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"../bad.dcdl\"\n", + ) + .unwrap(); + let settings = load_profile_settings("workspace-test", dir.path()); + assert!( + settings + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "profile_source_path_escape") + ); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: "stale".to_string(), + default_profile: None, + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("profile_registry_revision_conflict") + ); + } + + #[test] + fn profile_source_rejects_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "bad".to_string(), + description: None, + content: "not decodal".to_string(), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!( + err.to_string().contains("profile_source_syntax_invalid") + || err.to_string().contains("profile_source_archive_invalid") + ); + } + + #[test] + fn invalid_project_profile_is_not_a_launch_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + + let settings = load_profile_settings("workspace-test", dir.path()); + let bad = settings + .profiles + .iter() + .find(|profile| profile.profile_id == "project:bad") + .expect("bad profile summary is still visible for repair"); + assert!( + bad.diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("profile_source_")) + ); + assert!(project_profile_candidates(dir.path()).is_empty()); + assert!(!is_profile_candidate(dir.path(), "project:bad")); + } + + #[test] + fn registry_update_rejects_missing_source_and_unknown_default() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision.clone(), + default_profile: Some("project:missing".to_string()), + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_default_unknown")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "missing".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_missing")); + assert_eq!( + fs::read_to_string(dir.path().join(".yoi/profiles.toml")).unwrap(), + "" + ); + } + + #[test] + fn profile_source_rejects_too_large_content() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "large".to_string(), + description: None, + content: "x".repeat((MAX_PROFILE_SOURCE_BYTES + 1) as usize), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_too_large")); + } + + #[cfg(unix)] + #[test] + fn registry_update_rejects_symlink_escape() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_decodal("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "escape".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + let rendered = err.to_string(); + assert!(rendered.contains("profile_source_symlink_escape")); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a34f1c80..ffa67b8a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,7 +6,7 @@ use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::{CONTENT_TYPE, LOCATION}; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use chrono::{SecondsFormat, Utc}; use futures::StreamExt; @@ -36,6 +36,11 @@ use crate::observation::{ BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig, }; +use crate::profile_settings::{ + CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, + UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, + UpdateWorkspaceProfileSourceRequest, +}; use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; @@ -264,6 +269,24 @@ pub fn build_router(api: WorkspaceApi) -> Router { Router::new() .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) + .route( + "/api/w/{workspace_id}/settings/workspace", + get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), + ) + .route( + "/api/w/{workspace_id}/settings/profiles", + get(scoped_get_profile_settings).post(scoped_create_profile_source), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/registry", + put(scoped_update_profile_registry), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/{profile_source_id}", + get(scoped_get_profile_source) + .put(scoped_update_profile_source) + .delete(scoped_delete_profile_source), + ) .route("/api/tickets", get(list_tickets)) .route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets)) .route("/api/tickets/{id}", get(get_ticket)) @@ -814,6 +837,116 @@ async fn scoped_get_workspace( get_workspace(State(api)).await } +async fn scoped_get_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &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, + ))) +} + +async fn scoped_update_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = + crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; + Ok(Json( + crate::profile_settings::WorkspaceMetadataMutationResponse { + workspace, + diagnostics: vec![RuntimeDiagnostic { + code: "workspace_metadata_updated".to_string(), + severity: DiagnosticSeverity::Info, + message: "Workspace display metadata was updated.".to_string(), + }], + }, + )) +} + +async fn scoped_get_profile_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::load_profile_settings( + &api.config.workspace_id, + &api.config.workspace_root, + ))) +} + +async fn scoped_create_profile_source( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::create_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_update_profile_registry( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_registry( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_get_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::read_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + )?)) +} + +async fn scoped_update_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + +async fn scoped_delete_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::delete_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, @@ -1639,12 +1772,24 @@ async fn create_workspace_worker( State(api): State, Json(request): Json, ) -> ApiResult> { - let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { - settings_bad_request( - "unsupported_worker_profile", - "profile must be selected from Backend-published worker profile candidates", - ) - })?; + let profile_selector = + profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile) + .ok_or_else(|| { + settings_bad_request( + "unsupported_worker_profile", + "profile must be selected from Backend-published worker profile candidates", + ) + })?; + let resolved_config_bundle = if request.profile.starts_with("project:") { + crate::profile_settings::build_workspace_profile_config_bundle( + &api.config.workspace_root, + &api.config.workspace_id, + &api.config.workspace_created_at, + &request.profile, + )? + } else { + None + }; let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -1702,6 +1847,7 @@ async fn create_workspace_worker( working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory, + resolved_config_bundle, }, ) .map_err(|err| err.into_error())?; @@ -2753,7 +2899,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, - profiles: worker_profile_candidates(), + profiles: worker_profile_candidates_for_root(&api.config.workspace_root), repositories: working_directory_repository_options(api), working_directories: working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), @@ -2823,8 +2969,16 @@ fn working_directory_request_for_browser( }) } +#[cfg(test)] fn worker_profile_candidates() -> Vec { - vec![ + worker_profile_candidates_for_root(Path::new(".")) + .into_iter() + .filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default") + .collect() +} + +fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec { + let mut candidates = vec![ WorkerLaunchProfileCandidate { id: "builtin:coder".to_string(), label: "Coding Worker".to_string(), @@ -2835,14 +2989,37 @@ fn worker_profile_candidates() -> Vec { label: "Runtime default".to_string(), description: "Use the selected Runtime's default profile.".to_string(), }, - ] + ]; + candidates.extend( + crate::profile_settings::project_profile_candidates(workspace_root) + .into_iter() + .filter(|profile| profile.diagnostics.is_empty()) + .map(|profile| WorkerLaunchProfileCandidate { + id: profile.profile_id, + label: profile.label, + description: profile + .description + .unwrap_or_else(|| "Workspace Decodal profile source.".to_string()), + }), + ); + candidates } fn profile_selector_for_candidate(profile: &str) -> Option { - match profile { - "builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())), - "runtime_default" => Some(ProfileSelector::RuntimeDefault), - _ => None, + crate::profile_settings::selector_for_builtin_candidate(profile) + .filter(|_| matches!(profile, "builtin:coder" | "runtime_default")) +} + +fn profile_selector_for_candidate_with_root( + workspace_root: &Path, + profile: &str, +) -> Option { + if profile_selector_for_candidate(profile).is_some() { + profile_selector_for_candidate(profile) + } else if crate::profile_settings::is_profile_candidate(workspace_root, profile) { + crate::profile_settings::selector_for_builtin_candidate(profile) + } else { + None } } @@ -3184,10 +3361,15 @@ struct ApiError { impl From for ApiError { fn from(error: Error) -> Self { - Self { - error, - diagnostics: Vec::new(), - } + let diagnostics = match &error { + Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { + code: code.clone(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }], + _ => Vec::new(), + }; + Self { error, diagnostics } } } @@ -3227,6 +3409,23 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { StatusCode::NOT_IMPLEMENTED } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" || code == "unknown_profile_selector" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { StatusCode::CONFLICT } @@ -3244,12 +3443,18 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; + let response_message = match &self.error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&self.error.to_string()), + }; ( status, [(CONTENT_TYPE, "application/json")], Json(serde_json::json!({ "error": status.canonical_reason().unwrap_or("error"), - "message": self.error.to_string(), + "message": response_message, "diagnostics": self.diagnostics, })) .to_string(), @@ -3265,7 +3470,7 @@ mod tests { use axum::http::Request; use futures::{SinkExt, StreamExt}; use serde_json::{Value, json}; - use std::sync::Arc; + use std::{fs, sync::Arc}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; @@ -3322,6 +3527,210 @@ mod tests { assert!(!sanitized.contains("/home/example")); } + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "coder", + "content": valid_profile_source("coder"), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_selector_duplicate"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "bad", + "content": "not decodal", + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + diagnostic_codes(&response.1) + .iter() + .any(|code| code.starts_with("profile_source_")) + ); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "schema", + "content": valid_profile_source("schema"), + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")) + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_registry_schema_invalid"); + } + + #[tokio::test] + async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "alpha", + "content": valid_profile_source("alpha"), + "registry_revision": "stale" + }), + ) + .await; + assert_eq!(response.0, StatusCode::CONFLICT); + assert_diagnostic(&response.1, "profile_registry_revision_conflict"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "large", + "content": "x".repeat((256 * 1024) + 1), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_too_large"); + } + + #[cfg(unix)] + #[tokio::test] + async fn profile_settings_api_redacts_symlink_escape_response() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_profile_source("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let response = profile_settings_request( + dir.path(), + "PUT", + "/settings/profiles/registry", + json!({ + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")), + "default_profile": null, + "profiles": [{ "name": "escape", "profile_source_id": "project:escape" }] + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_symlink_escape"); + let rendered = response.1.to_string(); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn worker_launch_rejects_invalid_project_profile_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + assert!( + worker_profile_candidates_for_root(dir.path()) + .iter() + .all(|candidate| candidate.id != "project:bad") + ); + assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none()); + } + + fn valid_profile_source(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + fn test_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(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("rev:{modified}:{}", metadata.len()) + } + + async fn profile_settings_request( + workspace_root: &Path, + method: &str, + path: &str, + body: Value, + ) -> (StatusCode, Value) { + let app = test_app(workspace_root.to_path_buf()).await; + let request = Request::builder() + .method(method) + .uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let json = serde_json::from_slice::(&bytes).unwrap(); + (status, json) + } + + fn diagnostic_codes(response: &Value) -> Vec { + response["diagnostics"] + .as_array() + .expect("diagnostics array") + .iter() + .map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string()) + .collect() + } + + fn assert_diagnostic(response: &Value, code: &str) { + let codes = diagnostic_codes(response); + assert!( + !codes.is_empty(), + "diagnostics must not be empty: {response}" + ); + assert!( + codes.iter().any(|actual| actual == code), + "missing {code}: {codes:?}" + ); + } + #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< @@ -4599,6 +5008,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .expect("spawn worker"); diff --git a/web/workspace/src/lib/workspace-api/http.ts b/web/workspace/src/lib/workspace-api/http.ts index 92365eae..b519c8c2 100644 --- a/web/workspace/src/lib/workspace-api/http.ts +++ b/web/workspace/src/lib/workspace-api/http.ts @@ -38,6 +38,31 @@ export async function loadJson( } } +async function requireJson(response: Response, path: string): Promise { + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `${path} request failed (${response.status})`); + } + return (await response.json()) as T; +} + +export async function workspaceApiJson(path: string): Promise { + return requireJson(await fetch(path), path); +} + +export async function workspaceApiJsonWithBody(path: string, init: RequestInit): Promise { + return requireJson( + await fetch(path, { + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + ...init, + }), + path, + ); +} + export function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/web/workspace/src/lib/workspace-settings/model.ts b/web/workspace/src/lib/workspace-settings/model.ts index c692244e..8c0b4689 100644 --- a/web/workspace/src/lib/workspace-settings/model.ts +++ b/web/workspace/src/lib/workspace-settings/model.ts @@ -6,6 +6,7 @@ export type Diagnostic = { export type SettingsSectionId = | "runtime-connections" + | "profile-sources" | "backend-config" | "workspace-identity"; @@ -84,6 +85,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", ], }, + { + id: "profile-sources", + label: "Profile Sources", + status: "editable", + summary: + "Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.", + bullets: [ + "Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.", + "Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.", + "Launch profile candidates refresh from the same Backend projection after registry or source updates.", + ], + }, { id: "backend-config", label: "Backend Config", diff --git a/web/workspace/src/lib/workspace-settings/profile-api.ts b/web/workspace/src/lib/workspace-settings/profile-api.ts new file mode 100644 index 00000000..79141333 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-api.ts @@ -0,0 +1,81 @@ +import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http"; +import type { + ProfileSettingsMutationResponse, + ProfileSettingsResponse, + WorkspaceMetadataMutationResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, +} from "./profile-types"; + +export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`); +} + +export function updateWorkspaceMetadataSettings( + workspaceId: string, + request: { display_name: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`); +} + +export function createProfileSource( + workspaceId: string, + request: { name: string; description?: string; content: string; registry_revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updateProfileRegistry( + workspaceId: string, + request: { + registry_revision: string; + default_profile?: string | null; + profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>; + }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSource( + workspaceId: string, + sourceId: string, +): Promise { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + ); +} + +export function updateProfileSource( + workspaceId: string, + sourceId: string, + request: { content: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "PUT", body: JSON.stringify(request) }, + ); +} + +export function deleteProfileSource( + workspaceId: string, + sourceId: string, + request: { registry_revision: string; source_revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "DELETE", body: JSON.stringify(request) }, + ); +} diff --git a/web/workspace/src/lib/workspace-settings/profile-types.ts b/web/workspace/src/lib/workspace-settings/profile-types.ts new file mode 100644 index 00000000..d421f2f6 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-types.ts @@ -0,0 +1,60 @@ +import type { Diagnostic } from "./model"; + +export type WorkspaceMetadataSettingsResponse = { + workspace_id: string; + display_name: string; + created_at: string; + revision: string; + source: string; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceMetadataMutationResponse = { + workspace: WorkspaceMetadataSettingsResponse; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSummary = { + profile_id: string; + selector: string; + label: string; + source_kind: "builtin" | "project" | string; + profile_source_id?: string | null; + description?: string | null; + editable: boolean; + is_default: boolean; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceSummary = { + profile_source_id: string; + display_path: string; + kind: "decodal" | string; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsResponse = { + workspace_id: string; + registry_revision: string; + default_profile?: string | null; + profiles: WorkspaceProfileSummary[]; + sources: WorkspaceProfileSourceSummary[]; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceDetailResponse = { + workspace_id: string; + profile: WorkspaceProfileSummary; + source: WorkspaceProfileSourceSummary; + content: string; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsMutationResponse = { + workspace_id: string; + settings: ProfileSettingsResponse; + diagnostics: Diagnostic[]; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index 1737a2bc..4c7faefb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -13,6 +13,21 @@ type RuntimeConnectionMutationResponse, type RuntimeConnectionSettingsResponse, } from "$lib/workspace-settings/model"; + import type { + ProfileSettingsResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, + } from "$lib/workspace-settings/profile-types"; + import { + createProfileSource, + deleteProfileSource, + fetchProfileSettings, + fetchProfileSource, + fetchWorkspaceMetadataSettings, + updateProfileRegistry, + updateProfileSource, + updateWorkspaceMetadataSettings, + } from "$lib/workspace-settings/profile-api"; type RemoteAddForm = { runtime_id: string; @@ -44,6 +59,19 @@ display_name: "", endpoint: "", }); + let workspaceMetadata = $state(null); + let profileSettings = $state(null); + let selectedProfileSource = $state(null); + let profileSourceContent = $state(""); + let newProfileName = $state(""); + let newProfileDescription = $state(""); + let newProfileContent = $state("{\n slug = \"workspace-profile\";\n description = \"Workspace profile\";\n scope = \"workspace_read\";\n}\n"); + let profileLoading = $state(true); + let profileMessage = $state(null); + let profileDiagnostics = $state([]); + let profileSubmitting = $state(false); + let workspaceNameDraft = $state(""); + let defaultProfileDraft = $state(""); $effect(() => { if (!workspaceId) { @@ -83,6 +111,179 @@ }; }); + $effect(() => { + if (!workspaceId) { + profileLoading = false; + return; + } + let cancelled = false; + async function loadProfileAndWorkspaceSettings() { + profileLoading = true; + profileMessage = null; + profileDiagnostics = []; + try { + const [workspace, profiles] = await Promise.all([ + fetchWorkspaceMetadataSettings(workspaceId), + fetchProfileSettings(workspaceId), + ]); + if (!cancelled) { + workspaceMetadata = workspace; + workspaceNameDraft = workspace.display_name; + profileSettings = profiles; + defaultProfileDraft = profiles.default_profile ?? ""; + profileDiagnostics = [...workspace.diagnostics, ...profiles.diagnostics]; + } + } catch (err) { + if (!cancelled) { + profileMessage = err instanceof Error ? err.message : "profile settings request failed"; + } + } finally { + if (!cancelled) { + profileLoading = false; + } + } + } + loadProfileAndWorkspaceSettings(); + return () => { + cancelled = true; + }; + }); + + async function refreshProfileSettings() { + profileSettings = await fetchProfileSettings(workspaceId); + profileDiagnostics = profileSettings.diagnostics; + } + + async function submitWorkspaceName() { + if (!workspaceMetadata) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateWorkspaceMetadataSettings(workspaceId, { + display_name: workspaceNameDraft, + revision: workspaceMetadata.revision, + }); + workspaceMetadata = response.workspace; + workspaceNameDraft = response.workspace.display_name; + profileDiagnostics = response.diagnostics.concat(response.workspace.diagnostics); + profileMessage = "Workspace display name updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "workspace update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileRegistry() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileRegistry(workspaceId, { + registry_revision: profileSettings.registry_revision, + default_profile: defaultProfileDraft || null, + profiles: profileSettings.profiles + .filter((profile) => profile.source_kind === "project") + .map((profile) => ({ + name: profile.selector.replace(/^project:/, ""), + description: profile.description ?? null, + profile_source_id: profile.profile_source_id ?? null, + })), + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile registry updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile registry update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitNewProfileSource() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await createProfileSource(workspaceId, { + name: newProfileName, + description: newProfileDescription || undefined, + content: newProfileContent, + registry_revision: profileSettings.registry_revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + newProfileName = ""; + newProfileDescription = ""; + profileMessage = "Profile source created."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source create failed"; + } finally { + profileSubmitting = false; + } + } + + async function selectProfileSource(sourceId: string) { + profileSubmitting = true; + profileMessage = null; + try { + selectedProfileSource = await fetchProfileSource(workspaceId, sourceId); + profileSourceContent = selectedProfileSource.content; + profileDiagnostics = selectedProfileSource.diagnostics.concat( + selectedProfileSource.source.diagnostics, + selectedProfileSource.profile.diagnostics, + ); + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source load failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceUpdate() { + if (!selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + content: profileSourceContent, + revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + await selectProfileSource(selectedProfileSource.source.profile_source_id); + profileMessage = "Profile source updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceDelete() { + if (!profileSettings || !selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await deleteProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + registry_revision: profileSettings.registry_revision, + source_revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + selectedProfileSource = null; + profileSourceContent = ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile source deleted."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source delete failed"; + } finally { + profileSubmitting = false; + } + } + async function submitRemoteRuntime() { submitting = true; mutationMessage = null; @@ -410,8 +611,109 @@ {/if} +
+
+
+

editable

+

Profile Sources

+
+ Backend scoped +
+

+ Workspace profile settings are surfaced as source-qualified selectors and Decodal source summaries. The browser never receives raw host paths, runtime endpoints, tokens, resource handles, archive content, or archive digests. +

+ + {#if profileLoading} +

Loading profile settings…

+ {:else} +
+
{ event.preventDefault(); submitWorkspaceName(); }}> +

Workspace display name

+ +

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

+ +
+ +
{ event.preventDefault(); submitProfileRegistry(); }}> +

Default launch profile

+ + +
+
+ +
+

Discovered profiles

+ + + + + + {#each profileSettings?.profiles ?? [] as profile} + + + + + + + + {/each} + +
SelectorLabelSourceStatusAction
{profile.selector}{profile.label}{profile.source_kind}{profile.diagnostics.length === 0 ? "ok" : `${profile.diagnostics.length} diagnostics`} + {#if profile.profile_source_id} + + {:else} + builtin + {/if} +
+
+ +
+
{ event.preventDefault(); submitNewProfileSource(); }}> +

Create project profile source

+ + + + +
+ + {#if selectedProfileSource} +
{ event.preventDefault(); submitProfileSourceUpdate(); }}> +

Edit {selectedProfileSource.profile.label}

+

Source id: {selectedProfileSource.source.profile_source_id}; display path: {selectedProfileSource.source.display_path}

+ +
+ + +
+
+ {:else} +
+

No profile source selected

+

Open a project source from the discovered profile table to edit it.

+
+ {/if} +
+ + {#if profileMessage} +

{profileMessage}

+ {/if} + {@render DiagnosticsList({ diagnostics: profileDiagnostics })} + {/if} +
+
- {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section} + {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections" && section.id !== "profile-sources") as section}