diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index f7135959..c0ff7708 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -6,6 +6,8 @@ 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::EffectivePromptCatalog; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; @@ -247,6 +249,85 @@ pub fn selector_for_workspace_candidate( .then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string())) } +fn validate_prompt_projection_matches_state( + workspace_id: &str, + state: &WorkspaceConfigState, + projection: &crate::prompt_settings::WorkspacePromptProjection, +) -> Result<()> { + if !projection.matches(workspace_id, state) { + return Err(Error::Config( + "Prompt projection identity does not match Workspace config state".to_string(), + )); + } + let prompt_catalog = projection.catalog(); + let mismatches = [ + ( + prompt_catalog.config_revision != state.snapshot.revision, + "config revision", + ), + ( + prompt_catalog.schema_fingerprint != state.contract.schema_bundle.fingerprint, + "schema fingerprint", + ), + ( + prompt_catalog.toolchain_fingerprint != state.contract.fingerprint, + "toolchain fingerprint", + ), + ] + .into_iter() + .filter_map(|(mismatch, label)| mismatch.then_some(label)) + .collect::>(); + if mismatches.is_empty() { + Ok(()) + } else { + Err(Error::Config(format!( + "Prompt projection does not match Workspace config state: {}", + mismatches.join(", ") + ))) + } +} + +fn virtual_profile_bundle_id( + state: &WorkspaceConfigState, + workspace_id: &str, + profile_selector: &worker_runtime::catalog::ProfileSelector, + prompt_catalog: &EffectivePromptCatalog, + archive: Option<&ProfileSourceArchive>, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"workspace-profile-launch-v1\0"); + hasher.update(workspace_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(state.snapshot.revision.to_le_bytes()); + hasher.update(b"\0"); + hasher.update(state.snapshot.digest.as_bytes()); + hasher.update(b"\0"); + hasher.update(state.projection_digest.as_bytes()); + hasher.update(b"\0"); + hasher.update( + serde_json::to_vec(profile_selector).map_err(|error| Error::Config(error.to_string()))?, + ); + hasher.update(b"\0"); + hasher.update(prompt_catalog.catalog_digest.as_bytes()); + hasher.update(b"\0"); + hasher.update(prompt_catalog.schema_fingerprint.as_bytes()); + hasher.update(b"\0"); + hasher.update(prompt_catalog.toolchain_fingerprint.as_bytes()); + if let Some(archive) = archive { + hasher.update(b"\0"); + hasher.update(archive.reference.digest.as_bytes()); + } + let digest = hasher.finalize(); + let identity = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(format!( + "workspace-config-profile-r{}-{identity}", + state.snapshot.revision + )) +} + pub fn build_virtual_profile_config_bundle( projection: &ProfileConfigProjection, state: &WorkspaceConfigState, @@ -254,6 +335,28 @@ pub fn build_virtual_profile_config_bundle( workspace_created_at: &str, selector: &str, ) -> Result> { + let prompt_projection = + crate::prompt_settings::project_workspace_prompt_projection(workspace_id, state)?; + build_virtual_profile_config_bundle_with_prompt_projection( + projection, + state, + workspace_id, + workspace_created_at, + selector, + &prompt_projection, + ) +} + +pub fn build_virtual_profile_config_bundle_with_prompt_projection( + projection: &ProfileConfigProjection, + state: &WorkspaceConfigState, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, + prompt_projection: &crate::prompt_settings::WorkspacePromptProjection, +) -> Result> { + validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?; + let prompt_catalog = prompt_projection.catalog().clone(); let archive = projection .entries .get(selector) @@ -261,9 +364,16 @@ pub fn build_virtual_profile_config_bundle( .transpose()?; let profile_selector = selector_for_builtin_candidate(selector) .unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string())); + let bundle_id = virtual_profile_bundle_id( + state, + workspace_id, + &profile_selector, + &prompt_catalog, + archive.as_ref(), + )?; let bundle = ConfigBundle { metadata: ConfigBundleMetadata { - id: format!("workspace-config-profile-r{}", state.snapshot.revision), + id: bundle_id, digest: String::new(), revision: state.snapshot.revision.to_string(), workspace_id: workspace_id.to_string(), @@ -281,7 +391,7 @@ pub fn build_virtual_profile_config_bundle( label: Some(selector.to_string()), }], declarations: Vec::new(), - prompt_catalog: Some(crate::prompt_settings::project_prompts_from_workspace_config(state)?), + prompt_catalog: Some(prompt_catalog), profile_source_archive: archive, profile_source_archive_handle: None, } @@ -828,6 +938,146 @@ mod tests { assert_eq!(archive.reference.source_graph.import_count, 1); } + #[test] + fn virtual_config_launch_bundle_identity_is_profile_specific_and_stable() { + 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(); + + let companion = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:companion", + ) + .unwrap() + .unwrap(); + let companion_retry = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:companion", + ) + .unwrap() + .unwrap(); + let coder = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:coder", + ) + .unwrap() + .unwrap(); + + assert_eq!(companion.metadata.id, companion_retry.metadata.id); + assert_eq!(companion.metadata.digest, companion_retry.metadata.digest); + assert_ne!(companion.metadata.id, coder.metadata.id); + assert_ne!(companion.metadata.digest, coder.metadata.digest); + assert!( + companion + .metadata + .id + .starts_with("workspace-config-profile-r7-") + ); + assert!( + coder + .metadata + .id + .starts_with("workspace-config-profile-r7-") + ); + } + + #[test] + fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_revision() { + 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(); + let prompt_projection = + crate::prompt_settings::project_workspace_prompt_projection("workspace-test", &state) + .unwrap(); + let mut mismatched_state = state.clone(); + mismatched_state.snapshot.revision += 1; + + let error = build_virtual_profile_config_bundle_with_prompt_projection( + &projection, + &mismatched_state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:coder", + &prompt_projection, + ) + .unwrap_err(); + assert!(error.to_string().contains("does not match")); + } + + #[test] + fn virtual_config_launch_bundle_identity_separates_project_profile_archives() { + 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"; }, + { selector = "project:beta"; source = "profiles/beta.dcdl"; }, + ]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/beta.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("beta"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let alpha = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:alpha", + ) + .unwrap() + .unwrap(); + let beta = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:beta", + ) + .unwrap() + .unwrap(); + + assert_ne!(alpha.metadata.id, beta.metadata.id); + assert_ne!(alpha.metadata.digest, beta.metadata.digest); + assert_ne!( + alpha.profile_source_archive.unwrap().reference.digest, + beta.profile_source_archive.unwrap().reference.digest + ); + } + #[test] fn virtual_config_projection_rejects_missing_profile_source() { let state = virtual_state(vec![ diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs index 5e207d81..8f920e61 100644 --- a/crates/workspace-server/src/prompt_settings.rs +++ b/crates/workspace-server/src/prompt_settings.rs @@ -1,3 +1,6 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock}; + use config_source::{ConfigProjectionValidator, ConfigSchemaContribution}; use worker::{EffectivePromptCatalog, prompt_schema_source}; @@ -6,6 +9,168 @@ use crate::config_source::{ }; use crate::{Error, Result}; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PromptProjectionCacheKey { + workspace_id: String, + config_revision: u64, + projection_digest: String, + schema_fingerprint: String, + toolchain_fingerprint: String, +} + +impl PromptProjectionCacheKey { + fn new(workspace_id: &str, state: &WorkspaceConfigState) -> Self { + Self { + workspace_id: workspace_id.to_string(), + config_revision: state.snapshot.revision, + projection_digest: state.projection_digest.clone(), + schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(), + toolchain_fingerprint: state.contract.fingerprint.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct WorkspacePromptProjection { + identity: PromptProjectionCacheKey, + catalog: EffectivePromptCatalog, +} + +impl WorkspacePromptProjection { + pub fn catalog(&self) -> &EffectivePromptCatalog { + &self.catalog + } + + pub fn matches(&self, workspace_id: &str, state: &WorkspaceConfigState) -> bool { + self.identity == PromptProjectionCacheKey::new(workspace_id, state) + } +} + +type PromptProjectionCell = OnceLock, String>>; + +#[derive(Debug, Default)] +struct PromptProjectionCacheState { + entries: BTreeMap>, + active: BTreeMap, +} + +/// WorkspaceApi-shared immutable Prompt projections keyed by authoritative Workspace config +/// identity. +/// +/// This cache is an evaluation optimization only. Callers must load the active +/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. Advancing a +/// Workspace replaces only its active cache entry; in-flight users retain their immutable `Arc`. +#[derive(Debug, Clone, Default)] +pub struct WorkspacePromptProjectionCache { + inner: Arc>, +} + +impl WorkspacePromptProjectionCache { + pub fn resolve( + &self, + workspace_id: &str, + state: &WorkspaceConfigState, + ) -> Result> { + let key = PromptProjectionCacheKey::new(workspace_id, state); + let (cell, cached) = { + let mut cache = self.lock()?; + if let Some(active) = cache.active.get(workspace_id) { + if key.config_revision == active.config_revision && key != *active { + return Err(Error::RegistryInconsistency(format!( + "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", + key.config_revision + ))); + } + if key.config_revision < active.config_revision { + (Arc::new(PromptProjectionCell::new()), false) + } else { + let cell = cache + .entries + .entry(key.clone()) + .or_insert_with(|| Arc::new(PromptProjectionCell::new())) + .clone(); + (cell, true) + } + } else { + let cell = cache + .entries + .entry(key.clone()) + .or_insert_with(|| Arc::new(PromptProjectionCell::new())) + .clone(); + (cell, true) + } + }; + + let resolved = cell + .get_or_init(|| { + project_workspace_prompt_projection(workspace_id, state) + .map(Arc::new) + .map_err(|error| error.to_string()) + }) + .clone(); + let catalog = match resolved { + Ok(catalog) => catalog, + Err(error) => { + if cached { + self.lock()?.entries.remove(&key); + } + return Err(Error::Config(error)); + } + }; + + if cached { + self.record_resolved(workspace_id, &key, &cell)?; + } + Ok(catalog) + } + + fn record_resolved( + &self, + workspace_id: &str, + key: &PromptProjectionCacheKey, + cell: &Arc, + ) -> Result<()> { + let mut cache = self.lock()?; + let active = cache.active.get(workspace_id).cloned(); + match active { + Some(active) if active.config_revision > key.config_revision => { + cache.entries.remove(key); + } + Some(active) if active.config_revision == key.config_revision => { + if active != *key { + cache.entries.remove(key); + return Err(Error::RegistryInconsistency(format!( + "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", + key.config_revision + ))); + } + cache.entries.entry(key.clone()).or_insert(cell.clone()); + } + _ => { + cache.entries.insert(key.clone(), cell.clone()); + cache.active.insert(workspace_id.to_string(), key.clone()); + cache.entries.retain(|existing, _| { + existing.workspace_id != workspace_id + || existing == key + || existing.config_revision > key.config_revision + }); + } + } + Ok(()) + } + + fn lock(&self) -> Result> { + self.inner.lock().map_err(|_| { + Error::RegistryInconsistency("Prompt projection cache lock was poisoned".to_string()) + }) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.lock().expect("cache lock").entries.len() + } +} + #[derive(Debug, Default)] pub struct PromptConfigSchemaProvider; @@ -73,6 +238,16 @@ pub fn project_prompts_from_workspace_config( .map_err(|error| Error::RegistryInconsistency(error.to_string())) } +pub fn project_workspace_prompt_projection( + workspace_id: &str, + state: &WorkspaceConfigState, +) -> Result { + Ok(WorkspacePromptProjection { + identity: PromptProjectionCacheKey::new(workspace_id, state), + catalog: project_prompts_from_workspace_config(state)?, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -82,12 +257,16 @@ mod tests { }; fn state(source: &str) -> WorkspaceConfigState { + state_at(7, source) + } + + fn state_at(revision: u64, source: &str) -> WorkspaceConfigState { let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider .contribution() .unwrap()]) .unwrap(); let snapshot = ConfigTreeSnapshot::from_entries( - 7, + revision, [ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, @@ -113,6 +292,159 @@ mod tests { } } + #[test] + fn prompt_projection_cache_shares_immutable_entry_and_replaces_workspace_revision() { + let cache = WorkspacePromptProjectionCache::default(); + let initial = state("{}"); + let first = cache.resolve("workspace-a", &initial).unwrap(); + let retry = cache.resolve("workspace-a", &initial).unwrap(); + assert!(Arc::ptr_eq(&first, &retry)); + assert_eq!(cache.len(), 1); + + let updated = state_at( + 8, + r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#, + ); + let replacement = cache.resolve("workspace-a", &updated).unwrap(); + assert!(!Arc::ptr_eq(&first, &replacement)); + assert_eq!( + replacement.catalog().templates["common.language"], + "UPDATED" + ); + assert_eq!(cache.len(), 1); + assert_ne!(first.catalog().templates["common.language"], "UPDATED"); + + let other_workspace = cache.resolve("workspace-b", &updated).unwrap(); + assert!(!Arc::ptr_eq(&replacement, &other_workspace)); + assert_eq!(cache.len(), 2); + } + + #[test] + fn prompt_projection_cache_does_not_let_stale_revision_evict_active_entry() { + let cache = WorkspacePromptProjectionCache::default(); + let current = state_at( + 8, + r#"{ prompts = { common = { language = "CURRENT"; }; }; }"#, + ); + let stale = state_at(7, "{}"); + + let current_catalog = cache.resolve("workspace-a", ¤t).unwrap(); + let stale_catalog = cache.resolve("workspace-a", &stale).unwrap(); + let current_retry = cache.resolve("workspace-a", ¤t).unwrap(); + + assert_eq!(stale_catalog.catalog().config_revision, 7); + assert!(Arc::ptr_eq(¤t_catalog, ¤t_retry)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn prompt_projection_cache_rejects_same_revision_reinterpretation() { + let cache = WorkspacePromptProjectionCache::default(); + let first = state_at(7, "{}"); + let changed = state_at( + 7, + r#"{ prompts = { common = { language = "CHANGED"; }; }; }"#, + ); + + cache.resolve("workspace-a", &first).unwrap(); + let error = cache.resolve("workspace-a", &changed).unwrap_err(); + assert!( + error + .to_string() + .contains("without a config revision transition") + ); + assert_eq!(cache.len(), 1); + } + + #[test] + fn prompt_projection_cache_post_init_rejects_concurrent_same_revision_identity() { + let cache = WorkspacePromptProjectionCache::default(); + let first = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); + let conflicting = PromptProjectionCacheKey::new( + "workspace-a", + &state_at( + 7, + r#"{ prompts = { common = { language = "CONFLICT"; }; }; }"#, + ), + ); + let first_cell = Arc::new(PromptProjectionCell::new()); + let conflicting_cell = Arc::new(PromptProjectionCell::new()); + { + let mut state = cache.lock().unwrap(); + state.entries.insert(first.clone(), first_cell.clone()); + state + .entries + .insert(conflicting.clone(), conflicting_cell.clone()); + } + + cache + .record_resolved("workspace-a", &first, &first_cell) + .unwrap(); + let error = cache + .record_resolved("workspace-a", &conflicting, &conflicting_cell) + .unwrap_err(); + assert!( + error + .to_string() + .contains("without a config revision transition") + ); + let state = cache.lock().unwrap(); + assert_eq!(state.active["workspace-a"], first); + assert!(!state.entries.contains_key(&conflicting)); + } + + #[test] + fn prompt_projection_cache_keeps_newer_inflight_entry_when_older_finishes_first() { + let cache = WorkspacePromptProjectionCache::default(); + let older = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); + let newer = PromptProjectionCacheKey::new( + "workspace-a", + &state_at(8, r#"{ prompts = { common = { language = "NEW"; }; }; }"#), + ); + let older_cell = Arc::new(PromptProjectionCell::new()); + let newer_cell = Arc::new(PromptProjectionCell::new()); + { + let mut state = cache.lock().unwrap(); + state.entries.insert(older.clone(), older_cell.clone()); + state.entries.insert(newer.clone(), newer_cell.clone()); + } + + cache + .record_resolved("workspace-a", &older, &older_cell) + .unwrap(); + assert!(cache.lock().unwrap().entries.contains_key(&newer)); + + cache + .record_resolved("workspace-a", &newer, &newer_cell) + .unwrap(); + let state = cache.lock().unwrap(); + assert_eq!(state.active["workspace-a"], newer); + assert_eq!(state.entries.len(), 1); + assert!(state.entries.contains_key(&newer)); + } + + #[test] + fn prompt_projection_cache_single_flights_concurrent_resolve() { + let cache = WorkspacePromptProjectionCache::default(); + let state = Arc::new(state("{}")); + let mut threads = Vec::new(); + for _ in 0..8 { + let cache = cache.clone(); + let state = state.clone(); + threads.push(std::thread::spawn(move || { + cache.resolve("workspace-a", &state).unwrap() + })); + } + + let catalogs = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + let first = &catalogs[0]; + assert!(catalogs.iter().all(|catalog| Arc::ptr_eq(first, catalog))); + assert_eq!(cache.len(), 1); + } + #[test] fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() { let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6034bee1..7f44904d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -256,6 +256,7 @@ pub struct WorkspaceApi { pub(crate) store: Arc, config_store: Arc, config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, + prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, runtime: Arc, companion: Arc, @@ -806,6 +807,8 @@ impl WorkspaceApi { let api = Self { config_store, config_schema_registry, + prompt_projection_cache: + crate::prompt_settings::WorkspacePromptProjectionCache::default(), authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -5243,12 +5246,13 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { else { return; }; - let Ok(projection) = - crate::prompt_settings::project_prompts_from_workspace_config(&config_state) + let Ok(projection) = api + .prompt_projection_cache + .resolve(&api.config.workspace_id, &config_state) else { return; }; - let Ok(catalog) = worker::PromptCatalog::from_projection(projection) else { + let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else { return; }; let content = match catalog.render_serializable( @@ -9005,13 +9009,18 @@ async fn create_workspace_worker( "profile must be selected from Backend-published worker profile candidates", ) })?; - 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 prompt_catalog = api + .prompt_projection_cache + .resolve(&api.config.workspace_id, &config_state)?; + let resolved_config_bundle = + crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( + &profile_projection, + &config_state, + &api.config.workspace_id, + &api.config.workspace_created_at, + &profile, + prompt_catalog.as_ref(), + )?; let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name",