diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 5d33a09c..30304125 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -19,8 +19,8 @@ pub use paths::user_profiles_path; pub use profile::{ ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot, - resolve_profile_artifact, resolve_profile_artifact_value, + ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, + resolve_profile_artifact_value, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index f330a3bd..4d4d0f16 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -22,7 +22,6 @@ use crate::{ const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); -const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; struct BuiltinProfile { name: &'static str, @@ -322,10 +321,10 @@ pub struct ProfileDiscovery { } impl ProfileDiscovery { - pub fn for_cwd(cwd: &Path) -> Self { + pub fn for_cwd(_cwd: &Path) -> Self { Self { user_config: paths::user_profiles_path(), - project_config: find_project_profiles_from(cwd), + project_config: None, } } pub fn with_sources(user_config: Option, project_config: Option) -> Self { @@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot { pub source: ProfileSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_override: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceOverrideSnapshot { - pub path: PathBuf, -} - -#[derive(Debug)] -struct WorkspaceOverrideLayer { - path: PathBuf, - config: WorkerManifestConfig, } #[derive(Debug, Clone)] @@ -495,7 +481,6 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let workspace_override = load_workspace_override_from(&workspace_base)?; let raw_artifact = read_profile_artifact_file(&absolute_path)?; resolve_profile_value( source, @@ -504,7 +489,6 @@ impl ProfileResolver { options, raw_artifact.clone(), raw_artifact, - workspace_override, ) } @@ -519,7 +503,6 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let workspace_override = load_workspace_override_from(&workspace_base)?; let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| { ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`")) })?; @@ -530,7 +513,6 @@ impl ProfileResolver { options, raw_artifact.clone(), raw_artifact, - workspace_override, ) } } @@ -542,7 +524,6 @@ fn resolve_profile_value( options: ProfileResolveOptions, value: serde_json::Value, raw_artifact: serde_json::Value, - workspace_override: Option, ) -> Result { if !workspace_base.is_absolute() { return Err(ProfileError::InvalidPath { @@ -585,29 +566,11 @@ fn resolve_profile_value( memory: profile.memory, skills: profile.skills, }; - let mut config = - WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); - let workspace_override_snapshot = if let Some(override_layer) = workspace_override { - let override_base = - override_layer - .path - .parent() - .ok_or_else(|| ProfileError::InvalidPath { - path: override_layer.path.clone(), - message: "workspace override path has no parent directory".into(), - })?; - config = config.merge(override_layer.config.resolve_paths(override_base)); - Some(WorkspaceOverrideSnapshot { - path: override_layer.path, - }) - } else { - None - }; + let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), profile: profile_meta.clone(), - workspace_override: workspace_override_snapshot, }); let manifest_snapshot = serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; @@ -751,70 +714,6 @@ fn load_profile_registry_file( Ok(()) } -fn load_workspace_override_from( - workspace_base: &Path, -) -> Result, ProfileError> { - find_workspace_override_from(workspace_base) - .map(|path| load_workspace_override_file(&path)) - .transpose() -} - -fn load_workspace_override_file(path: &Path) -> Result { - let content = - std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead { - path: path.to_path_buf(), - source, - })?; - let config = WorkerManifestConfig::from_toml(&content).map_err(|source| { - ProfileError::WorkspaceOverrideParse { - path: path.to_path_buf(), - source, - } - })?; - if config.worker.name.is_some() { - return Err(ProfileError::InvalidWorkspaceOverride { - path: path.to_path_buf(), - message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(), - }); - } - Ok(WorkspaceOverrideLayer { - path: path.to_path_buf(), - config, - }) -} - -fn find_workspace_override_from(start: &Path) -> Option { - let start = start - .canonicalize() - .ok() - .unwrap_or_else(|| start.to_path_buf()); - let mut cur: Option<&Path> = Some(start.as_path()); - while let Some(dir) = cur { - let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - -fn find_project_profiles_from(start: &Path) -> Option { - let start = start - .canonicalize() - .ok() - .unwrap_or_else(|| start.to_path_buf()); - let mut cur: Option<&Path> = Some(start.as_path()); - while let Some(dir) = cur { - let candidate = dir.join(".yoi").join("profiles.toml"); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - fn add_builtin_profiles(registry: &mut ProfileRegistry) { for profile in BUILTIN_PROFILES { registry.push_entry(ProfileRegistryEntry::embedded( @@ -1285,7 +1184,6 @@ pub fn resolve_profile_artifact_value( ProfileResolveOptions::with_worker_name(worker_name), raw_artifact.clone(), raw_artifact, - None, ) } @@ -1313,20 +1211,6 @@ pub enum ProfileError { #[source] source: toml::de::Error, }, - #[error("failed to read workspace local manifest override {}: {source}", .path.display())] - WorkspaceOverrideRead { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse workspace local manifest override {}: {source}", .path.display())] - WorkspaceOverrideParse { - path: PathBuf, - #[source] - source: toml::de::Error, - }, - #[error("invalid workspace local manifest override {}: {message}", .path.display())] - InvalidWorkspaceOverride { path: PathBuf, message: String }, #[error("no default profile is configured")] NoDefaultProfile, #[error("profile resolution requires an explicit runtime Worker name")] @@ -1594,7 +1478,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let err = ProfileResolver::new() .with_workspace_base(tmp.path()) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"), + ProfileResolveOptions::default(), + ) .unwrap_err(); assert!(matches!(err, ProfileError::MissingRuntimeWorkerName)); } @@ -1857,134 +1744,6 @@ worker_context_max_tokens = 68000 } ); } - #[test] - fn workspace_local_override_layers_over_profile_defaults() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("project"); - let nested = workspace.join("nested"); - let yoi_dir = workspace.join(".yoi"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::create_dir_all(&yoi_dir).unwrap(); - let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - std::fs::write( - &override_path, - r#" -[worker] -prompt_pack = "prompts.toml" -[engine] -language = "ja" -[session] -record_event_trace = false -"#, - ) - .unwrap(); - - let resolved = ProfileResolver::new() - .with_workspace_base(&nested) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap(); - - assert_eq!(resolved.manifest.worker.name, "runtime-worker"); - assert_eq!(resolved.manifest.engine.language, "ja"); - assert!(!resolved.manifest.session.record_event_trace); - assert_eq!( - resolved.manifest.worker.prompt_pack.as_deref(), - Some(yoi_dir.join("prompts.toml").as_path()) - ); - assert!(resolved.manifest.scope.allow.is_empty()); - assert_eq!( - resolved - .manifest - .profile - .as_ref() - .and_then(|snapshot| snapshot.workspace_override.as_ref()) - .map(|snapshot| snapshot.path.as_path()), - Some(override_path.as_path()) - ); - } - - #[test] - fn workspace_local_override_uses_nearest_ancestor() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("project"); - let nested = workspace.join("nested"); - let child = nested.join("child"); - let parent_yoi = workspace.join(".yoi"); - let nested_yoi = nested.join(".yoi"); - std::fs::create_dir_all(&child).unwrap(); - std::fs::create_dir_all(&parent_yoi).unwrap(); - std::fs::create_dir_all(&nested_yoi).unwrap(); - std::fs::write( - parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), - r#" -[worker] -prompt_pack = "parent-prompts.toml" -[engine] -language = "parent" -"#, - ) - .unwrap(); - let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - std::fs::write( - &nested_override_path, - r#" -[worker] -prompt_pack = "nested-prompts.toml" -[engine] -language = "nested" -"#, - ) - .unwrap(); - - let resolved = ProfileResolver::new() - .with_workspace_base(&child) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap(); - - assert_eq!(resolved.manifest.engine.language, "nested"); - assert_eq!( - resolved.manifest.worker.prompt_pack.as_deref(), - Some(nested_yoi.join("nested-prompts.toml").as_path()) - ); - assert_eq!( - resolved - .manifest - .profile - .as_ref() - .and_then(|snapshot| snapshot.workspace_override.as_ref()) - .map(|snapshot| snapshot.path.as_path()), - Some(nested_override_path.as_path()) - ); - } - - #[test] - fn workspace_local_override_rejects_runtime_worker_name() { - let tmp = TempDir::new().unwrap(); - let yoi_dir = tmp.path().join(".yoi"); - std::fs::create_dir_all(&yoi_dir).unwrap(); - std::fs::write( - yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), - "[worker]\nname = \"not-local\"\n", - ) - .unwrap(); - - let err = ProfileResolver::new() - .with_workspace_base(tmp.path()) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap_err(); - assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. })); - assert!(err.to_string().contains("worker.name")); - } - #[test] fn unsupported_profile_extension_has_clear_diagnostic() { let tmp = TempDir::new().unwrap(); @@ -2003,7 +1762,7 @@ language = "nested" ); } #[test] - fn discovery_reads_user_and_project_registry_and_project_default_wins() { + fn explicit_discovery_sources_preserve_non_workspace_tooling_contract() { let tmp = TempDir::new().unwrap(); let user_config = tmp.path().join("profiles.toml"); let project_dir = tmp.path().join("project/.yoi"); diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 91e34a1b..8f925c26 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -1,7 +1,7 @@ //! Inline-viewport "spawn Worker and attach" UX. //! //! Rendered at the user's current cursor position when `yoi` is invoked -//! with no positional argument. Discovers `.yoi/profiles.toml` profile +//! with no positional argument. Uses user-configured and bundled Profile //! choices plus bundled profiles, defaults to the builtin profile, prompts for //! the Worker's name, and on confirmation launches the Worker runtime command as an //! independent process. Once the process reports its socket via the @@ -654,68 +654,28 @@ mod tests { } #[test] - fn profile_choices_use_project_registry_default() { + fn profile_choices_ignore_repository_local_profile_registry() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let yoi = project.join(".yoi"); std::fs::create_dir_all(&yoi).unwrap(); std::fs::write( yoi.join("profiles.toml"), - r#" -default = "coder" -[profile] -coder = "profiles/coder.toml" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n", ) .unwrap(); let (choices, default_index) = profile_choices_for_cwd(&project); - let default_choice = choices - .iter() - .position(|choice| choice.selector.as_deref() == Some("project:coder")) - .expect("project default choice is present"); - assert_eq!(default_index, default_choice); - let selected = &choices[default_index]; - assert_eq!(selected.selector.as_deref(), Some("project:coder")); - assert_eq!(selected.label, "project:coder (default)"); - assert!(selected.is_default); - } - - #[test] - fn profile_choices_include_builtin_and_project_default_marker() { - let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("project"); - let yoi = project.join(".yoi"); - std::fs::create_dir_all(&yoi).unwrap(); - std::fs::write( - yoi.join("profiles.toml"), - r#" -default = "coder" -[profile.coder] -path = "profiles/coder.toml" -description = "Project coder" -"#, - ) - .unwrap(); - - let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(choices[0].selector.as_deref(), Some("builtin:companion")); - assert_eq!( - choices[0].label, - "builtin:companion — Bundled Companion role profile" + assert_eq!(default_index, 0); + assert!( + choices + .iter() + .all(|choice| { choice.selector.as_deref() != Some("project:coder") }) ); - let project_index = choices - .iter() - .position(|choice| choice.selector.as_deref() == Some("project:coder")) - .expect("project default choice is present"); - assert_eq!(default_index, project_index); - assert_eq!( - choices[project_index].selector.as_deref(), - Some("project:coder") - ); - assert_eq!( - choices[project_index].label, - "project:coder (default) — Project coder" + assert!( + choices + .iter() + .any(|choice| { choice.selector.as_deref() == Some("builtin:companion") }) ); } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 60bc4657..4a5511e3 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -764,7 +764,7 @@ permission = "write" let yoi_dir = tmp.path().join(".yoi"); std::fs::create_dir_all(&yoi_dir).unwrap(); write( - &yoi_dir.join("override.local.toml"), + &yoi_dir.join("ignored-local-file.toml"), r#" [worker] name = "from-local-override" @@ -822,7 +822,7 @@ permission = "write" let yoi_dir = workspace.join(".yoi"); std::fs::create_dir_all(&yoi_dir).unwrap(); write( - &yoi_dir.join("override.local.toml"), + &yoi_dir.join("ignored-local-file.toml"), r#" [worker] name = "from-local-override" diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 21fbc182..6934b252 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1550,8 +1550,7 @@ extract_threshold = 4000 default: Option<&str>, profiles: &[(&str, &str, &str)], ) -> AvailableProfiles { - let yoi = project.join(".yoi"); - let profile_dir = yoi.join("profiles"); + let profile_dir = project.join("explicit-project-profiles"); std::fs::create_dir_all(&profile_dir).unwrap(); let mut registry_toml = String::new(); if let Some(default) = default { @@ -1560,9 +1559,9 @@ extract_threshold = 4000 registry_toml.push_str("[profile]\n"); for (name, file, body) in profiles { std::fs::write(profile_dir.join(file), body).unwrap(); - registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n")); + registry_toml.push_str(&format!("{name} = \"explicit-project-profiles/{file}\"\n")); } - let registry_path = yoi.join("profiles.toml"); + let registry_path = project.join("explicit-project-profiles.toml"); std::fs::write(®istry_path, registry_toml).unwrap(); AvailableProfiles { registry: Some( @@ -1944,7 +1943,7 @@ max_tokens = 3333 let user_config = tmp.path().join("user-profiles.toml"); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap(); - let project_config = project.join(".yoi/profiles.toml"); + let project_config = project.join("explicit-project-profiles.toml"); let ambiguous = AvailableProfiles { registry: Some( ProfileDiscovery::with_sources(Some(user_config), Some(project_config)) diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index bd2be398..ff961dbc 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -45,6 +45,30 @@ impl WorkspaceConfigSchemaRegistry { } } +pub fn evaluate_workspace_config_state( + state: &WorkspaceConfigState, + schema_bundle: WorkspaceConfigSchemaBundle, +) -> Result { + let expected_fingerprint = state.contract.fingerprint.clone(); + let contract = main_config_contract_with_schema(schema_bundle); + if !state.contract.schema_bundle.contributions.is_empty() + && contract.fingerprint != expected_fingerprint + { + return Err(Error::RegistryInconsistency( + "active Workspace config schema fingerprint does not match the current provider bundle" + .to_string(), + )); + } + SnapshotEnvironment::new(state.snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + Error::InvalidInput( + serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "virtual config evaluation failed".to_string()), + ) + }) +} + fn main_config_contract_with_schema( schema_bundle: WorkspaceConfigSchemaBundle, ) -> ToolchainContract { @@ -873,6 +897,44 @@ mod tests { assert!(error.to_string().contains("toolchain fingerprint mismatch")); } + #[test] + fn active_state_evaluation_rejects_provider_fingerprint_drift() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{}", + ) + .unwrap()], + ) + .unwrap(); + let persisted_bundle = + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "one"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let state = WorkspaceConfigState { + projection_digest: "persisted".to_string(), + contract: main_config_contract_with_schema(persisted_bundle), + snapshot, + }; + let changed_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "2", + r#"{ test = { value = String default "two"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let error = evaluate_workspace_config_state(&state, changed_bundle).unwrap_err(); + assert!(error.to_string().contains("schema fingerprint")); + } + #[tokio::test] async fn workspace_materializes_main_entrypoint() { let store = open_store().await; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index be21be8d..d87bdf9d 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -1,32 +1,303 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; +use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; +use manifest::{ProfileSource, resolve_profile_artifact_value}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; +use crate::config_source::{ + WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, +}; 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 PROFILE_SOURCE_TREE_ID: &str = "project"; -const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles"; -const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; -const BUILTIN_PROFILE_IDS: &[&str] = &[ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", -]; -const BUILTIN_PROFILE_SLUGS: &[&str] = - &["companion", "intake", "orchestrator", "coder", "reviewer"]; +const PROFILE_SCHEMA_SOURCE: &str = r#"{ + profile = { + default_profile = String default "builtin:companion"; + entries = [...{ + selector = String; + source = String; + label = String default ""; + description = String default ""; + }] default []; + }; +}"#; + +#[derive(Debug, Default)] +pub struct ProfileConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE) + .map_err(|error| Error::Config(error.to_string())) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualProfileConfig { + profile: VirtualProfileSection, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualProfileSection { + default_profile: String, + entries: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualProfileEntry { + selector: String, + source: String, + label: String, + description: String, +} + +#[derive(Debug, Clone)] +pub struct ProfileConfigProjection { + pub settings: ProfileSettingsResponse, + entries: BTreeMap, + sources: BTreeMap, +} + +pub fn project_profiles_from_workspace_config( + workspace_id: &str, + state: &WorkspaceConfigState, +) -> Result { + let schema = ProfileConfigSchemaProvider.contribution()?; + let bundle = config_source::WorkspaceConfigSchemaBundle::compose([schema]) + .map_err(|error| Error::Config(error.to_string()))?; + let evaluation = evaluate_workspace_config_state(state, bundle)?; + if evaluation.projection_digest != state.projection_digest + && state + .contract + .schema_bundle + .contributions + .iter() + .any(|entry| entry.provider_id == "builtin:profile") + { + return Err(Error::RegistryInconsistency(format!( + "Profile projection digest mismatch for Workspace {workspace_id}" + ))); + } + let projected = evaluation.projections.first().ok_or_else(|| { + Error::RegistryInconsistency("Workspace config has no active projection".to_string()) + })?; + let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone()) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; + let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile)); + let mut entries = BTreeMap::new(); + let sources = state + .snapshot + .entries + .iter() + .filter(|(_, entry)| entry.content_type == ConfigContentType::Decodal) + .map(|(path, entry)| (path.as_str().to_string(), entry.content.clone())) + .collect::>(); + let mut source_summaries = Vec::new(); + for entry in config.profile.entries { + if !entry.selector.starts_with("project:") { + return Err(profile_validation_error( + "profile_selector_invalid", + "Workspace Profile selectors must use project:*", + )); + } + if entries.contains_key(&entry.selector) { + return Err(profile_validation_error( + "profile_selector_duplicate", + "Workspace Profile selectors must be unique", + )); + } + let source_path = VirtualPath::parse(&entry.source).map_err(|error| { + profile_validation_error("profile_source_path_invalid", &error.to_string()) + })?; + let source_entry = state.snapshot.get(&source_path).ok_or_else(|| { + profile_validation_error( + "profile_source_missing", + &format!( + "Profile source {:?} is missing from the active config revision", + entry.source + ), + ) + })?; + if source_entry.content_type != ConfigContentType::Decodal { + return Err(profile_validation_error( + "profile_source_type_invalid", + "Profile sources must use Decodal content", + )); + } + let archive_source = entry.source.clone(); + let label = if entry.label.is_empty() { + entry.selector.trim_start_matches("project:").to_string() + } else { + entry.label.clone() + }; + resolve_profile_artifact_value( + evaluate_profile_source(&state.snapshot, &source_path)?, + ProfileSource::Archive { + archive_id: format!("workspace-config-r{}", state.snapshot.revision), + source: archive_source.clone(), + }, + Path::new("/"), + "workspace-config-validation", + ) + .map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?; + profiles.push(WorkspaceProfileSummary { + profile_id: entry.selector.clone(), + selector: entry.selector.clone(), + label, + source_kind: "project".to_string(), + profile_source_id: Some(entry.source.clone()), + description: (!entry.description.is_empty()).then(|| entry.description.clone()), + editable: false, + is_default: config.profile.default_profile == entry.selector, + diagnostics: Vec::new(), + }); + source_summaries.push(WorkspaceProfileSourceSummary { + profile_source_id: entry.source.clone(), + display_path: entry.source.clone(), + kind: "virtual_config".to_string(), + content_type: "decodal".to_string(), + content_digest: source_entry.content_digest.clone(), + provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, + editable: false, + revision: state.snapshot.revision.to_string(), + size_bytes: source_entry.content.len() as u64, + diagnostics: Vec::new(), + }); + entries.insert(entry.selector.clone(), entry); + } + if !profiles + .iter() + .any(|profile| profile.selector == config.profile.default_profile) + { + return Err(profile_validation_error( + "unknown_default_profile", + "Default Profile must select a builtin or Workspace Profile", + )); + } + Ok(ProfileConfigProjection { + settings: ProfileSettingsResponse { + workspace_id: workspace_id.to_string(), + registry_revision: format!("config:{}", state.snapshot.revision), + config_revision: Some(state.snapshot.revision), + tree_digest: Some(state.snapshot.digest.clone()), + projection_digest: Some(evaluation.projection_digest), + default_profile: Some(config.profile.default_profile), + profiles, + sources: source_summaries, + diagnostics: Vec::new(), + }, + entries, + sources, + }) +} + +fn evaluate_profile_source( + snapshot: &config_source::ConfigTreeSnapshot, + source_path: &VirtualPath, +) -> Result { + let contract = config_source::ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![source_path.clone()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + config_source::WorkspaceConfigSchemaBundle::empty(), + ); + let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + profile_validation_error( + "profile_source_invalid", + &serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "Profile source evaluation failed".to_string()), + ) + })?; + evaluation + .projections + .into_iter() + .next() + .map(|projection| projection.data_json) + .ok_or_else(|| { + profile_validation_error("profile_source_invalid", "Profile source has no projection") + }) +} + +pub fn selector_for_workspace_candidate( + projection: &ProfileConfigProjection, + profile: &str, +) -> Option { + if let Some(selector) = selector_for_builtin_candidate(profile) { + return Some(selector); + } + projection + .entries + .contains_key(profile) + .then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string())) +} + +pub fn build_virtual_profile_config_bundle( + projection: &ProfileConfigProjection, + state: &WorkspaceConfigState, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, +) -> Result> { + let Some(entry) = projection.entries.get(selector) else { + return Ok(None); + }; + let archive = build_virtual_profile_archive(selector, entry, &projection.sources, state)?; + let bundle = ConfigBundle { + metadata: ConfigBundleMetadata { + id: format!("workspace-config-profile-r{}", state.snapshot.revision), + digest: String::new(), + revision: state.snapshot.revision.to_string(), + workspace_id: workspace_id.to_string(), + created_at: workspace_created_at.to_string(), + provenance: ConfigBundleProvenance { + source: "workspace_config".to_string(), + detail: Some(format!( + "revision={} tree={} projection={}", + state.snapshot.revision, state.snapshot.digest, state.projection_digest + )), + }, + }, + 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)) +} + +fn build_virtual_profile_archive( + selector: &str, + entry: &VirtualProfileEntry, + all_sources: &BTreeMap, + state: &WorkspaceConfigState, +) -> Result { + let mut closure = BTreeMap::new(); + let mut imports = BTreeMap::new(); + collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?; + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: format!("workspace-config-profile-r{}", state.snapshot.revision), + entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]), + imports, + sources: closure, + }) + .map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string())) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceMetadataSettingsResponse { @@ -55,12 +326,16 @@ pub struct WorkspaceMetadataMutationResponse { pub struct ProfileSettingsResponse { pub workspace_id: String, pub registry_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tree_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_digest: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_profile: Option, pub profiles: Vec, pub sources: Vec, - #[serde(default)] - pub source_trees: Vec, pub diagnostics: Vec, } @@ -99,132 +374,6 @@ pub enum WorkspaceProfileSourceProvenance { ProjectProfileSourceTree, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeSummary { - pub source_tree_id: String, - pub label: String, - pub root_path: String, - pub kind: String, - pub content_type: String, - pub content_digest: String, - pub provenance: WorkspaceProfileSourceProvenance, - pub editable: bool, - pub revision: String, - pub file_count: usize, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeFileSummary { - pub path: String, - pub kind: String, - pub content_type: String, - pub content_digest: String, - pub provenance: WorkspaceProfileSourceProvenance, - pub editable: bool, - pub revision: String, - pub size_bytes: u64, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeResponse { - pub workspace_id: String, - pub tree: WorkspaceProfileSourceTreeSummary, - pub files: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeFileResponse { - pub workspace_id: String, - pub source_tree_id: String, - pub file: WorkspaceProfileSourceTreeFileSummary, - pub content: String, - 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 WriteWorkspaceProfileTreeFileRequest { - pub path: String, - pub content: String, - #[serde(default)] - pub revision: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeleteWorkspaceProfileTreeFileRequest { - pub path: String, - pub revision: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ReadWorkspaceProfileTreeFileQuery { - pub path: 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 { @@ -233,37 +382,6 @@ struct WorkspaceIdentityFile { 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, @@ -355,481 +473,6 @@ pub fn update_workspace_metadata( )) } -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, - source_trees: vec![profile_source_tree_summary(workspace_root)], - 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 = prepare_source_path_for_write(workspace_root, &relative_path)?; - 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 read_profile_source_tree( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let files = list_profile_tree_files(workspace_root)?; - Ok(WorkspaceProfileSourceTreeResponse { - workspace_id: workspace_id.to_string(), - tree: profile_source_tree_summary_with_count(workspace_root, files.len()), - files, - diagnostics: Vec::new(), - }) -} - -pub fn read_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - query: ReadWorkspaceProfileTreeFileQuery, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let relative_path = relative_source_path_for_virtual_path(&query.path)?; - let full = checked_source_path(workspace_root, &relative_path)?; - let metadata = source_metadata(&full)?; - if metadata.len() > MAX_PROFILE_SOURCE_BYTES { - return Err(profile_validation_error( - "profile_source_too_large", - "Profile source is too large for browser editing", - )); - } - let content = fs::read_to_string(&full)?; - Ok(WorkspaceProfileSourceTreeFileResponse { - workspace_id: workspace_id.to_string(), - source_tree_id: source_tree_id.to_string(), - file: summarize_tree_file(&full, display_source_path(&relative_path)), - content, - diagnostics: Vec::new(), - }) -} - -pub fn write_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - request: WriteWorkspaceProfileTreeFileRequest, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { - return Err(profile_validation_error( - "profile_source_too_large", - "Profile source exceeds the browser editing size limit", - )); - } - let relative_path = relative_source_path_for_virtual_path(&request.path)?; - let full = prepare_source_path_for_write(workspace_root, &relative_path)?; - if let Some(expected) = request.revision.as_deref() { - ensure_revision(&full, expected, "profile_source_revision_conflict")?; - } else if full.exists() { - return Err(profile_validation_error( - "profile_source_revision_required", - "Existing profile source edits require a revision token", - )); - } - validate_tree_content(workspace_root, &relative_path, &request.content)?; - fs::write(&full, &request.content)?; - Ok(WorkspaceProfileSourceTreeFileResponse { - workspace_id: workspace_id.to_string(), - source_tree_id: source_tree_id.to_string(), - file: summarize_tree_file(&full, display_source_path(&relative_path)), - content: request.content, - diagnostics: vec![diagnostic( - "profile_source_file_written", - DiagnosticSeverity::Info, - "Profile source file was written through the source tree API.", - )], - }) -} - -pub fn delete_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - request: DeleteWorkspaceProfileTreeFileRequest, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let relative_path = relative_source_path_for_virtual_path(&request.path)?; - let full = checked_source_path(workspace_root, &relative_path)?; - ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - if project_entries(®istry, &mut Vec::new()) - .iter() - .any(|entry| entry.relative_path == relative_path) - { - return Err(profile_validation_error( - "profile_source_registered", - "Registered profile entry sources must be deleted through the profile registry API", - )); - } - if full.exists() { - fs::remove_file(full)?; - } - read_profile_source_tree(workspace_id, workspace_root, source_tree_id) -} - -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 sources = read_profile_source_tree_contents(workspace_root)?; - let archive = build_profile_archive_for_selector(®istry, sources, selector)?; - 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) - || 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 = [ ( @@ -866,90 +509,6 @@ fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec 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(), @@ -958,268 +517,6 @@ fn profile_validation_error(code: impl Into, message: impl 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 profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary { - let file_count = list_profile_tree_files(workspace_root) - .map(|files| files.len()) - .unwrap_or(0); - profile_source_tree_summary_with_count(workspace_root, file_count) -} - -fn profile_source_tree_summary_with_count( - workspace_root: &Path, - file_count: usize, -) -> WorkspaceProfileSourceTreeSummary { - WorkspaceProfileSourceTreeSummary { - source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(), - label: "Project profile sources".to_string(), - root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(), - kind: "decodal_source_tree".to_string(), - content_type: "application/vnd.yoi.profile-source-tree+json".to_string(), - content_digest: profile_source_tree_digest(workspace_root) - .unwrap_or_else(|_| "sha256:unavailable".to_string()), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - editable: true, - revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)), - file_count, - diagnostics: Vec::new(), - } -} - -fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> { - if source_tree_id == PROFILE_SOURCE_TREE_ID { - Ok(()) - } else { - Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "unknown_profile_source_tree".to_string(), - message: "Unknown profile source tree".to_string(), - }) - } -} - -fn list_profile_tree_files( - workspace_root: &Path, -) -> Result> { - let Some(source_root) = existing_profile_source_root(workspace_root)? else { - return Ok(Vec::new()); - }; - let mut files = Vec::new(); - collect_profile_tree_files(workspace_root, &source_root, &source_root.path, &mut files)?; - files.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(files) -} - -fn collect_profile_tree_files( - workspace_root: &Path, - source_root: &ProfileSourceRoot, - dir: &Path, - files: &mut Vec, -) -> Result<()> { - let canonical_dir = fs::canonicalize(dir)?; - if !canonical_dir.starts_with(&source_root.canonical_path) { - return Err(profile_source_symlink_escape( - "Profile source directory resolves outside the workspace profile source root", - )); - } - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_symlink() { - return Err(profile_source_symlink_escape( - "Profile source tree entries must not be symlinks", - )); - } - if file_type.is_dir() { - collect_profile_tree_files(workspace_root, source_root, &path, files)?; - } else if file_type.is_file() - && path.extension().and_then(|value| value.to_str()) == Some("dcdl") - { - let canonical_file = fs::canonicalize(&path)?; - if !canonical_file.starts_with(&source_root.canonical_path) - || !canonical_file.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source file resolves outside the workspace profile source root", - )); - } - let relative = path - .strip_prefix(workspace_root) - .map_err(|_| { - profile_validation_error( - "profile_source_path_invalid", - "Profile source is outside workspace", - ) - })? - .to_path_buf(); - files.push(summarize_tree_file(&path, display_source_path(&relative))); - } - } - Ok(()) -} - -fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary { - WorkspaceProfileSourceTreeFileSummary { - path: virtual_path, - kind: "decodal".to_string(), - content_type: "text/x-decodal".to_string(), - content_digest: file_content_digest(path), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - editable: true, - revision: file_revision(path), - size_bytes: source_metadata(path) - .map(|metadata| metadata.len()) - .unwrap_or(0), - diagnostics: Vec::new(), - } -} - -fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result { - let normalized = normalize_virtual_profile_source_path(virtual_path)?; - let Some(rest) = normalized.strip_prefix("profiles/") else { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source paths must be under profiles/", - )); - }; - if rest.is_empty() || !rest.ends_with(".dcdl") { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source files must use the .dcdl extension", - )); - } - Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest)) -} - -fn display_source_path(relative_path: &Path) -> String { - let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - let relative = relative_path - .strip_prefix(profile_root) - .unwrap_or(relative_path); - let rest = relative.to_string_lossy().replace('\\', "/"); - format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}") -} - -fn normalize_virtual_profile_source_path(path: &str) -> Result { - let path = path - .strip_prefix("project:") - .or_else(|| path.strip_prefix("workspace:")) - .unwrap_or(path); - if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must be a virtual relative path", - )); - } - let mut normalized = PathBuf::new(); - for component in Path::new(path).components() { - match component { - Component::CurDir => {} - Component::Normal(value) => normalized.push(value), - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must not escape the virtual source tree", - )); - } - } - } - let normalized = normalized.to_string_lossy().replace('\\', "/"); - if normalized.is_empty() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must not be empty", - )); - } - Ok(normalized) -} - -fn read_profile_source_tree_contents(workspace_root: &Path) -> Result> { - let mut sources = BTreeMap::new(); - for file in list_profile_tree_files(workspace_root)? { - let relative = relative_source_path_for_virtual_path(&file.path)?; - let full = checked_source_path(workspace_root, &relative)?; - sources.insert(file.path, fs::read_to_string(full)?); - } - Ok(sources) -} - -fn build_profile_archive_from_tree_sources( - registry: &ProfileRegistryDocument, - sources: BTreeMap, -) -> Result<(ProfileSourceArchive, BTreeMap)> { - let mut entrypoints = BTreeMap::new(); - for entry in project_entries(registry, &mut Vec::new()) { - let path = display_source_path(&entry.relative_path); - if sources.contains_key(&path) { - entrypoints.insert(project_selector(&entry.name), path); - } - } - build_profile_archive_from_source_set(entrypoints, sources) -} - -fn build_profile_archive_for_selector( - registry: &ProfileRegistryDocument, - sources: BTreeMap, - selector: &str, -) -> Result { - let Some(name) = selector.strip_prefix("project:") else { - return Err(profile_validation_error( - "profile_selector_invalid", - "Project profile archive selector must use project:*", - )); - }; - let entry = project_entries(registry, &mut Vec::new()) - .into_iter() - .find(|entry| entry.name == name) - .ok_or_else(|| { - profile_validation_error( - "unknown_profile_selector", - "Selected project profile is not present in the workspace profile registry", - ) - })?; - let root_path = display_source_path(&entry.relative_path); - if !sources.contains_key(&root_path) { - return Err(profile_validation_error( - "profile_source_missing", - "Selected project profile source is missing from the source tree", - )); - } - let mut closure_sources = BTreeMap::new(); - let mut imports = BTreeMap::new(); - collect_profile_import_closure(&root_path, &sources, &mut closure_sources, &mut imports)?; - let mut entrypoints = BTreeMap::new(); - entrypoints.insert(selector.to_string(), root_path); - build_profile_archive_from_source_set_with_imports(entrypoints, closure_sources, imports) -} - fn collect_profile_import_closure( current_path: &str, all_sources: &BTreeMap, @@ -1252,52 +549,6 @@ fn collect_profile_import_closure( Ok(()) } -fn build_profile_archive_from_source_set( - entrypoints: BTreeMap, - sources: BTreeMap, -) -> Result<(ProfileSourceArchive, BTreeMap)> { - let mut imports = BTreeMap::new(); - let source_snapshot = sources.clone(); - for (current_path, content) in &source_snapshot { - for specifier in collect_decodal_import_specifiers(content) { - let target = resolve_profile_source_import(current_path, &specifier)?; - if source_snapshot.contains_key(&target) { - imports.insert(format!("{current_path}\0{specifier}"), target); - } else { - return Err(profile_validation_error( - "profile_source_import_missing", - &format!( - "Profile source import {specifier:?} from {current_path} resolves to missing {target}" - ), - )); - } - } - } - build_profile_archive_from_source_set_with_imports(entrypoints, sources, imports.clone()) - .map(|archive| (archive, imports)) -} - -fn build_profile_archive_from_source_set_with_imports( - entrypoints: BTreeMap, - mut sources: BTreeMap, - imports: BTreeMap, -) -> Result { - if sources.is_empty() { - sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string()); - } - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: "workspace-project-decodal-profiles-v1".to_string(), - entrypoints, - imports, - sources, - }) - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_archive_invalid".to_string(), - message: err.to_string(), - }) -} - fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result { if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() { return Err(profile_validation_error( @@ -1325,15 +576,34 @@ fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result< .join(raw) }; let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?; - if !normalized.starts_with("profiles/") { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Profile source import must resolve inside the profiles/ tree", - )); - } Ok(normalized) } +fn normalize_virtual_profile_source_path(path: &str) -> Result { + let mut normalized = PathBuf::new(); + for component in Path::new(path).components() { + match component { + Component::CurDir => {} + Component::Normal(value) => normalized.push(value), + Component::ParentDir => { + if !normalized.pop() { + return Err(profile_validation_error( + "profile_source_import_invalid", + "Profile source import escapes the virtual tree", + )); + } + } + Component::RootDir | Component::Prefix(_) => { + return Err(profile_validation_error( + "profile_source_import_invalid", + "Profile source import must be relative", + )); + } + } + } + Ok(normalized.to_string_lossy().replace('\\', "/")) +} + fn collect_decodal_import_specifiers(content: &str) -> Vec { let mut specifiers = Vec::new(); for line in content.lines() { @@ -1365,507 +635,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec { specifiers } -fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> { - let mut sources = read_profile_source_tree_contents(workspace_root)?; - sources.insert(display_source_path(relative_path), content.to_string()); - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; - archive - .verify() - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_invalid".to_string(), - message: err.to_string(), - })?; - Ok(()) -} - -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(), - }); - } - validate_source_candidate_path(workspace_root, relative_path)?; - let mut sources = read_profile_source_tree_contents(workspace_root)?; - sources.insert(display_source_path(relative_path), content.to_string()); - let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; - registry.profile.entry(name.to_string()).or_insert_with(|| { - ProfileEntryFile::Table(ProfileEntryTable { - path: display_source_path(relative_path), - description: None, - }) - }); - let selector = project_selector(name); - let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; - 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(), - })?; - 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(), - content_type: "text/x-decodal".to_string(), - content_digest: checked_source_path(workspace_root, relative_path) - .map(|path| file_content_digest(&path)) - .unwrap_or_else(|_| "sha256:unavailable".to_string()), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - 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 profile_source_tree_digest(workspace_root: &Path) -> Result { - let mut bytes = Vec::new(); - for file in list_profile_tree_files(workspace_root)? { - bytes.extend_from_slice(file.path.as_bytes()); - bytes.push(0); - bytes.extend_from_slice(file.content_digest.as_bytes()); - bytes.push(0); - } - Ok(sha256_hex(&bytes)) -} - -fn file_content_digest(path: &Path) -> String { - fs::read(path) - .map(|bytes| sha256_hex(&bytes)) - .unwrap_or_else(|_| "sha256:unavailable".to_string()) -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::from("sha256:"); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(out, "{byte:02x}"); - } - out -} - -#[derive(Debug, Clone)] -struct ProfileSourceRoot { - path: PathBuf, - canonical_path: PathBuf, - canonical_workspace: PathBuf, -} - -fn profile_source_symlink_escape(message: impl Into) -> Error { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_symlink_escape".to_string(), - message: message.into(), - } -} - -fn existing_profile_source_root(workspace_root: &Path) -> Result> { - let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - let metadata = match fs::symlink_metadata(&source_root) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; - validate_profile_source_root_metadata(workspace_root, source_root, metadata).map(Some) -} - -fn prepare_profile_source_root_for_write(workspace_root: &Path) -> Result { - let canonical_workspace = fs::canonicalize(workspace_root)?; - let yoi_dir = workspace_root.join(".yoi"); - match fs::symlink_metadata(&yoi_dir) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Workspace .yoi directory must not be a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Workspace .yoi path is not a directory", - )); - } - let canonical_yoi = fs::canonicalize(&yoi_dir)?; - if !canonical_yoi.starts_with(&canonical_workspace) { - return Err(profile_source_symlink_escape( - "Workspace .yoi directory resolves outside the workspace root", - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&yoi_dir)?, - Err(err) => return Err(err.into()), - } - - let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - match fs::symlink_metadata(&source_root) { - Ok(metadata) => { - validate_profile_source_root_metadata(workspace_root, source_root, metadata) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&source_root)?; - let metadata = fs::symlink_metadata(&source_root)?; - validate_profile_source_root_metadata(workspace_root, source_root, metadata) - } - Err(err) => Err(err.into()), - } -} - -fn validate_profile_source_root_metadata( - workspace_root: &Path, - source_root: PathBuf, - metadata: std::fs::Metadata, -) -> Result { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Workspace profile source root must not be a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Workspace profile source root is not a directory", - )); - } - let canonical_workspace = fs::canonicalize(workspace_root)?; - let canonical_path = fs::canonicalize(&source_root)?; - if !canonical_path.starts_with(&canonical_workspace) { - return Err(profile_source_symlink_escape( - "Workspace profile source root resolves outside the workspace root", - )); - } - Ok(ProfileSourceRoot { - path: source_root, - canonical_path, - canonical_workspace, - }) -} - -fn validate_source_candidate_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 Some(_) = existing_profile_source_root(workspace_root)? else { - return Ok(()); - }; - let full = workspace_root.join(relative_path); - if full.exists() || full.parent().is_some_and(Path::exists) { - checked_source_path(workspace_root, relative_path)?; - } - Ok(()) -} - -fn prepare_source_path_for_write(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 = prepare_profile_source_root_for_write(workspace_root)?; - let full = workspace_root.join(relative_path); - let parent = full.parent().ok_or_else(|| { - profile_validation_error( - "profile_source_path_invalid", - "Profile source path has no parent directory", - ) - })?; - let parent_relative = parent.strip_prefix(&source_root.path).map_err(|_| { - profile_validation_error( - "profile_source_path_escape", - "Profile source parent must remain inside the source tree", - ) - })?; - let mut current = source_root.path.clone(); - for component in parent_relative.components() { - let Component::Normal(name) = component else { - return Err(profile_validation_error( - "profile_source_path_escape", - "Profile source parent must be a normalized relative path", - )); - }; - let next = current.join(name); - match fs::symlink_metadata(&next) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Profile source parent contains a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source parent component is not a directory", - )); - } - let canonical_next = fs::canonicalize(&next)?; - if !canonical_next.starts_with(&source_root.canonical_path) - || !canonical_next.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source parent resolves outside the workspace profile source root", - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&next)?, - Err(err) => return Err(err.into()), - } - current = next; - } - checked_source_path(workspace_root, relative_path) -} - -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 = existing_profile_source_root(workspace_root)?.ok_or_else(|| { - profile_validation_error( - "profile_source_missing", - "Workspace profile source root does not exist", - ) - })?; - let full = workspace_root.join(relative_path); - if let Ok(canonical_full) = fs::canonicalize(&full) { - if !canonical_full.starts_with(&source_root.canonical_path) - || !canonical_full.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source resolves outside the workspace profile source root", - )); - } - } else if let Some(parent) = full.parent() { - let canonical_parent = fs::canonicalize(parent)?; - if !canonical_parent.starts_with(&source_root.canonical_path) - || !canonical_parent.starts_with(&source_root.canonical_workspace) - { - 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 { @@ -1877,27 +646,6 @@ fn sanitize_display_name(value: &str) -> Result { } 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 { @@ -1909,17 +657,9 @@ pub fn selector_for_builtin_candidate( | "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 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(); @@ -1932,32 +672,6 @@ fn file_revision(path: &Path) -> String { .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, @@ -1969,7 +683,6 @@ fn diagnostic( message: message.into(), } } - fn sanitize_error(value: &str) -> String { value .split_whitespace() @@ -1990,318 +703,148 @@ mod tests { use super::*; fn valid_decodal(slug: &str) -> String { - format!( - r#"{{ - slug = "{slug}"; - description = "Test"; - scope = "workspace_read"; - }}"# - ) + format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#) + } + + fn virtual_state(entries: Vec) -> WorkspaceConfigState { + let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap(); + WorkspaceConfigState { + projection_digest: String::new(), + contract: config_source::ToolchainContract::new( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + ), + snapshot, + } } #[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(); + fn virtual_config_projection_is_builtin_only_by_default() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + assert_eq!( + projection.settings.default_profile.as_deref(), + Some("builtin:companion") + ); + assert_eq!(projection.settings.config_revision, Some(7)); + assert!(projection.settings.projection_digest.is_some()); assert!( - created + projection .settings .profiles .iter() - .any(|profile| profile.profile_id == "project:alpha") + .all(|item| !item.editable) ); + } + + #[test] + fn virtual_config_projection_builds_archive_from_active_revision() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:alpha", + ) + .unwrap() + .unwrap(); + assert_eq!(projection.settings.config_revision, Some(7)); assert!( - build_workspace_profile_archive(dir.path(), "project:alpha") + bundle + .metadata + .provenance + .detail .unwrap() - .is_some() + .contains("revision=7") ); - } - - #[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")); + let archive = bundle.profile_source_archive.unwrap(); 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())); - } - - #[test] - fn selected_profile_archive_contains_only_import_closure() { - let mut registry = ProfileRegistryDocument::default(); - registry.profile.insert( - "alpha".to_string(), - ProfileEntryFile::Table(ProfileEntryTable { - path: "profiles/alpha.dcdl".to_string(), - description: None, - }), - ); - registry.profile.insert( - "beta".to_string(), - ProfileEntryFile::Table(ProfileEntryTable { - path: "profiles/beta.dcdl".to_string(), - description: None, - }), - ); - let mut sources = BTreeMap::new(); - sources.insert( - "profiles/alpha.dcdl".to_string(), - r#"{ extra = import "./shared.dcdl"; }"#.to_string(), - ); - sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string()); - sources.insert("profiles/beta.dcdl".to_string(), "{}".to_string()); - sources.insert("profiles/unregistered.dcdl".to_string(), "{}".to_string()); - - let archive = - build_profile_archive_for_selector(®istry, sources, "project:alpha").unwrap(); - let verified = archive.verify().unwrap(); - let manifest = verified.manifest(); - let source_paths: Vec<_> = manifest - .sources - .iter() - .map(|source| source.path.as_str()) - .collect(); - assert_eq!(manifest.entrypoints.len(), 1); - assert_eq!( - manifest + archive + .reference + .source_graph .entrypoints .get("project:alpha") .map(String::as_str), Some("profiles/alpha.dcdl") ); - assert!(source_paths.contains(&"profiles/alpha.dcdl")); - assert!(source_paths.contains(&"profiles/shared.dcdl")); - assert!(!source_paths.contains(&"profiles/beta.dcdl")); - assert!(!source_paths.contains(&"profiles/unregistered.dcdl")); + assert_eq!(archive.reference.source_graph.source_count, 1); } - #[cfg(unix)] #[test] - fn tree_write_rejects_symlink_parent_before_outside_side_effect() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - let outside = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles/link")).unwrap(); - - let err = write_profile_tree_file( + fn virtual_config_projection_preserves_import_closure() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"import "../shared/profile.dcdl""#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("shared/profile.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, "workspace-test", - dir.path(), - PROFILE_SOURCE_TREE_ID, - WriteWorkspaceProfileTreeFileRequest { - path: "profiles/link/nested/new.dcdl".to_string(), - content: valid_decodal("new"), - revision: None, - }, + "2026-01-01T00:00:00Z", + "project:alpha", ) - .unwrap_err(); - - assert!(err.to_string().contains("profile_source_symlink_escape")); - assert!(!outside.path().join("nested").exists()); + .unwrap() + .unwrap(); + let archive = bundle.profile_source_archive.unwrap(); + assert_eq!(archive.reference.source_graph.source_count, 2); + assert_eq!(archive.reference.source_graph.import_count, 1); } - #[cfg(unix)] #[test] - fn source_root_symlink_is_rejected_before_tree_side_effects() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - let outside = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles")).unwrap(); - - let err = write_profile_tree_file( - "workspace-test", - dir.path(), - PROFILE_SOURCE_TREE_ID, - WriteWorkspaceProfileTreeFileRequest { - path: "profiles/nested/new.dcdl".to_string(), - content: valid_decodal("new"), - revision: None, - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); - assert!(!outside.path().join("nested").exists()); - assert!(!outside.path().join("new.dcdl").exists()); - - let err = read_profile_source_tree("workspace-test", dir.path(), PROFILE_SOURCE_TREE_ID) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); - - let err = build_workspace_profile_archive(dir.path(), "project:any").unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); + fn virtual_config_projection_rejects_missing_profile_source() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#, + ) + .unwrap(), + ]); + let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err(); + assert!( + error + .to_string() + .contains("missing from the active config revision") + ); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b2354b8b..b5e2552b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -84,12 +84,7 @@ use crate::observation::{ BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSource, RuntimeObservationSourceConfig, }; -use crate::profile_settings::{ - CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, - DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery, - UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, - UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest, -}; +use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, @@ -756,9 +751,17 @@ impl WorkspaceApi { let config_store = Arc::new(crate::SqliteWorkspaceStore::open( config.database_path.clone(), )?); + config_store.ensure_workspace_config_materialized( + &config.workspace_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + )?; + let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default() + .with_provider(Arc::new( + crate::profile_settings::ProfileConfigSchemaProvider, + )); let api = Self { config_store, - config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(), + config_schema_registry, authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -1171,27 +1174,7 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .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/trees/{source_tree_id}", - get(scoped_get_profile_source_tree), - ) - .route( - "/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file", - get(scoped_get_profile_tree_file) - .put(scoped_write_profile_tree_file) - .delete(scoped_delete_profile_tree_file), - ) - .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), + get(scoped_get_profile_settings), ) .route( "/api/w/{workspace_id}/flows", @@ -2577,130 +2560,19 @@ async fn scoped_get_profile_settings( 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_tree( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::read_profile_source_tree( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - )?)) -} - -async fn scoped_get_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Query(query): Query, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::read_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - query, - )?)) -} - -async fn scoped_write_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::write_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - request, - )?)) -} - -async fn scoped_delete_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::delete_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - 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, - )?)) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + Ok(Json( + crate::profile_settings::project_profiles_from_workspace_config( + &path.workspace_id, + &state, + )? + .settings, + )) } async fn scoped_list_tickets( @@ -5134,12 +5006,7 @@ fn start_memory_staging_consolidation( } let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()); - let resolved_config_bundle = crate::profile_settings::build_workspace_profile_config_bundle( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - MEMORY_CONSOLIDATION_PROFILE, - )?; + let resolved_config_bundle = None; let initial_submit = vec![Segment::text(input_content)]; let result = api.spawn_workspace_worker( &runtime_id, @@ -8142,7 +8009,7 @@ async fn test_remote_runtime_connection( async fn get_worker_launch_options( State(api): State, ) -> ApiResult> { - Ok(Json(worker_launch_options_response(&api))) + Ok(Json(worker_launch_options_response(&api)?)) } fn working_directory_request_from_repository( @@ -8298,18 +8165,20 @@ async fn create_workspace_worker( initial_submit, working_directory, } = request; + let config_state = api + .config_store + .load_workspace_config(&api.config.workspace_id)? + .ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?; + let profile_projection = crate::profile_settings::project_profiles_from_workspace_config( + &api.config.workspace_id, + &config_state, + )?; let profile = profile .as_deref() .map(str::trim) .filter(|profile| !profile.is_empty()) .map(ToOwned::to_owned) - .or_else(|| { - crate::profile_settings::load_profile_settings( - &api.config.workspace_id, - &api.config.workspace_root, - ) - .default_profile - }) + .or_else(|| profile_projection.settings.default_profile.clone()) .ok_or_else(|| { settings_bad_request( "workspace_default_profile_missing", @@ -8317,24 +8186,20 @@ async fn create_workspace_worker( ) })?; let profile_selector = - profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else( - || { + crate::profile_settings::selector_for_workspace_candidate(&profile_projection, &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 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, - &profile, - )? - } else { - None - }; + })?; + let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle( + &profile_projection, + &config_state, + &api.config.workspace_id, + &api.config.workspace_created_at, + &profile, + )?; let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -10437,7 +10302,7 @@ async fn probe_remote_json( }) } -fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse { +fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult { let runtimes = api .runtime .list_runtimes(api.config.max_records.min(200)) @@ -10456,10 +10321,17 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp } }) .collect(); - let profile_settings = crate::profile_settings::load_profile_settings( + let config_state = api + .config_store + .load_workspace_config(&api.config.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + let profile_settings = crate::profile_settings::project_profiles_from_workspace_config( &api.config.workspace_id, - &api.config.workspace_root, - ); + &config_state, + )? + .settings; let profiles = profile_settings .profiles .into_iter() @@ -10477,7 +10349,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp .unwrap_or_else(|| "Workspace profile.".to_string()), }) .collect(); - WorkerLaunchOptionsResponse { + Ok(WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, default_profile: profile_settings.default_profile, @@ -10485,7 +10357,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp repositories: working_directory_repository_options(api), working_directories: available_working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), - } + }) } fn working_directory_repository_options( @@ -11069,27 +10941,6 @@ fn working_directory_request_for_browser( }) } -fn profile_selector_for_candidate_with_root( - workspace_root: &Path, - profile: &str, -) -> Option { - if let Some(selector @ ProfileSelector::Builtin(_)) = - crate::profile_settings::selector_for_builtin_candidate(profile) - { - return Some(selector); - } - crate::profile_settings::project_profile_candidates(workspace_root) - .into_iter() - .find(|candidate| { - candidate.profile_id == profile - && !candidate - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - }) - .and_then(|_| crate::profile_settings::selector_for_builtin_candidate(profile)) -} - fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult { worker_id.parse::().map_err(|_| { settings_bad_request( @@ -12776,93 +12627,6 @@ mod tests { assert!(!serialized.contains("materialized_path")); } - #[test] - fn worker_profile_candidates_are_backend_published_and_mapped() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "default = \"builtin:companion\"\n", - ) - .unwrap(); - let settings = crate::profile_settings::load_profile_settings("workspace-test", dir.path()); - for expected in [ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", - ] { - assert!( - settings - .profiles - .iter() - .any(|profile| profile.profile_id == expected), - "missing {expected}" - ); - } - assert!( - settings - .profiles - .iter() - .all(|profile| profile.profile_id != "builtin:default") - ); - assert_eq!( - settings.default_profile.as_deref(), - Some("builtin:companion") - ); - assert!(matches!( - profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"), - Some(ProfileSelector::Builtin(value)) if value == "builtin:coder" - )); - assert!( - profile_selector_for_candidate_with_root(dir.path(), "free-text-profile").is_none() - ); - } - - #[tokio::test] - async fn worker_launch_options_publish_every_valid_workspace_profile_and_default() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - concat!( - "default = \"builtin:companion\"\n", - "[profile.custom]\n", - "path = \".yoi/profiles/custom.dcdl\"\n", - ), - ) - .unwrap(); - fs::write( - dir.path().join(".yoi/profiles/custom.dcdl"), - "slug = \"custom\"; description = \"Custom profile\";\n", - ) - .unwrap(); - let api = test_app(dir.path()).await; - - let response = get_json(api, "/api/workers/launch-options").await; - assert_eq!(response["default_profile"], "builtin:companion"); - let profiles = response["profiles"].as_array().unwrap(); - for expected in [ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", - "project:custom", - ] { - assert!( - profiles.iter().any(|profile| profile["id"] == expected), - "missing {expected}: {profiles:?}" - ); - } - assert!( - profiles - .iter() - .all(|profile| profile["id"] != "builtin:default") - ); - } - #[test] fn runtime_connection_request_validation_bounds_browser_input() { let ok = AddRemoteRuntimeConnectionRequest { @@ -12911,218 +12675,6 @@ mod tests { ); } - #[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(); - let candidates = - crate::profile_settings::load_profile_settings("workspace-test", dir.path()) - .profiles - .into_iter() - .filter(|profile| { - !profile - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - }) - .map(|profile| profile.profile_id) - .collect::>(); - assert!(!candidates.iter().any(|profile| profile == "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:?}" - ); - } - struct DeterministicExecutionBackend { contexts: std::sync::Mutex< std::collections::HashMap< @@ -13405,14 +12957,7 @@ mod tests { ) .unwrap(); - let resolved_config_bundle = - crate::profile_settings::build_workspace_profile_config_bundle( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - MEMORY_CONSOLIDATION_PROFILE, - ) - .unwrap(); + let resolved_config_bundle = None; let existing = api .runtime .spawn_worker( @@ -16064,12 +15609,6 @@ mod tests { #[tokio::test] async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() { let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "default = \"builtin:coder\"\n", - ) - .unwrap(); let app = test_app(dir.path()).await; let created = post_json( app.clone(), @@ -16090,7 +15629,7 @@ mod tests { .find(|worker| worker["worker_id"] == created["worker_id"]) .expect("created Worker should be listed"); assert_eq!(worker["label"], "Worker"); - assert_eq!(worker["profile"], "builtin:coder"); + assert_eq!(worker["profile"], "builtin:companion"); assert!(worker.get("role").is_none()); assert_eq!(worker["worker_id"], created["worker_id"]); let detail_path = format!( @@ -17745,6 +17284,37 @@ mod tests { assert_eq!(shown["linked_tickets"], json!(["00000000001J3"])); } + #[tokio::test] + async fn profile_settings_are_read_only_virtual_config_projection() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"); + let settings = get_json(app.clone(), &path).await; + assert_eq!(settings["default_profile"], "builtin:companion"); + assert_eq!(settings["config_revision"], 0); + assert!(settings["tree_digest"].as_str().is_some()); + assert!(settings["projection_digest"].as_str().is_some()); + assert!( + settings["profiles"] + .as_array() + .unwrap() + .iter() + .all(|profile| profile["editable"] == false) + ); + let mutation = app + .oneshot( + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(mutation.status(), StatusCode::METHOD_NOT_ALLOWED); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())