feat: integrate value-based Workspace Profiles
This commit is contained in:
+995
-27
File diff suppressed because it is too large
Load Diff
@@ -4496,6 +4496,10 @@ pub struct ConfigSchemaContribution {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub projection_validator: Option<ConfigProjectionValidator>,
|
||||
/// Completion-only shape for partial values; never materialized as defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub authoring_source: Option<String>,
|
||||
pub source_digest: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,35 @@ pub struct ProfileSourceArchiveInput {
|
||||
|
||||
impl ProfileSourceArchive {
|
||||
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
|
||||
Self::build_with_source_format(input, "decodal", "text/x-decodal")
|
||||
}
|
||||
|
||||
/// Archive an already evaluated Profile value without Decodal evaluation or conversion.
|
||||
pub fn build_evaluated_profile(
|
||||
id: String,
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
let path = "profiles/evaluated.json".to_string();
|
||||
let source = serde_json::to_string(&value)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
|
||||
Self::build_with_source_format(
|
||||
ProfileSourceArchiveInput {
|
||||
id,
|
||||
entrypoints: BTreeMap::from([(selector, path.clone())]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([(path, source)]),
|
||||
},
|
||||
"json",
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_with_source_format(
|
||||
input: ProfileSourceArchiveInput,
|
||||
kind: &str,
|
||||
content_type: &str,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
if input.sources.len() > MAX_SOURCES {
|
||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||
}
|
||||
@@ -145,8 +174,8 @@ impl ProfileSourceArchive {
|
||||
source_meta.push(ProfileSourceArchiveSource {
|
||||
path: path.clone(),
|
||||
source_key: path.clone(),
|
||||
kind: "decodal".to_string(),
|
||||
content_type: default_decodal_content_type(),
|
||||
kind: kind.to_string(),
|
||||
content_type: content_type.to_string(),
|
||||
digest: sha256_hex(source.as_bytes()),
|
||||
size_bytes: size,
|
||||
});
|
||||
@@ -288,7 +317,17 @@ impl VerifiedProfileSourceArchive {
|
||||
actual: source.source_key.clone(),
|
||||
});
|
||||
}
|
||||
if source.content_type != default_decodal_content_type() {
|
||||
let expected_content_type = match source.kind.as_str() {
|
||||
"decodal" => "text/x-decodal",
|
||||
"json" => "application/json",
|
||||
_ => {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if source.content_type != expected_content_type {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.content_type.clone(),
|
||||
@@ -297,12 +336,6 @@ impl VerifiedProfileSourceArchive {
|
||||
if !manifest_paths.insert(source.path.clone()) {
|
||||
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
|
||||
}
|
||||
if source.kind != "decodal" {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
let content = entries
|
||||
.get(&source.path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
|
||||
@@ -373,30 +406,50 @@ impl VerifiedProfileSourceArchive {
|
||||
.entrypoints
|
||||
.get(selector)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
|
||||
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
||||
let source = self
|
||||
.sources
|
||||
.get(path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||
let metadata = self
|
||||
.manifest
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| &source.path == path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||
let json = match metadata.kind.as_str() {
|
||||
"json" => serde_json::from_str(source)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
|
||||
"decodal" => {
|
||||
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
||||
let module = engine.add_root_source(path, path, source).map_err(|err| {
|
||||
ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
}
|
||||
})?;
|
||||
let value = engine
|
||||
let value =
|
||||
engine
|
||||
.eval_module(module)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let data = engine
|
||||
let data =
|
||||
engine
|
||||
.materialize(&value)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let json = decodal_data_to_json(&data);
|
||||
decodal_data_to_json(&data)
|
||||
}
|
||||
_ => {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: path.clone(),
|
||||
kind: metadata.kind.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
json,
|
||||
ProfileSource::Archive {
|
||||
@@ -674,6 +727,171 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn evaluated_profile() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"slug": "evaluated",
|
||||
"description": "Evaluated profile",
|
||||
"scope": "workspace_read",
|
||||
"worker": { "mode": "wip" },
|
||||
"feature": {
|
||||
"task": { "enabled": true },
|
||||
"memory": { "enabled": true },
|
||||
"sub_worker": { "enabled": true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn evaluated_archive() -> ProfileSourceArchive {
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
"evaluated-archive".to_string(),
|
||||
"project:evaluated".to_string(),
|
||||
evaluated_profile(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_preserves_explicit_worker_mode_and_features() {
|
||||
let archive = evaluated_archive();
|
||||
let verified = archive.verify().unwrap();
|
||||
let source = &verified.manifest().sources[0];
|
||||
assert_eq!(source.kind, "json");
|
||||
assert_eq!(source.content_type, "application/json");
|
||||
assert_eq!(verified.manifest().imports.len(), 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&verified.sources[&source.path]).unwrap(),
|
||||
evaluated_profile()
|
||||
);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manifest = verified
|
||||
.resolve_profile("project:evaluated", root.path(), "evaluated-worker")
|
||||
.unwrap();
|
||||
let json = serde_json::to_value(&manifest).unwrap();
|
||||
assert_eq!(manifest.worker.name, "evaluated-worker");
|
||||
assert_eq!(json["worker"]["mode"], "wip");
|
||||
assert_eq!(json["feature"]["task"]["enabled"], true);
|
||||
assert_eq!(json["feature"]["memory"]["profile"]["enabled"], true);
|
||||
assert_eq!(json["feature"]["sub_worker"]["enabled"], true);
|
||||
}
|
||||
|
||||
// Recompute the outer digest so verification reaches the source-level checks.
|
||||
fn repack_archive(
|
||||
archive: &mut ProfileSourceArchive,
|
||||
manifest: &ProfileSourceArchiveManifest,
|
||||
sources: &BTreeMap<String, String>,
|
||||
) {
|
||||
let mut content = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut content);
|
||||
append_bytes(
|
||||
&mut builder,
|
||||
MANIFEST_PATH,
|
||||
&serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for (path, source) in sources {
|
||||
append_bytes(&mut builder, path, source.as_bytes()).unwrap();
|
||||
}
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
archive.reference.digest = sha256_hex(&content);
|
||||
archive.reference.size_bytes = content.len() as u64;
|
||||
archive.content = content;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_reuses_source_size_limit() {
|
||||
assert!(matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
"oversized".to_string(),
|
||||
"default".to_string(),
|
||||
serde_json::Value::String("x".repeat(MAX_SOURCE_BYTES as usize)),
|
||||
),
|
||||
Err(ProfileArchiveError::LimitExceeded("source bytes"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_rejects_archive_and_source_tampering() {
|
||||
let mut archive = evaluated_archive();
|
||||
archive.content[0] ^= 1;
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::ArchiveDigestMismatch { .. })
|
||||
));
|
||||
|
||||
let mut archive = evaluated_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
let path = verified.manifest.sources[0].path.clone();
|
||||
verified
|
||||
.sources
|
||||
.get_mut(&path)
|
||||
.unwrap()
|
||||
.replace_range(0..1, "[");
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::SourceDigestMismatch { path: actual, .. }) if actual == path
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_requires_matching_source_kind_and_content_type() {
|
||||
for (kind, content_type) in [
|
||||
("json", "text/x-decodal"),
|
||||
("decodal", "application/json"),
|
||||
("unknown", "application/json"),
|
||||
] {
|
||||
let mut archive = evaluated_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].kind = kind.to_string();
|
||||
verified.manifest.sources[0].content_type = content_type.to_string();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::UnsupportedSource { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_does_not_fallback_to_filesystem_or_default() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let profiles = root.path().join("profiles");
|
||||
std::fs::create_dir(&profiles).unwrap();
|
||||
let local_profile = profiles.join("evaluated.json");
|
||||
std::fs::write(
|
||||
&local_profile,
|
||||
serde_json::to_vec(&evaluated_profile()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let verified = evaluated_archive().verify().unwrap();
|
||||
for selector in [
|
||||
"default",
|
||||
"profiles/evaluated.json",
|
||||
local_profile.to_str().unwrap(),
|
||||
] {
|
||||
assert!(matches!(
|
||||
verified.resolve_profile(selector, root.path(), "evaluated-worker"),
|
||||
Err(ProfileArchiveError::MissingEntrypoint(actual)) if actual == selector
|
||||
));
|
||||
}
|
||||
|
||||
// A valid local file must not rescue an invalid archived Profile value either.
|
||||
let invalid = ProfileSourceArchive::build_evaluated_profile(
|
||||
"invalid".to_string(),
|
||||
"project:evaluated".to_string(),
|
||||
serde_json::json!({ "not_a_profile_field": true }),
|
||||
)
|
||||
.unwrap()
|
||||
.verify()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
invalid.resolve_profile("project:evaluated", root.path(), "evaluated-worker"),
|
||||
Err(ProfileArchiveError::Profile { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_and_verify_archive() {
|
||||
let archive = sample_archive();
|
||||
|
||||
@@ -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,43 +193,13 @@ 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"; }]; }; }"#,
|
||||
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(),
|
||||
]);
|
||||
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("missing from the active config revision")
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,37 @@ A Profile should not contain runtime-bound fields:
|
||||
|
||||
Those fields depend on one run, one parent, or one machine. Putting them in a reusable Profile makes reuse unsafe.
|
||||
|
||||
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Decodal source archives, or explicit JSON/TOML artifacts. Decodal is the authoring syntax used by the Workspace profile editor and Backend Runtime archive path; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch should not depend on executable profile scripts.
|
||||
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Workspace Config values, or explicit JSON/TOML artifacts. Decodal is the Workspace authoring syntax; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch does not depend on executable profile scripts.
|
||||
|
||||
### Workspace registration and migration
|
||||
|
||||
Register the Profile **value**, not a source pathname:
|
||||
|
||||
```dcdl
|
||||
{
|
||||
profile = {
|
||||
default_profile = "project:my-companion";
|
||||
entries = [{
|
||||
selector = "project:my-companion";
|
||||
label = "My Companion";
|
||||
description = "My reusable Worker recipe";
|
||||
profile = import "./profiles/my-companion.dcdl";
|
||||
}];
|
||||
};
|
||||
} as WorkspaceConfigSchema
|
||||
```
|
||||
|
||||
The imported file can contain `{ worker = { mode = "wip"; }; model = { ref = "codex-oauth/gpt-5.6-sol"; }; feature = { task = { enabled = true; }; }; }`. That record can also be written directly at `entries[0].profile`. Composition uses ordinary Decodal expressions, for example `(import "./profiles/base.dcdl") // { worker = { mode = "wip"; }; }` for an explicit patch, or `&` for compatible refinement. No dedicated Profile file or `profiles/` directory is required. Imports are relative to the **containing virtual config file**, not a host filesystem path; missing imports and virtual-tree escapes are rejected.
|
||||
|
||||
`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. Builtin-value import helpers are not part of this registration contract.
|
||||
|
||||
The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config.
|
||||
|
||||
The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched.
|
||||
|
||||
`scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values.
|
||||
|
||||
Extraction `reasoning` in both `feature.memory` and `feature.subjektiv`, like `engine.reasoning`, accepts either a string effort or a signed integer budget. These union values are also opaque to editor constraints and semantically checked on save. Compaction authoring covers the complete token settings and their supported `compact_*` aliases, plus the ratio helper's `request` / `worker` aliases; values are not renamed or defaulted during Workspace evaluation. The shared test corpus `resources/config-schema/profile-authoring-values.json` proves exact values in native/WASM analysis and effective settings through save/projection/Runtime consumption. Its canonical compaction case must cover every serialized `CompactionConfigPartial` field, making future contract drift visible in tests.
|
||||
|
||||
## Manifests
|
||||
|
||||
@@ -27,7 +57,7 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
|
||||
|
||||
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
|
||||
|
||||
Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers.
|
||||
Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. Builtin Profiles retain their embedded Decodal archive path. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers.
|
||||
|
||||
## Local stdio MCP server declarations
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Review finding severity の入力契約
|
||||
|
||||
T-710 の独立レビュー中、Reviewer が `ReviewMergeRequest` に finding severity `medium` を渡し、HTTP 422 で拒否された。Backend の許可値は `blocker` / `major` / `minor` / `note` で、最初の呼び出しでは verdict が記録されなかった。
|
||||
|
||||
親は committed session と MR thread を確認して未記録であることを確かめ、Reviewer の新しい turn で同じ capability・同じ MR/source に対して `major` を使った訂正を依頼した。訂正は成功し、MR sequence 3 に `request_changes` が記録された。別の保存経路や Ticket comment で verdict を代替しなかった。
|
||||
|
||||
## 改善案
|
||||
|
||||
- finding severity は公開 tool schema でも Backend と同じ enum として示し、受理可能な語彙を入力前に確認できるようにする。
|
||||
- capability を使う操作の検証エラーでは、イベントが未記録であることと、訂正した入力を再送できるかを明確に返す。Reviewer が「一回の呼び出し」と「一回の有効な verdict 記録」を混同し、未記録のまま止まる障壁を減らせる。
|
||||
|
||||
これは observed rejection と訂正成功に基づくフィードバックであり、Backend の review authority を迂回する提案ではない。
|
||||
@@ -1235,6 +1235,13 @@
|
||||
"ConfigSchemaContribution": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"authoring_source": {
|
||||
"description": "Completion-only shape for partial values; never materialized as defaults.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -15474,7 +15481,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da"
|
||||
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
[
|
||||
{
|
||||
"name": "canonical_compaction_tokens",
|
||||
"profile": {
|
||||
"compaction": {
|
||||
"kind": "tokens",
|
||||
"prune_enabled": false,
|
||||
"prune_protected_tokens": 3000,
|
||||
"prune_min_savings": 1000,
|
||||
"threshold": 80000,
|
||||
"request_threshold": 90000,
|
||||
"retained_tokens": 12000,
|
||||
"overview_target_tokens": 1000,
|
||||
"overview_warning_tokens": 2000,
|
||||
"overview_deadline_tokens": 3000,
|
||||
"worker_context_max_tokens": 20000,
|
||||
"finish_warning_remaining_tokens": 1000,
|
||||
"final_reserve_tokens": 2000,
|
||||
"worker_max_turns": 5,
|
||||
"summary_target_tokens": 1000,
|
||||
"summary_max_tokens": 2000,
|
||||
"auto_read_budget_tokens": 500,
|
||||
"result_context_max_tokens": 4000,
|
||||
"model": { "ref": "codex-oauth/gpt-5.6-sol" }
|
||||
}
|
||||
},
|
||||
"manifest_expect": {
|
||||
"/compaction/prune_enabled": false,
|
||||
"/compaction/prune_protected_tokens": 3000,
|
||||
"/compaction/prune_min_savings": 1000,
|
||||
"/compaction/threshold": 80000,
|
||||
"/compaction/request_threshold": 90000,
|
||||
"/compaction/retained_tokens": 12000,
|
||||
"/compaction/overview_target_tokens": 1000,
|
||||
"/compaction/overview_warning_tokens": 2000,
|
||||
"/compaction/overview_deadline_tokens": 3000,
|
||||
"/compaction/worker_context_max_tokens": 20000,
|
||||
"/compaction/finish_warning_remaining_tokens": 1000,
|
||||
"/compaction/final_reserve_tokens": 2000,
|
||||
"/compaction/worker_max_turns": 5,
|
||||
"/compaction/summary_target_tokens": 1000,
|
||||
"/compaction/summary_max_tokens": 2000,
|
||||
"/compaction/auto_read_budget_tokens": 500,
|
||||
"/compaction/result_context_max_tokens": 4000,
|
||||
"/compaction/model/ref": "codex-oauth/gpt-5.6-sol"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compaction_token_aliases_without_kind",
|
||||
"profile": {
|
||||
"compaction": {
|
||||
"compact_threshold": 80000,
|
||||
"compact_request_threshold": 90000,
|
||||
"compact_retained_tokens": 12000,
|
||||
"compact_worker_max_input_tokens": 20000,
|
||||
"compact_worker_max_turns": 5,
|
||||
"compact_auto_read_budget": 500
|
||||
}
|
||||
},
|
||||
"manifest_expect": {
|
||||
"/compaction/threshold": 80000,
|
||||
"/compaction/request_threshold": 90000,
|
||||
"/compaction/retained_tokens": 12000,
|
||||
"/compaction/worker_context_max_tokens": 20000,
|
||||
"/compaction/worker_max_turns": 5,
|
||||
"/compaction/auto_read_budget_tokens": 500
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compaction_ratio_aliases",
|
||||
"profile": {
|
||||
"model": { "context_window": 100000 },
|
||||
"compaction": {
|
||||
"kind": "ratio",
|
||||
"threshold": 0.9,
|
||||
"request": 0.8,
|
||||
"worker": 0.2
|
||||
}
|
||||
},
|
||||
"manifest_expect": {
|
||||
"/compaction/threshold": 90000,
|
||||
"/compaction/request_threshold": 80000,
|
||||
"/compaction/worker_context_max_tokens": 20000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "numeric_extraction_reasoning",
|
||||
"profile": {
|
||||
"engine": { "reasoning": -1 },
|
||||
"feature": {
|
||||
"memory": { "extraction": { "enabled": true, "reasoning": 4096 } },
|
||||
"subjektiv": { "extraction": { "enabled": true, "reasoning": 4096 } }
|
||||
}
|
||||
},
|
||||
"manifest_expect": {
|
||||
"/engine/reasoning": -1,
|
||||
"/feature/memory/profile/extraction/reasoning": 4096,
|
||||
"/feature/subjektiv/profile/extraction/reasoning": 4096
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "string_extraction_reasoning",
|
||||
"profile": {
|
||||
"feature": {
|
||||
"memory": { "extraction": { "enabled": true, "reasoning": "high" } },
|
||||
"subjektiv": {
|
||||
"extraction": { "enabled": true, "reasoning": "medium" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"manifest_expect": {
|
||||
"/feature/memory/profile/extraction/reasoning": "high",
|
||||
"/feature/subjektiv/profile/extraction/reasoning": "medium"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Editor-only shape of partial Profile values, used for completion and constraint
|
||||
# analysis without materialization. Never used as defaults: omitted fields remain
|
||||
# omitted until Manifest resolution.
|
||||
let
|
||||
Flag = { enabled = Bool; };
|
||||
Model = {
|
||||
ref = String; scheme = String; base_url = String; model_id = String;
|
||||
auth = { kind = String; ref = String; file = String; };
|
||||
capability = Unknown; context_window = Int; max_context_window = Int;
|
||||
};
|
||||
Extraction = {
|
||||
# ReasoningControl accepts string effort or signed integer budget.
|
||||
enabled = Bool; model = Model; reasoning = Unknown;
|
||||
threshold = Int; worker_max_turns = Int;
|
||||
};
|
||||
# Profile scope accepts either a string intent or a table. Decodal 0.4 has
|
||||
# no union range; keep this field opaque to constraint analysis and expose
|
||||
# the table only as a completion hint. This shape is never materialized;
|
||||
# actual scope forms, intent values and table fields are checked on save.
|
||||
Scope = Unknown default { intent = String; deny_write = [...String]; symlink_policy = String; };
|
||||
Profile = {
|
||||
slug = String;
|
||||
description = String;
|
||||
worker = { mode = String; };
|
||||
model = Model;
|
||||
engine = {
|
||||
instruction = String; language = String; reasoning = Unknown;
|
||||
max_tokens = Int; max_turns = Int; temperature = Unknown; top_p = Unknown;
|
||||
top_k = Int; stop_sequences = [...String]; tool_call_dispatch = String;
|
||||
tool_output = { default_max_bytes = Int; per_tool = { ...Int }; };
|
||||
file_upload = { max_bytes = Int; };
|
||||
};
|
||||
scope = Scope;
|
||||
delegation_scope = Scope;
|
||||
session = {
|
||||
record_event_trace = Bool;
|
||||
};
|
||||
permissions = { default_action = String; rule = [...{ tool = String; pattern = String; action = String; }]; };
|
||||
feature = {
|
||||
task = Flag; web = Flag; image = Flag; sub_worker = Flag; flow = Flag;
|
||||
worker = { enabled = Bool; direct_spawn = Bool; };
|
||||
workspace_worker_discovery = Flag; objective = Flag; manage_workdir = Flag;
|
||||
workdir_catalog = Flag; workspace_config = Flag; orchestration = Flag;
|
||||
ticket = { enabled = Bool; authoring = Bool; thread = Bool; intake = Bool; workflow = Bool; };
|
||||
merge_request = { show = Bool; open = Bool; review = Bool; readiness_check = Bool; complete = Bool; };
|
||||
memory = {
|
||||
enabled = Bool; staging_tools = Bool;
|
||||
resident = { inject_summary = Bool; };
|
||||
extraction = Extraction;
|
||||
consolidation = { request_enabled = Bool; };
|
||||
};
|
||||
subjektiv = { enabled = Bool; consolidation_tools = Bool; extraction = Extraction; };
|
||||
};
|
||||
mcp = { stdio_server = [...{
|
||||
name = String; command = String; args = [...String];
|
||||
cwd = { kind = String; path = String; };
|
||||
env = { inherit = [...String]; set = { ...Unknown }; };
|
||||
}]; };
|
||||
compaction = {
|
||||
# Token settings (CompactionConfigPartial) and ratio helper fields.
|
||||
# Threshold/context values accept integer tokens or numeric ratios.
|
||||
kind = String; threshold = Unknown; request_threshold = Unknown;
|
||||
worker_context_max_tokens = Unknown; request = Unknown; worker = Unknown;
|
||||
prune_enabled = Bool; prune_protected_tokens = Int; prune_min_savings = Int;
|
||||
retained_tokens = Int;
|
||||
overview_target_tokens = Int; overview_warning_tokens = Int; overview_deadline_tokens = Int;
|
||||
finish_warning_remaining_tokens = Int; final_reserve_tokens = Int;
|
||||
worker_max_turns = Int; summary_target_tokens = Int; summary_max_tokens = Int;
|
||||
auto_read_budget_tokens = Int; result_context_max_tokens = Int; model = Model;
|
||||
# Supported token aliases remain authorable without rewriting recipes.
|
||||
compact_threshold = Int; compact_request_threshold = Int; compact_retained_tokens = Int;
|
||||
compact_worker_max_input_tokens = Int; compact_worker_max_turns = Int; compact_auto_read_budget = Int;
|
||||
};
|
||||
web = {
|
||||
enabled = Bool; allow_private_addresses = Bool;
|
||||
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
|
||||
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
|
||||
};
|
||||
skills = { directories = [...String]; };
|
||||
};
|
||||
in {
|
||||
profile = {
|
||||
default_profile = String;
|
||||
entries = [...{
|
||||
selector = String; profile = Profile; label = String; description = String;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
profile = {
|
||||
default_profile = String default "builtin:companion";
|
||||
entries = [...{
|
||||
selector = String;
|
||||
profile = { ...Unknown };
|
||||
label = String default "";
|
||||
description = String default "";
|
||||
}] default [];
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -237,6 +237,10 @@ export type ConfigSchemaContribution = {
|
||||
version: string;
|
||||
source: string;
|
||||
projection_validator?: ConfigProjectionValidator;
|
||||
/**
|
||||
* Completion-only shape for partial values; never materialized as defaults.
|
||||
*/
|
||||
authoring_source?: string;
|
||||
source_digest: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -187,7 +187,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
||||
const item = record(
|
||||
value,
|
||||
["provider_id", "namespace", "version", "source", "source_digest"],
|
||||
["projection_validator"],
|
||||
["projection_validator", "authoring_source"],
|
||||
);
|
||||
const projection = item.projection_validator === undefined
|
||||
? undefined
|
||||
@@ -197,6 +197,12 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
||||
namespace: boundedString(item.namespace),
|
||||
version: boundedString(item.version),
|
||||
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||
...(item.authoring_source === undefined ? {} : {
|
||||
authoring_source: boundedString(
|
||||
item.authoring_source,
|
||||
MAX_SCHEMA_SOURCE_BYTES,
|
||||
),
|
||||
}),
|
||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
||||
};
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfigProjectionValidator } from "./ConfigProjectionValidator";
|
||||
|
||||
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
|
||||
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, authoring_source?: string | null, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
|
||||
|
||||
@@ -244,3 +244,41 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
|
||||
}
|
||||
assert(message.includes("structured diagnostics"));
|
||||
});
|
||||
|
||||
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => {
|
||||
const body = withSchema("validation");
|
||||
const authoringSource = "shape\n".repeat(6000);
|
||||
const contribution = {
|
||||
...body.contract.schema_bundle.contributions[0],
|
||||
authoring_source: authoringSource,
|
||||
};
|
||||
const withAuthoring = {
|
||||
...body,
|
||||
contract: {
|
||||
...body.contract,
|
||||
schema_bundle: {
|
||||
...body.contract.schema_bundle,
|
||||
contributions: [contribution],
|
||||
},
|
||||
},
|
||||
};
|
||||
const parsed = parseWorkspaceConfigTreeResponse(withAuthoring);
|
||||
assertEquals(
|
||||
parsed.contract.schema_bundle.contributions[0].authoring_source,
|
||||
authoringSource,
|
||||
);
|
||||
assertEquals(parsed.contract.schema_bundle.source, "validation");
|
||||
for (const invalid of [null, 42, {}, "é".repeat(4 * 1024 * 1024) + "x"]) {
|
||||
assertThrows(() =>
|
||||
parseWorkspaceConfigTreeResponse({
|
||||
...withAuthoring,
|
||||
contract: {
|
||||
...body.contract,
|
||||
schema_bundle: {
|
||||
...body.contract.schema_bundle,
|
||||
contributions: [{ ...contribution, authoring_source: invalid }],
|
||||
},
|
||||
},
|
||||
}), ConfigSourceApiError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -381,3 +381,376 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () =>
|
||||
assertEquals(result.from, 2);
|
||||
assertEquals(result.items.some((item) => item.label === "profile"), true);
|
||||
});
|
||||
|
||||
function fixtureDecodal(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(fixtureDecodal).join(", ")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{ ${
|
||||
Object.entries(value).map(([key, value]) =>
|
||||
`${key} = ${fixtureDecodal(value)};`
|
||||
).join(" ")
|
||||
} }`;
|
||||
}
|
||||
if (
|
||||
typeof value === "string" || typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) return JSON.stringify(value);
|
||||
throw new Error("fixture omission must not be represented as null/undefined");
|
||||
}
|
||||
|
||||
Deno.test("generated WASM authors value-based Profiles using the Backend schema without materializing authoring fields", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../../resources/config-schema/profile.dcdl",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const authoring_source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../../resources/config-schema/profile-authoring.dcdl",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const schema = compose_schema_bundle([{
|
||||
provider_id: "builtin:profile",
|
||||
namespace: "profile",
|
||||
version: "2",
|
||||
source,
|
||||
source_digest: await digestText(source),
|
||||
authoring_source,
|
||||
}]) as WorkspaceConfigSchemaBundle;
|
||||
const content =
|
||||
'{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema';
|
||||
const tree: ConfigTreeSnapshot = {
|
||||
revision: 8,
|
||||
digest: "sha256:value-profile-tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
path: "main.dcdl",
|
||||
content_type: "decodal",
|
||||
content,
|
||||
content_digest: await digestText(content),
|
||||
},
|
||||
},
|
||||
};
|
||||
const valueContract = {
|
||||
...contract,
|
||||
entrypoints: ["main.dcdl"],
|
||||
schema_bundle: schema,
|
||||
fingerprint: await toolchainFingerprint(["main.dcdl"], schema),
|
||||
};
|
||||
set_snapshot(tree);
|
||||
set_schema_bundle(schema);
|
||||
const evaluated = evaluate_snapshot(tree, valueContract) as {
|
||||
projections: Array<
|
||||
{ data_json: { profile: { entries: Array<{ profile: unknown }> } } }
|
||||
>;
|
||||
};
|
||||
assertEquals(
|
||||
evaluated.projections[0].data_json.profile.entries[0].profile,
|
||||
{},
|
||||
);
|
||||
for (
|
||||
const [body, token, label] of [
|
||||
[
|
||||
"{ 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 = { ta } } }] } } as WorkspaceConfigSchema",
|
||||
"ta",
|
||||
"task",
|
||||
],
|
||||
]
|
||||
) {
|
||||
const cursor = body.lastIndexOf(token) + token.length;
|
||||
const result = complete_current("main.dcdl", body, cursor, true) as {
|
||||
from: number;
|
||||
items: Array<{ label: string }>;
|
||||
};
|
||||
assertEquals(
|
||||
result.items.some((item) => item.label === label),
|
||||
true,
|
||||
label,
|
||||
);
|
||||
assertEquals(result.from, cursor - token.length);
|
||||
}
|
||||
for (
|
||||
const value of [
|
||||
"{}",
|
||||
"{ worker = {}; }",
|
||||
'{ worker = { mode = "wip"; }; }',
|
||||
"{ feature = { task = {}; }; }",
|
||||
]
|
||||
) {
|
||||
const partialContent = content.replace(
|
||||
"profile = {};",
|
||||
`profile = ${value};`,
|
||||
);
|
||||
assertEquals(
|
||||
analyze_snapshot(tree, "main.dcdl", partialContent),
|
||||
[],
|
||||
value,
|
||||
);
|
||||
}
|
||||
const recipe = "{ worker = { mode = 42; }; }";
|
||||
const analysisTree = {
|
||||
...tree,
|
||||
entries: {
|
||||
...tree.entries,
|
||||
"recipe.dcdl": {
|
||||
path: "recipe.dcdl",
|
||||
content_type: "decodal",
|
||||
content: recipe,
|
||||
content_digest: await digestText(recipe),
|
||||
},
|
||||
},
|
||||
};
|
||||
for (
|
||||
const [value, field, expectedPath] of [
|
||||
["42", "profile", "main.dcdl"],
|
||||
["{ worker = 42; }", "worker", "main.dcdl"],
|
||||
["{ worker = { mode = 42; }; }", "mode", "main.dcdl"],
|
||||
["{ feature = { task = { enabled = 42; }; }; }", "enabled", "main.dcdl"],
|
||||
['{ worker = { typo = "wip"; }; }', "typo", "main.dcdl"],
|
||||
['import "./recipe.dcdl"', "mode", "recipe.dcdl"],
|
||||
[
|
||||
'(import "./recipe.dcdl") // { worker = { mode = 42; }; }',
|
||||
"mode",
|
||||
"main.dcdl",
|
||||
],
|
||||
]
|
||||
) {
|
||||
const invalidContent = content.replace(
|
||||
"profile = {};",
|
||||
`profile = ${value};`,
|
||||
);
|
||||
const diagnostics = analyze_snapshot(
|
||||
analysisTree,
|
||||
"main.dcdl",
|
||||
invalidContent,
|
||||
) as Array<{
|
||||
path: string;
|
||||
revision: number;
|
||||
kind: string;
|
||||
span: { start_byte: number; end_byte: number };
|
||||
message: string;
|
||||
labels: Array<
|
||||
{ span: { start_byte: number; end_byte: number }; message: string }
|
||||
>;
|
||||
}>;
|
||||
assertEquals(diagnostics.length > 0, true, value);
|
||||
const diagnostic = diagnostics[0];
|
||||
assertEquals(diagnostic.path, expectedPath);
|
||||
assertEquals(diagnostic.revision, tree.revision);
|
||||
assertEquals(
|
||||
["constraint_violation", "type_mismatch"].includes(diagnostic.kind),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
diagnostic.message.includes(field) ||
|
||||
diagnostic.labels.some((label) => label.message.includes(field)),
|
||||
true,
|
||||
);
|
||||
// New authoring failures must be anchored in the supplied value, not the
|
||||
// much longer schema source, including relative imported recipes.
|
||||
if (value !== "42") {
|
||||
const length = new TextEncoder().encode(
|
||||
expectedPath === "main.dcdl" ? invalidContent : recipe,
|
||||
).length;
|
||||
assertEquals(
|
||||
diagnostic.span.end_byte > diagnostic.span.start_byte &&
|
||||
diagnostic.span.end_byte <= length,
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
diagnostic.labels.every((label) => label.span.end_byte <= length),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of ["scope", "delegation_scope"]) {
|
||||
for (
|
||||
const [prefix, label] of [["int", "intent"], ["deny", "deny_write"], [
|
||||
"sym",
|
||||
"symlink_policy",
|
||||
]]
|
||||
) {
|
||||
const body =
|
||||
`{ profile.entries = [{ profile.${field} = { ${prefix} } }]; } as WorkspaceConfigSchema`;
|
||||
const cursor = body.lastIndexOf(prefix) + prefix.length;
|
||||
const result = complete_current("main.dcdl", body, cursor, true) as {
|
||||
items: Array<{ label: string }>;
|
||||
};
|
||||
assertEquals(
|
||||
result.items.some((item) => item.label === label),
|
||||
true,
|
||||
`${field}.${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (
|
||||
const [recipe, expected] of [
|
||||
['{ scope = "workspace_read"; delegation_scope = "workspace_write"; }', {
|
||||
scope: "workspace_read",
|
||||
delegation_scope: "workspace_write",
|
||||
}],
|
||||
['{ scope = "workspace_write"; delegation_scope = "workspace_read"; }', {
|
||||
scope: "workspace_write",
|
||||
delegation_scope: "workspace_read",
|
||||
}],
|
||||
[
|
||||
'{ scope = { intent = "workspace_read"; }; delegation_scope = { intent = "workspace_write"; deny_write = ["private"]; symlink_policy = "resolved"; }; }',
|
||||
{
|
||||
scope: { intent: "workspace_read" },
|
||||
delegation_scope: {
|
||||
intent: "workspace_write",
|
||||
deny_write: ["private"],
|
||||
symlink_policy: "resolved",
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const
|
||||
) {
|
||||
for (
|
||||
const form of [
|
||||
recipe,
|
||||
'import "./scoped.dcdl"',
|
||||
'(import "./scoped.dcdl") // { description = "patched"; }',
|
||||
]
|
||||
) {
|
||||
const scopedContent = content.replace(
|
||||
"profile = {};",
|
||||
`profile = ${form};`,
|
||||
);
|
||||
const scopedTree = {
|
||||
...tree,
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
...tree.entries["main.dcdl"],
|
||||
content: scopedContent,
|
||||
content_digest: await digestText(scopedContent),
|
||||
},
|
||||
"scoped.dcdl": {
|
||||
path: "scoped.dcdl",
|
||||
content_type: "decodal",
|
||||
content: recipe,
|
||||
content_digest: await digestText(recipe),
|
||||
},
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
analyze_snapshot(scopedTree, "main.dcdl", undefined),
|
||||
[],
|
||||
form,
|
||||
);
|
||||
const result = evaluate_snapshot(
|
||||
scopedTree,
|
||||
valueContract,
|
||||
) as typeof evaluated;
|
||||
assertEquals(
|
||||
result.projections[0].data_json.profile.entries[0].profile,
|
||||
form.includes("patched")
|
||||
? { ...expected, description: "patched" }
|
||||
: expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
const cases = JSON.parse(
|
||||
await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../../resources/config-schema/profile-authoring-values.json",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
) as Array<{ name: string; profile: Record<string, unknown> }>;
|
||||
for (const fixture of cases) {
|
||||
const recipe = fixtureDecodal(fixture.profile);
|
||||
for (
|
||||
const [form, patched] of [[recipe, false], [
|
||||
'import "./recipe.dcdl"',
|
||||
false,
|
||||
], [
|
||||
'(import "./recipe.dcdl") // { description = "patched"; }',
|
||||
true,
|
||||
]] as const
|
||||
) {
|
||||
const fixtureContent = content.replace(
|
||||
"profile = {};",
|
||||
`profile = ${form};`,
|
||||
);
|
||||
const fixtureTree = {
|
||||
...tree,
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
...tree.entries["main.dcdl"],
|
||||
content: fixtureContent,
|
||||
content_digest: await digestText(fixtureContent),
|
||||
},
|
||||
"recipe.dcdl": {
|
||||
path: "recipe.dcdl",
|
||||
content_type: "decodal",
|
||||
content: recipe,
|
||||
content_digest: await digestText(recipe),
|
||||
},
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
analyze_snapshot(fixtureTree, "main.dcdl", undefined),
|
||||
[],
|
||||
`${fixture.name}: ${form}`,
|
||||
);
|
||||
const result = evaluate_snapshot(
|
||||
fixtureTree,
|
||||
valueContract,
|
||||
) as typeof evaluated;
|
||||
assertEquals(
|
||||
result.projections[0].data_json.profile.entries[0].profile,
|
||||
patched
|
||||
? { ...fixture.profile, description: "patched" }
|
||||
: fixture.profile,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (
|
||||
const [path, prefix, label] of [
|
||||
["compaction", "reta", "retained_tokens"],
|
||||
["compaction", "prune_min", "prune_min_savings"],
|
||||
["compaction", "req", "request"],
|
||||
["compaction", "wor", "worker"],
|
||||
["compaction", "compact_ret", "compact_retained_tokens"],
|
||||
["feature.memory.extraction", "reas", "reasoning"],
|
||||
["feature.subjektiv.extraction", "reas", "reasoning"],
|
||||
]
|
||||
) {
|
||||
const body =
|
||||
`{ profile.entries = [{ profile.${path} = { ${prefix} } }]; } as WorkspaceConfigSchema`;
|
||||
const cursor = body.lastIndexOf(prefix) + prefix.length;
|
||||
const result = complete_current("main.dcdl", body, cursor, true) as {
|
||||
items: Array<{ label: string }>;
|
||||
};
|
||||
assertEquals(
|
||||
result.items.some((item) => item.label === label),
|
||||
true,
|
||||
`${path}.${label}`,
|
||||
);
|
||||
}
|
||||
// Neither completion nor diagnostic shape checking changes the saved value.
|
||||
assertEquals(evaluate_snapshot(tree, valueContract), evaluated);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user