feat: integrate value-based Workspace Profiles
This commit is contained in:
@@ -140,7 +140,11 @@ pub(crate) fn validate_workspace_config_candidate_projections(
|
||||
};
|
||||
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||
if has_provider("builtin:profile") {
|
||||
crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?;
|
||||
crate::profile_settings::project_profiles_from_evaluation(
|
||||
workspace_id,
|
||||
&state,
|
||||
&candidate.evaluation,
|
||||
)?;
|
||||
}
|
||||
if has_provider("builtin:runtime") {
|
||||
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
||||
use config_source::ConfigSchemaContribution;
|
||||
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
||||
use serde::Deserialize;
|
||||
use server_api::{
|
||||
@@ -21,24 +21,19 @@ use crate::config_source::{
|
||||
use crate::store::WorkspaceRecord;
|
||||
use crate::{Error, Result};
|
||||
|
||||
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 [];
|
||||
};
|
||||
}"#;
|
||||
const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl");
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProfileConfigSchemaProvider;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE)
|
||||
ConfigSchemaContribution::new("builtin:profile", "profile", "2", PROFILE_SCHEMA_SOURCE)
|
||||
.map(|schema| {
|
||||
schema.with_authoring_source(include_str!(
|
||||
"../../../resources/config-schema/profile-authoring.dcdl"
|
||||
))
|
||||
})
|
||||
.map_err(|error| Error::Config(error.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -59,7 +54,7 @@ struct VirtualProfileSection {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct VirtualProfileEntry {
|
||||
selector: String,
|
||||
source: String,
|
||||
profile: serde_json::Value,
|
||||
label: String,
|
||||
description: String,
|
||||
}
|
||||
@@ -68,7 +63,6 @@ struct VirtualProfileEntry {
|
||||
pub struct ProfileConfigProjection {
|
||||
pub settings: ProfileSettingsResponse,
|
||||
entries: BTreeMap<String, VirtualProfileEntry>,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn project_profiles_from_workspace_config(
|
||||
@@ -96,23 +90,42 @@ pub fn project_profiles_from_workspace_config(
|
||||
"Profile projection digest mismatch for Workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
project_profiles_from_evaluation(workspace_id, state, &evaluation)
|
||||
}
|
||||
|
||||
pub(crate) fn project_profiles_from_evaluation(
|
||||
workspace_id: &str,
|
||||
state: &WorkspaceConfigState,
|
||||
evaluation: &config_source::EvaluationResult,
|
||||
) -> Result<ProfileConfigProjection> {
|
||||
if evaluation.projection_digest != state.projection_digest {
|
||||
return Err(Error::Config(
|
||||
"Profile evaluation does not match Workspace config state".into(),
|
||||
));
|
||||
}
|
||||
let projected = evaluation.projections.first().ok_or_else(|| {
|
||||
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
|
||||
})?;
|
||||
if projected
|
||||
.data_json
|
||||
.pointer("/profile/entries")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|entries| entries.iter().any(|entry| entry.get("source").is_some()))
|
||||
{
|
||||
return Err(profile_validation_error(
|
||||
"profile_source_registration_removed",
|
||||
"profile.entries.source is no longer supported: replace source = \"profiles/name.dcdl\" with profile = import \"./profiles/name.dcdl\" (relative to the containing config file). Saved config is not rewritten.",
|
||||
));
|
||||
}
|
||||
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::<BTreeMap<_, _>>();
|
||||
let mut source_summaries = Vec::new();
|
||||
for entry in config.profile.entries {
|
||||
if !entry.selector.starts_with("project:") {
|
||||
if !entry.selector.starts_with("project:")
|
||||
|| entry.selector.trim_start_matches("project:").is_empty()
|
||||
{
|
||||
return Err(profile_validation_error(
|
||||
"profile_selector_invalid",
|
||||
"Workspace Profile selectors must use project:*",
|
||||
@@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config(
|
||||
"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)?,
|
||||
entry.profile.clone(),
|
||||
ProfileSource::Archive {
|
||||
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
|
||||
source: archive_source.clone(),
|
||||
source: entry.selector.clone(),
|
||||
},
|
||||
Path::new("/"),
|
||||
"workspace-config-validation",
|
||||
)
|
||||
.map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?;
|
||||
.map_err(|error| profile_validation_error("profile_value_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()),
|
||||
profile_source_id: Some(entry.selector.clone()),
|
||||
description: (!entry.description.is_empty()).then(|| entry.description.clone()),
|
||||
editable: false,
|
||||
is_default: config.profile.default_profile == entry.selector,
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes");
|
||||
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(),
|
||||
profile_source_id: entry.selector.clone(),
|
||||
display_path: format!("profile.entries[{}].profile", source_summaries.len()),
|
||||
kind: "evaluated_config".to_string(),
|
||||
content_type: "application/json".to_string(),
|
||||
content_digest: config_source::digest_bytes(&value_bytes),
|
||||
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
|
||||
editable: false,
|
||||
revision: state.snapshot.revision.to_string(),
|
||||
size_bytes: source_entry.content.len() as u64,
|
||||
size_bytes: value_bytes.len() as u64,
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
entries.insert(entry.selector.clone(), entry);
|
||||
@@ -198,46 +193,16 @@ pub fn project_profiles_from_workspace_config(
|
||||
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),
|
||||
projection_digest: Some(evaluation.projection_digest.clone()),
|
||||
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<serde_json::Value> {
|
||||
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,
|
||||
@@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
||||
selector: &str,
|
||||
prompt_projection: &worker::WorkspacePromptProjection,
|
||||
) -> Result<Option<ConfigBundle>> {
|
||||
if projection.settings.workspace_id != workspace_id
|
||||
|| projection.settings.config_revision != Some(state.snapshot.revision)
|
||||
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|
||||
|| projection.settings.projection_digest.as_deref()
|
||||
!= Some(state.projection_digest.as_str())
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"Profile projection does not match Workspace config state".into(),
|
||||
));
|
||||
}
|
||||
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
|
||||
let prompt_catalog = prompt_projection.catalog().clone();
|
||||
let profile_selector =
|
||||
@@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
||||
))
|
||||
})?;
|
||||
let archive = match projection.entries.get(selector) {
|
||||
Some(entry) => Some(build_virtual_profile_archive(
|
||||
selector,
|
||||
entry,
|
||||
&projection.sources,
|
||||
state,
|
||||
)?),
|
||||
Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?),
|
||||
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
|
||||
};
|
||||
let bundle_id = virtual_profile_bundle_id(
|
||||
@@ -492,19 +462,14 @@ pub(crate) fn resolve_profile_manifest_from_config_bundle(
|
||||
fn build_virtual_profile_archive(
|
||||
selector: &str,
|
||||
entry: &VirtualProfileEntry,
|
||||
all_sources: &BTreeMap<String, String>,
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<ProfileSourceArchive> {
|
||||
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()))
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
format!("workspace-config-profile-r{}", state.snapshot.revision),
|
||||
selector.to_string(),
|
||||
entry.profile.clone(),
|
||||
)
|
||||
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
|
||||
}
|
||||
|
||||
pub fn workspace_metadata_settings(
|
||||
@@ -576,124 +541,6 @@ fn profile_validation_error(code: impl Into<String>, message: impl Into<String>)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_profile_import_closure(
|
||||
current_path: &str,
|
||||
all_sources: &BTreeMap<String, String>,
|
||||
closure_sources: &mut BTreeMap<String, String>,
|
||||
imports: &mut BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if closure_sources.contains_key(current_path) {
|
||||
return Ok(());
|
||||
}
|
||||
let content = all_sources.get(current_path).ok_or_else(|| {
|
||||
profile_validation_error(
|
||||
"profile_source_import_missing",
|
||||
&format!("Profile source import closure is missing {current_path}"),
|
||||
)
|
||||
})?;
|
||||
closure_sources.insert(current_path.to_string(), content.clone());
|
||||
for specifier in collect_decodal_import_specifiers(content) {
|
||||
let target = resolve_profile_source_import(current_path, &specifier)?;
|
||||
if !all_sources.contains_key(&target) {
|
||||
return Err(profile_validation_error(
|
||||
"profile_source_import_missing",
|
||||
&format!(
|
||||
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
|
||||
),
|
||||
));
|
||||
}
|
||||
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
|
||||
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
|
||||
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
|
||||
return Err(profile_validation_error(
|
||||
"profile_source_import_invalid",
|
||||
"Profile source import must be a virtual relative path",
|
||||
));
|
||||
}
|
||||
let raw = specifier
|
||||
.strip_prefix("project:")
|
||||
.or_else(|| specifier.strip_prefix("workspace:"))
|
||||
.unwrap_or(specifier);
|
||||
if specifier.contains(':') && raw == specifier {
|
||||
return Err(profile_validation_error(
|
||||
"profile_source_import_invalid",
|
||||
"Unsupported profile source import namespace",
|
||||
));
|
||||
}
|
||||
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
|
||||
PathBuf::from(raw)
|
||||
} else {
|
||||
Path::new(current_path)
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default()
|
||||
.join(raw)
|
||||
};
|
||||
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
|
||||
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<String> {
|
||||
let mut specifiers = Vec::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//") || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some(index) = trimmed.find("import") else {
|
||||
continue;
|
||||
};
|
||||
let after = trimmed[index + "import".len()..].trim_start();
|
||||
let Some(first) = after.chars().next() else {
|
||||
continue;
|
||||
};
|
||||
if first == '"' || first == '\'' {
|
||||
if let Some(end) = after[1..].find(first) {
|
||||
specifiers.push(after[1..1 + end].to_string());
|
||||
}
|
||||
} else {
|
||||
let ident: String = after
|
||||
.chars()
|
||||
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
|
||||
.collect();
|
||||
if !ident.is_empty() {
|
||||
specifiers.push(ident);
|
||||
}
|
||||
}
|
||||
}
|
||||
specifiers
|
||||
}
|
||||
|
||||
pub fn selector_for_builtin_candidate(
|
||||
id: &str,
|
||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||
@@ -711,6 +558,7 @@ pub fn selector_for_builtin_candidate(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{ConfigContentType, VirtualPath};
|
||||
|
||||
#[test]
|
||||
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
|
||||
@@ -779,7 +627,7 @@ mod tests {
|
||||
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"; }]; }; }"#,
|
||||
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
|
||||
)
|
||||
.unwrap(),
|
||||
config_source::ConfigEntry::new(
|
||||
@@ -819,18 +667,18 @@ mod tests {
|
||||
.entrypoints
|
||||
.get("project:alpha")
|
||||
.map(String::as_str),
|
||||
Some("profiles/alpha.dcdl")
|
||||
Some("profiles/evaluated.json")
|
||||
);
|
||||
assert_eq!(archive.reference.source_graph.source_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_config_projection_preserves_import_closure() {
|
||||
fn virtual_config_projection_materializes_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"; }]; }; }"#,
|
||||
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#,
|
||||
)
|
||||
.unwrap(),
|
||||
config_source::ConfigEntry::new(
|
||||
@@ -857,8 +705,8 @@ mod tests {
|
||||
.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);
|
||||
assert_eq!(archive.reference.source_graph.source_count, 1);
|
||||
assert_eq!(archive.reference.source_graph.import_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -955,8 +803,8 @@ mod tests {
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
r#"{ profile = { entries = [
|
||||
{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; },
|
||||
{ selector = "project:beta"; source = "profiles/beta.dcdl"; },
|
||||
{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; },
|
||||
{ selector = "project:beta"; profile = import "./profiles/beta.dcdl"; },
|
||||
]; }; }"#,
|
||||
)
|
||||
.unwrap(),
|
||||
@@ -1002,20 +850,83 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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")
|
||||
fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() {
|
||||
let bundle =
|
||||
config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
|
||||
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
|
||||
).unwrap()]).unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(snapshot.clone())
|
||||
.with_schema_bundle(bundle.clone());
|
||||
for (body, token, label) in [
|
||||
(
|
||||
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
|
||||
"sel",
|
||||
"selector",
|
||||
),
|
||||
(
|
||||
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
|
||||
"pro",
|
||||
"profile",
|
||||
),
|
||||
(
|
||||
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
|
||||
"wor",
|
||||
"worker",
|
||||
),
|
||||
(
|
||||
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
|
||||
"mo",
|
||||
"mode",
|
||||
),
|
||||
(
|
||||
"{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema",
|
||||
"ti",
|
||||
"ticket",
|
||||
),
|
||||
] {
|
||||
let offset = body.rfind(token).unwrap() + token.len();
|
||||
let completion = environment
|
||||
.complete_config(
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
body,
|
||||
offset,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
completion.items.iter().any(|item| item.label == label),
|
||||
"{label}: {completion:?}"
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
|
||||
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#,
|
||||
] {
|
||||
assert!(
|
||||
!environment
|
||||
.analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
let evaluation = environment
|
||||
.evaluate_contract(&config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
evaluation.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile"),
|
||||
Some(&serde_json::json!({}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12321,6 +12321,7 @@ fn config_schema_bundle_to_api(
|
||||
namespace: contribution.namespace,
|
||||
version: contribution.version,
|
||||
source: contribution.source,
|
||||
authoring_source: contribution.authoring_source,
|
||||
projection_validator: contribution
|
||||
.projection_validator
|
||||
.map(config_projection_validator_to_api),
|
||||
@@ -31976,6 +31977,7 @@ impl IntoResponse for ApiError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod subjektiv_jobs_tests;
|
||||
mod value_profiles_tests;
|
||||
mod worker_operations_tests;
|
||||
include!("server_workspace_config_tests.rs");
|
||||
use super::*;
|
||||
|
||||
@@ -454,7 +454,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
|
||||
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
|
||||
changes: vec![server_api::ConfigTreeChange::Update {
|
||||
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
|
||||
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; source = \"profiles/job-recipe.dcdl\"; }]; }; }".into(),
|
||||
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; profile = import \"./profiles/job-recipe.dcdl\"; }]; }; }".into(),
|
||||
}, server_api::ConfigTreeChange::Create {
|
||||
path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal,
|
||||
content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(),
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
use super::*;
|
||||
use crate::profile_settings::{
|
||||
build_virtual_profile_config_bundle, project_profiles_from_workspace_config,
|
||||
};
|
||||
|
||||
const PROFILE: &str = r#"{
|
||||
slug = "alpha";
|
||||
worker = { mode = "wip"; };
|
||||
model = { ref = "codex-oauth/gpt-5.6-sol"; };
|
||||
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
|
||||
}"#;
|
||||
|
||||
fn request(
|
||||
api: &WorkspaceApi,
|
||||
source: &str,
|
||||
extra: Vec<config_source::ConfigTreeChange>,
|
||||
) -> ConfigCommitRequest {
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main = state
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
|
||||
.unwrap();
|
||||
let mut changes = vec![config_source::ConfigTreeChange::Update {
|
||||
path: main.path.clone(),
|
||||
expected_digest: main.content_digest.clone(),
|
||||
content: source.into(),
|
||||
}];
|
||||
changes.extend(extra);
|
||||
ConfigCommitRequest {
|
||||
base_revision: state.snapshot.revision,
|
||||
base_digest: state.snapshot.digest,
|
||||
entrypoints: state.contract.entrypoints,
|
||||
changes,
|
||||
}
|
||||
}
|
||||
|
||||
fn source(value: &str) -> String {
|
||||
format!(
|
||||
r#"{{ profile = {{ default_profile = "project:alpha"; entries = [{{ selector = "project:alpha"; label = "Alpha"; description = "Value recipe"; profile = {value}; }}]; }}; }}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let forms = [
|
||||
PROFILE.to_string(),
|
||||
r#"import "./recipes/alpha.dcdl""#.into(),
|
||||
r#"(import "./recipes/base.dcdl") // { worker = { mode = "wip"; }; feature.task.enabled = true; }"#.into(),
|
||||
];
|
||||
let mut manifests = Vec::new();
|
||||
let mut saved_manifest = None;
|
||||
for (index, form) in forms.iter().enumerate() {
|
||||
let extra = if index == 0 {
|
||||
vec![
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: PROFILE.into(),
|
||||
},
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipes/base.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: PROFILE
|
||||
.replace("wip", "tools")
|
||||
.replace("enabled = true", "enabled = false"),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let state = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &source(form), extra),
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded, state);
|
||||
// The Browser receives the same fingerprint-bound authoring metadata through REST.
|
||||
let wire = workspace_config_state_to_api(loaded.clone());
|
||||
let decoded: config_source::WorkspaceConfigSchemaBundle =
|
||||
serde_json::from_value(serde_json::to_value(wire.contract.schema_bundle).unwrap())
|
||||
.unwrap();
|
||||
decoded.validate().unwrap();
|
||||
assert_eq!(decoded, loaded.contract.schema_bundle);
|
||||
assert!(
|
||||
decoded
|
||||
.contributions
|
||||
.iter()
|
||||
.find(|entry| entry.provider_id == "builtin:profile")
|
||||
.unwrap()
|
||||
.authoring_source
|
||||
.is_some()
|
||||
);
|
||||
let projection =
|
||||
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &loaded).unwrap();
|
||||
assert_eq!(
|
||||
projection.settings.default_profile.as_deref(),
|
||||
Some("project:alpha")
|
||||
);
|
||||
assert_eq!(
|
||||
projection.settings.config_revision,
|
||||
Some(state.snapshot.revision)
|
||||
);
|
||||
let selected = projection
|
||||
.settings
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|entry| entry.selector == "project:alpha")
|
||||
.unwrap();
|
||||
assert_eq!(selected.label, "Alpha");
|
||||
assert_eq!(selected.description.as_deref(), Some("Value recipe"));
|
||||
assert!(selected.is_default);
|
||||
assert_eq!(selected.profile_source_id.as_deref(), Some("project:alpha"));
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"2026-01-01T00:00:00Z",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
|
||||
assert_eq!(
|
||||
bundle.metadata.revision,
|
||||
state.snapshot.revision.to_string()
|
||||
);
|
||||
let archive = bundle.profile_source_archive.as_ref().unwrap();
|
||||
let verified = archive.verify().unwrap();
|
||||
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
|
||||
let resolved = verified
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"value-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(resolved.feature.task.enabled);
|
||||
assert!(!resolved.feature.ticket.enabled);
|
||||
assert!(!resolved.feature.ticket.authoring);
|
||||
assert!(!resolved.feature.sub_worker.enabled);
|
||||
assert_eq!(archive.reference.source_graph.import_count, 0);
|
||||
let evaluation = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.evaluate_contract(&state.contract)
|
||||
.unwrap();
|
||||
let value = evaluation.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile")
|
||||
.unwrap();
|
||||
assert!(value.is_object());
|
||||
assert!(
|
||||
value.get("engine").is_none(),
|
||||
"omission must remain omission in the Profile value"
|
||||
);
|
||||
let mut snapshot = serde_json::to_value(&resolved).unwrap();
|
||||
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
|
||||
manifests.push(snapshot);
|
||||
if index == 0 {
|
||||
saved_manifest =
|
||||
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap());
|
||||
}
|
||||
if index == 2 {
|
||||
let mut other_state = state.clone();
|
||||
other_state.snapshot.revision += 1;
|
||||
assert!(
|
||||
build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&other_state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha"
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("does not match")
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(manifests[0], manifests[1]);
|
||||
assert_eq!(manifests[1], manifests[2]);
|
||||
// Saving a new recipe is not an operation on existing Workers or their persisted Manifest.
|
||||
let state = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(
|
||||
&api,
|
||||
&source(
|
||||
&PROFILE
|
||||
.replace("wip", "tools")
|
||||
.replace("enabled = true", "enabled = false"),
|
||||
),
|
||||
vec![],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let fresh = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
|
||||
&bundle,
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fresh.worker.mode, manifest::WorkerMode::Tools);
|
||||
assert!(!fresh.feature.task.enabled);
|
||||
let restored =
|
||||
manifest::read_persisted_worker_manifest_snapshot(saved_manifest.unwrap()).unwrap();
|
||||
assert_eq!(restored.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(restored.feature.task.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let scalar = format!(
|
||||
r#"{PROFILE} // {{ scope = "workspace_read"; delegation_scope = "workspace_write"; }}"#
|
||||
);
|
||||
let table = format!(
|
||||
r#"{PROFILE} // {{ scope = {{ intent = "workspace_read"; }}; delegation_scope = {{ intent = "workspace_write"; }}; }}"#
|
||||
);
|
||||
let mut scopes = Vec::new();
|
||||
for (index, form) in [scalar.clone(), table, r#"import "./scoped.dcdl""#.into()]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let extra = if index == 0 {
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("scoped.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: scalar.clone(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let authored = format!("{} as WorkspaceConfigSchema", source(form));
|
||||
let state =
|
||||
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &authored, extra))
|
||||
.unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.with_schema_bundle(state.contract.schema_bundle.clone());
|
||||
assert!(
|
||||
environment
|
||||
.analyze(
|
||||
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
|
||||
None
|
||||
)
|
||||
.is_empty(),
|
||||
"{authored}"
|
||||
);
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let resolved = bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.verify()
|
||||
.unwrap()
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"scoped-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.scope.allow.len(), 1);
|
||||
assert_eq!(resolved.delegation_scope.allow.len(), 1);
|
||||
let scope = serde_json::to_value(&resolved.scope).unwrap();
|
||||
let delegation = serde_json::to_value(&resolved.delegation_scope).unwrap();
|
||||
assert_eq!(scope["allow"][0]["target"], "/runtime-worker");
|
||||
assert_eq!(scope["allow"][0]["permission"], "read");
|
||||
assert_eq!(delegation["allow"][0]["target"], "/runtime-worker");
|
||||
assert_eq!(delegation["allow"][0]["permission"], "write");
|
||||
scopes.push((scope, delegation));
|
||||
}
|
||||
assert_eq!(scopes[0], scopes[1]);
|
||||
assert_eq!(scopes[1], scopes[2]);
|
||||
}
|
||||
|
||||
fn profile_fixture_decodal(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => format!(
|
||||
"{{ {} }}",
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key} = {};", profile_fixture_decodal(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
serde_json::Value::Array(values) => format!(
|
||||
"[{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(profile_fixture_decodal)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
serde_json::Value::Null => panic!("fixture omission must not be represented as null"),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_authoring_corpus_preserves_effective_runtime_settings() {
|
||||
let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!(
|
||||
"../../../../../resources/config-schema/profile-authoring-values.json"
|
||||
))
|
||||
.unwrap();
|
||||
// The canonical token fixture must exercise every serializable setting,
|
||||
// so adding a field to CompactionConfigPartial cannot silently outgrow the
|
||||
// editor-shape coverage. Aliases/ratio helpers have separate corpus cases.
|
||||
let default = serde_json::to_value(manifest::CompactionConfigPartial::default()).unwrap();
|
||||
let canonical = cases
|
||||
.iter()
|
||||
.find(|case| case["name"] == "canonical_compaction_tokens")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
default
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
canonical["profile"]["compaction"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|name| name.as_str() != "kind")
|
||||
.collect()
|
||||
);
|
||||
for case in cases {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let recipe = profile_fixture_decodal(&case["profile"]);
|
||||
for (index, (form, patched)) in [
|
||||
(recipe.clone(), false),
|
||||
(r#"import "./recipe.dcdl""#.into(), false),
|
||||
(
|
||||
r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(),
|
||||
true,
|
||||
),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let extra = if index == 0 {
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipe.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: recipe.clone(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let authored = format!("{} as WorkspaceConfigSchema", source(form));
|
||||
let state = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &authored, extra),
|
||||
)
|
||||
.unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.with_schema_bundle(state.contract.schema_bundle.clone());
|
||||
let diagnostics = environment.analyze(
|
||||
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
|
||||
None,
|
||||
);
|
||||
assert!(diagnostics.is_empty(), "{}: {diagnostics:?}", case["name"]);
|
||||
let mut expected = case["profile"].clone();
|
||||
if *patched {
|
||||
expected["description"] = serde_json::json!("patched");
|
||||
}
|
||||
let evaluated = environment.evaluate_contract(&state.contract).unwrap();
|
||||
assert_eq!(
|
||||
evaluated.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile"),
|
||||
Some(&expected)
|
||||
);
|
||||
let projection =
|
||||
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let resolved = bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.verify()
|
||||
.unwrap()
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"corpus-worker",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = serde_json::to_value(resolved).unwrap();
|
||||
for (pointer, value) in case["manifest_expect"].as_object().unwrap() {
|
||||
assert_eq!(
|
||||
manifest.pointer(pointer),
|
||||
Some(value),
|
||||
"{}: {pointer}",
|
||||
case["name"]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_invalid_saves_preserve_the_active_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let before = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &source(PROFILE), vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
let invalid = [
|
||||
(source("42"), ""),
|
||||
(source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ worker = { name = "runtime-only"; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = 42; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = "not-an-intent"; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ delegation_scope = { intent = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = { intent = "workspace_read"; unknown = true; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = { intent = "workspace_read"; deny_write = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ delegation_scope = { intent = "workspace_write"; symlink_policy = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"import "./missing.dcdl""#), ""),
|
||||
(source(r#"import "../outside.dcdl""#), ""),
|
||||
(r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }, { selector = "project:alpha"; profile = {}; }]; }; }"#.into(), "profile_selector_duplicate"),
|
||||
(r#"{ profile = { default_profile = "project:unknown"; }; }"#.into(), "unknown_default_profile"),
|
||||
(r#"{ profile = { entries = [{ selector = "project:"; profile = {}; }]; }; }"#.into(), "profile_selector_invalid"),
|
||||
(r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#.into(), ""),
|
||||
];
|
||||
for (source, code) in invalid {
|
||||
let error =
|
||||
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &source, vec![]))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(code), "{error}");
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_legacy_source_config_requires_explicit_lossless_migration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let mut contributions = api.config_schema_registry.compose().unwrap().contributions;
|
||||
let profile_schema = contributions
|
||||
.iter_mut()
|
||||
.find(|entry| entry.provider_id == "builtin:profile")
|
||||
.unwrap();
|
||||
*profile_schema = config_source::ConfigSchemaContribution::new("builtin:profile", "profile", "1", r#"{
|
||||
profile = { default_profile = String default "builtin:companion";
|
||||
entries = [...{ selector = String; source = String; label = String default ""; description = String default ""; }] default [];
|
||||
};
|
||||
}"#).unwrap();
|
||||
let old_schema = config_source::WorkspaceConfigSchemaBundle::compose(contributions).unwrap();
|
||||
let old_source = r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; label = "Alpha"; source = "recipes/alpha.dcdl"; }]; }; }"#;
|
||||
let candidate = api
|
||||
.config_store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(
|
||||
&api,
|
||||
old_source,
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: PROFILE.into(),
|
||||
}],
|
||||
),
|
||||
old_schema,
|
||||
)
|
||||
.unwrap();
|
||||
// Fixture simulates a previously persisted v1 schema/revision.
|
||||
let legacy = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
|
||||
.unwrap();
|
||||
let error = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &legacy).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("profile_source_registration_removed")
|
||||
);
|
||||
assert!(error.to_string().contains("profile = import"));
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
legacy
|
||||
);
|
||||
assert!(
|
||||
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, old_source, vec![]))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
legacy
|
||||
);
|
||||
let migrated_source = old_source.replace(
|
||||
r#"source = "recipes/alpha.dcdl""#,
|
||||
r#"profile = import "./recipes/alpha.dcdl""#,
|
||||
);
|
||||
let migrated = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &migrated_source, vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &migrated).unwrap();
|
||||
assert_eq!(
|
||||
projection.settings.default_profile.as_deref(),
|
||||
Some("project:alpha")
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()),
|
||||
legacy
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
projection
|
||||
.settings
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|entry| entry.selector == "project:alpha")
|
||||
.unwrap()
|
||||
.label,
|
||||
"Alpha"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user