From 3eefd33334572d9caeaba333730e012cf4403143 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 21:38:23 +0900 Subject: [PATCH] feat: add workspace profile settings --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 23 + crates/workspace-server/src/companion.rs | 1 + crates/workspace-server/src/hosts.rs | 42 +- crates/workspace-server/src/lib.rs | 1 + .../workspace-server/src/profile_settings.rs | 1191 +++++++++++++++++ crates/workspace-server/src/server.rs | 189 ++- web/workspace/src/lib/workspace-api/http.ts | 25 + .../src/lib/workspace-settings/model.ts | 13 + .../src/lib/workspace-settings/profile-api.ts | 81 ++ .../lib/workspace-settings/profile-types.ts | 60 + .../w/[workspaceId]/settings/+page.svelte | 304 ++++- 12 files changed, 1909 insertions(+), 23 deletions(-) create mode 100644 crates/workspace-server/src/profile_settings.rs create mode 100644 web/workspace/src/lib/workspace-settings/profile-api.ts create mode 100644 web/workspace/src/lib/workspace-settings/profile-types.ts diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 4a55a181..aa55a61a 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T11:56:36Z' +updated_at: '2026-07-08T12:37:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 19fdecab..63b749d0 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -152,4 +152,27 @@ Acceptance basis: - Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 +--- + + + +## Implementation report + +Implemented workspace-scoped Workspace/Profile settings in the delegated worktree. + +Summary: +- Added scoped Workspace settings API to read/update display metadata through the workspace identity record with revision checks. +- Added scoped Profile settings API for workspace Decodal Profile registry/source discovery, create/update/delete, default selector updates, typed diagnostics, revision conflict checks, safe source-root/path validation, and Decodal/ProfileSourceArchive validation. +- Added Backend launch-options integration so project profile candidates refresh from the same settings projection; project-profile spawns carry the resolved ConfigBundle through the Runtime resource-fetch/config boundary. +- Added Settings page UI for workspace display name, profile registry/default selector, profile source creation/edit/delete, and safe diagnostics without exposing raw host paths, secrets, runtime endpoints/tokens, socket/session/store paths, archive content/digest, resource handles, or runtime internals. + +Validation: +- git diff --check +- cargo test -p yoi-workspace-server +- cargo check -p yoi +- cd web/workspace && deno task check && deno task test +- yoi ticket doctor +- nix build .#yoi --no-link + + --- 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..d49d9002 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,12 +1350,18 @@ 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, - ) + 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) @@ -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..e77a814e 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -8,6 +8,7 @@ pub mod companion; pub mod config; pub mod hosts; pub mod identity; +pub mod profile_settings; pub mod observation; pub mod records; pub mod repositories; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs new file mode 100644 index 00000000..209f6ecf --- /dev/null +++ b/crates/workspace-server/src/profile_settings.rs @@ -0,0 +1,1191 @@ +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()); + 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); + } + diagnostics.extend(validate_project_profiles(workspace_root, ®istry)); + 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(Error::Config)?; + 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(Error::Config)?; + 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())), + }), + ); + 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")?; + validate_default_profile(request.default_profile.as_deref())?; + 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, + }; + for entry in project_entries(®istry, &mut Vec::new()) { + let full = checked_source_path(workspace_root, &entry.relative_path)?; + if full.exists() { + let content = fs::read_to_string(&full)?; + validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)?; + } + } + 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(Error::Config)?; + 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(Error::Config)?; + 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(Error::Config)?; + 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") + .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_profiles(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Vec { + let mut diagnostics = Vec::new(); + for entry in project_entries(registry, &mut diagnostics) { + let full = match checked_source_path(workspace_root, &entry.relative_path) { + Ok(path) => path, + Err(err) => { + diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + err.to_string(), + )); + continue; + } + }; + match fs::read_to_string(&full) { + Ok(content) => { + if let Err(err) = validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) { + diagnostics.push(diagnostic( + "profile_source_invalid", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + format!("Profile source '{}' is missing.", entry.name), + )), + Err(err) => diagnostics.push(diagnostic( + "profile_source_read_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + } + } + if let Some(default) = registry.default.as_deref() { + if let Err(err) = validate_default_profile(Some(default)) { + diagnostics.push(diagnostic( + "profile_default_invalid", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )); + } + } + diagnostics +} + +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_default_profile(value: Option<&str>) -> Result<()> { + if let Some(value) = value { + if BUILTIN_PROFILE_IDS.contains(&value) || value.starts_with("project:") { + return Ok(()); + } + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_default_invalid".to_string(), + message: "Default profile must be a Backend-published builtin or project selector".to_string(), + }); + } + Ok(()) +} + +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")); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a34f1c80..773886d7 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,113 @@ 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 +1769,22 @@ async fn create_workspace_worker( State(api): State, Json(request): Json, ) -> ApiResult> { - let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { + 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 +1842,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 +2894,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 +2964,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 +2984,35 @@ 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 } } @@ -4599,6 +4769,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}