From a6f92104fa6d609bcf8e348bbf232842d90411ca Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 14 Aug 2026 13:45:14 +0900 Subject: [PATCH] server: project Workspace prompts into Worker config bundles --- crates/workspace-server/src/hosts.rs | 2 + crates/workspace-server/src/lib.rs | 1 + .../workspace-server/src/profile_settings.rs | 41 ++-- .../workspace-server/src/prompt_settings.rs | 194 ++++++++++++++++++ crates/workspace-server/src/server.rs | 17 +- 5 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 crates/workspace-server/src/prompt_settings.rs diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 835552a0..847812d1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -3496,6 +3496,7 @@ fn builtin_profile_config_bundle( label: embedded_profile_label(profile), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive, profile_source_archive_handle, } @@ -4436,6 +4437,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 811a3f0a..822afc85 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -15,6 +15,7 @@ pub mod memory_backend; pub mod memory_staging; pub mod observation; pub mod profile_settings; +pub mod prompt_settings; pub mod records; #[cfg(feature = "typescript")] pub use records::ticket_api_typescript; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 9b70da3c..efff65fa 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -254,10 +254,13 @@ pub fn build_virtual_profile_config_bundle( 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 archive = projection + .entries + .get(selector) + .map(|entry| build_virtual_profile_archive(selector, entry, &projection.sources, state)) + .transpose()?; + let profile_selector = selector_for_builtin_candidate(selector) + .unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string())); let bundle = ConfigBundle { metadata: ConfigBundleMetadata { id: format!("workspace-config-profile-r{}", state.snapshot.revision), @@ -274,11 +277,12 @@ pub fn build_virtual_profile_config_bundle( }, }, profiles: vec![ConfigProfileDescriptor { - selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()), + selector: profile_selector, label: Some(selector.to_string()), }], declarations: Vec::new(), - profile_source_archive: Some(archive), + prompt_catalog: Some(crate::prompt_settings::project_prompts_from_workspace_config(state)?), + profile_source_archive: archive, profile_source_archive_handle: None, } .with_computed_digest(); @@ -712,13 +716,26 @@ mod tests { fn virtual_state(entries: Vec) -> WorkspaceConfigState { let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap(); + let schema_bundle = config_source::WorkspaceConfigSchemaBundle::compose([ + ProfileConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap(); + let contract = config_source::ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema_bundle, + ); + let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .unwrap() + .projection_digest; 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, - ), + projection_digest, + contract, snapshot, } } diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs new file mode 100644 index 00000000..fa32f5c5 --- /dev/null +++ b/crates/workspace-server/src/prompt_settings.rs @@ -0,0 +1,194 @@ +use config_source::{ConfigProjectionValidator, ConfigSchemaContribution}; +use worker::{EffectivePromptCatalog, prompt_schema_source}; + +use crate::config_source::{ + WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, +}; +use crate::{Error, Result}; + +#[derive(Debug, Default)] +pub struct PromptConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for PromptConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new( + "builtin:prompts", + "prompts", + "1", + prompt_schema_source().map_err(|error| Error::Config(error.to_string()))?, + ) + .map(|contribution| { + contribution.with_projection_validator( + ConfigProjectionValidator::StaticTemplateCatalog { + namespace: "prompts".to_string(), + key_aliases: std::collections::BTreeMap::from([( + "default_prompt".to_string(), + "default".to_string(), + )]), + }, + ) + }) + .map_err(|error| Error::Config(error.to_string())) + } +} + +pub fn validate_evaluated_prompt_catalog( + evaluation: &config_source::EvaluationResult, +) -> Result<()> { + let projection = evaluation.projections.first().ok_or_else(|| { + Error::InvalidInput("Workspace config produced no active projection".to_string()) + })?; + let prompts = projection.data_json.get("prompts").ok_or_else(|| { + Error::InvalidInput("Workspace config projection has no prompts namespace".to_string()) + })?; + EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview") + .map(|_| ()) + .map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}"))) +} + +pub fn project_prompts_from_workspace_config( + state: &WorkspaceConfigState, +) -> Result { + let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?; + if evaluation.projection_digest != state.projection_digest { + return Err(Error::RegistryInconsistency( + "Prompt projection digest does not match the active Workspace config revision" + .to_string(), + )); + } + let projection = evaluation.projections.first().ok_or_else(|| { + Error::RegistryInconsistency("Workspace config has no active projection".to_string()) + })?; + let prompts = projection.data_json.get("prompts").ok_or_else(|| { + Error::RegistryInconsistency( + "active Workspace config projection has no prompts namespace".to_string(), + ) + })?; + EffectivePromptCatalog::from_projection( + prompts, + state.snapshot.revision, + state.contract.schema_bundle.fingerprint.clone(), + state.contract.fingerprint.clone(), + ) + .map_err(|error| Error::RegistryInconsistency(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, + VirtualPath, WorkspaceConfigSchemaBundle, + }; + + fn state(source: &str) -> WorkspaceConfigState { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries( + 7, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema, + ); + let projection_digest = SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .unwrap() + .projection_digest; + WorkspaceConfigState { + snapshot, + contract, + projection_digest, + } + } + + #[test] + fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() { + let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#); + let catalog = project_prompts_from_workspace_config(&state).unwrap(); + assert_eq!(catalog.config_revision, 7); + assert_eq!(catalog.templates["common.language"], "OVERRIDE"); + assert!(!catalog.templates["common.workspace"].is_empty()); + assert!(catalog.templates["default"].contains("common.workspace")); + } + + #[test] + fn preview_commit_validator_rejects_dynamic_missing_and_cyclic_includes() { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + for source in [ + r#"{ prompts = { common = { language = "{% include target %}"; }; }; }"#, + r#"{ prompts = { common = { language = "{% include \"missing\" %}"; }; }; }"#, + r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#, + ] { + let snapshot = ConfigTreeSnapshot::from_entries( + 0, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema.clone(), + ); + assert!( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .is_err() + ); + } + } + + #[test] + fn closed_prompt_schema_rejects_unknown_and_non_string_leaves() { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + for source in [ + "{ prompts = { common = { unknown = \"bad\"; }; }; }", + "{ prompts = { common = { language = 42; }; }; }", + ] { + let snapshot = ConfigTreeSnapshot::from_entries( + 0, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema.clone(), + ); + assert!( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .is_err() + ); + } + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a93d8f76..d1e95588 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -755,6 +755,7 @@ impl WorkspaceApi { .with_provider(Arc::new( crate::profile_settings::ProfileConfigSchemaProvider, )) + .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider)); config_store.ensure_workspace_config_materialized_with_schema( &config.workspace_id, @@ -2522,13 +2523,13 @@ async fn scoped_preview_workspace_config_tree( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json( - api.config_store.preview_workspace_config_with_schema( - &path.workspace_id, - &request, - api.config_schema_registry.compose()?, - )?, - )) + let candidate = api.config_store.preview_workspace_config_with_schema( + &path.workspace_id, + &request, + api.config_schema_registry.compose()?, + )?; + crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; + Ok(Json(candidate)) } async fn scoped_commit_workspace_config_tree( @@ -2544,6 +2545,7 @@ async fn scoped_commit_workspace_config_tree( &request, api.config_schema_registry.compose()?, )?; + crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; let state = api .config_store .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?; @@ -15256,6 +15258,7 @@ mod tests { label: Some("server-test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, }