feat: register Workspace Profiles as Decodal values (T-710)
Project the evaluated Workspace Profile value into a revision-bound JSON archive without source-path registration or Decodal re-evaluation. Preserve partial-value omissions and explicit overrides, validate before save, and reject legacy registrations with a documented explicit migration.
Fix array-element schema completion and provide fingerprint-bound authoring shapes independently of materialization defaults. Carry metadata through REST, regenerate TypeScript/OpenAPI/WASM artifacts, and exercise save/projection/Runtime consumption and persisted Manifest restore boundaries.
Validation: root cargo check; cargo test -p config-source; cargo test -p worker-runtime; cargo test -p server-api --features typescript; focused profile_settings/value_profiles/archive tests; 39 web config-source/profile API tests; deno task check; cargo fmt --all -- --check; git diff --check HEAD.
Full workspace-server test: 641 passed, one pre-existing ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output assertion failure. The same filter fails at base 3c424fd17d in a separately archived source tree and isolated target directory. Remaining server crate tests pass with only that filter skipped (641 lib + 11 bin); unrelated checker code is unchanged.
This commit is contained in:
+651
-37
@@ -487,6 +487,9 @@ pub struct ConfigSchemaContribution {
|
|||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
// Optional editor-only schema; never used to validate or materialize config.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub authoring_source: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub projection_validator: Option<ConfigProjectionValidator>,
|
pub projection_validator: Option<ConfigProjectionValidator>,
|
||||||
pub source_digest: String,
|
pub source_digest: String,
|
||||||
@@ -529,16 +532,31 @@ impl ConfigSchemaContribution {
|
|||||||
version,
|
version,
|
||||||
source_digest: digest_bytes(source.as_bytes()),
|
source_digest: digest_bytes(source.as_bytes()),
|
||||||
source,
|
source,
|
||||||
|
authoring_source: None,
|
||||||
projection_validator: None,
|
projection_validator: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_authoring_source(mut self, source: impl Into<String>) -> Self {
|
||||||
|
self.authoring_source = Some(source.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self {
|
pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self {
|
||||||
self.projection_validator = Some(validator);
|
self.projection_validator = Some(validator);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), ConfigTreeError> {
|
fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||||
|
if self
|
||||||
|
.authoring_source
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|source| source.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||||
|
"authoring schema source must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let expected = digest_bytes(self.source.as_bytes());
|
let expected = digest_bytes(self.source.as_bytes());
|
||||||
if self.source_digest != expected {
|
if self.source_digest != expected {
|
||||||
return Err(ConfigTreeError::InvalidSchemaContribution(format!(
|
return Err(ConfigTreeError::InvalidSchemaContribution(format!(
|
||||||
@@ -591,27 +609,41 @@ impl WorkspaceConfigSchemaBundle {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" & ")
|
.join(" & ")
|
||||||
};
|
};
|
||||||
let fingerprint = digest_bytes(
|
let mut fingerprint_input = serde_json::to_vec(&(
|
||||||
serde_json::to_vec(&(
|
CONFIG_SOURCE_CONTRACT_VERSION,
|
||||||
CONFIG_SOURCE_CONTRACT_VERSION,
|
DECODAL_VERSION,
|
||||||
DECODAL_VERSION,
|
contributions
|
||||||
contributions
|
.iter()
|
||||||
.iter()
|
.map(|contribution| {
|
||||||
.map(|contribution| {
|
(
|
||||||
(
|
contribution.provider_id.as_str(),
|
||||||
contribution.provider_id.as_str(),
|
contribution.namespace.as_str(),
|
||||||
contribution.namespace.as_str(),
|
contribution.version.as_str(),
|
||||||
contribution.version.as_str(),
|
contribution.source_digest.as_str(),
|
||||||
contribution.source_digest.as_str(),
|
contribution.projection_validator.as_ref(),
|
||||||
contribution.projection_validator.as_ref(),
|
)
|
||||||
)
|
})
|
||||||
})
|
.collect::<Vec<_>>(),
|
||||||
.collect::<Vec<_>>(),
|
digest_bytes(source.as_bytes()),
|
||||||
digest_bytes(source.as_bytes()),
|
))
|
||||||
))
|
.expect("schema bundle fingerprint input serializes");
|
||||||
.expect("schema bundle fingerprint input serializes")
|
let authoring_sources = contributions
|
||||||
.as_slice(),
|
.iter()
|
||||||
);
|
.filter_map(|contribution| {
|
||||||
|
contribution
|
||||||
|
.authoring_source
|
||||||
|
.as_deref()
|
||||||
|
.map(|source| (contribution.provider_id.as_str(), source))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
// Preserve legacy fingerprints when no editor-only metadata is present.
|
||||||
|
if !authoring_sources.is_empty() {
|
||||||
|
fingerprint_input.extend(
|
||||||
|
serde_json::to_vec(&("authoring_sources", authoring_sources))
|
||||||
|
.expect("authoring schema fingerprint input serializes"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let fingerprint = digest_bytes(&fingerprint_input);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
contributions,
|
contributions,
|
||||||
source,
|
source,
|
||||||
@@ -619,6 +651,25 @@ impl WorkspaceConfigSchemaBundle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authoring_source(&self) -> String {
|
||||||
|
if self.contributions.is_empty() {
|
||||||
|
return self.source.clone();
|
||||||
|
}
|
||||||
|
self.contributions
|
||||||
|
.iter()
|
||||||
|
.map(|contribution| {
|
||||||
|
format!(
|
||||||
|
"({})",
|
||||||
|
contribution
|
||||||
|
.authoring_source
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&contribution.source)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" & ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Self::compose(Vec::new()).expect("empty schema bundle is valid")
|
Self::compose(Vec::new()).expect("empty schema bundle is valid")
|
||||||
}
|
}
|
||||||
@@ -751,26 +802,32 @@ pub struct EvaluationResult {
|
|||||||
pub projection_digest: String,
|
pub projection_digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum ConfigCompletionPathSegment {
|
||||||
|
Field(String),
|
||||||
|
Index(usize),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ConfigFieldCompletionContext {
|
struct ConfigFieldCompletionContext {
|
||||||
schema_path: Vec<String>,
|
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||||
from: usize,
|
from: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum ConfigCompletionContainer {
|
enum ConfigCompletionContainer {
|
||||||
Object {
|
Object {
|
||||||
schema_path: Vec<String>,
|
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||||
pending_path: Vec<String>,
|
pending_path: Vec<String>,
|
||||||
last_identifier_from: Option<usize>,
|
last_identifier_from: Option<usize>,
|
||||||
trailing_dot: bool,
|
trailing_dot: bool,
|
||||||
reading_value: bool,
|
reading_value: bool,
|
||||||
},
|
},
|
||||||
Array {
|
Array {
|
||||||
schema_path: Vec<String>,
|
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||||
},
|
},
|
||||||
Other {
|
Other {
|
||||||
schema_path: Vec<String>,
|
schema_path: Vec<ConfigCompletionPathSegment>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,9 +912,18 @@ fn config_field_completion_context(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
SyntaxTokenKind::LBracket => {
|
SyntaxTokenKind::LBracket => {
|
||||||
let schema_path = pending_container_path(&containers);
|
let mut schema_path = pending_container_path(&containers);
|
||||||
|
schema_path.push(ConfigCompletionPathSegment::Index(0));
|
||||||
containers.push(ConfigCompletionContainer::Array { schema_path });
|
containers.push(ConfigCompletionContainer::Array { schema_path });
|
||||||
}
|
}
|
||||||
|
SyntaxTokenKind::Comma => {
|
||||||
|
if let Some(ConfigCompletionContainer::Array { schema_path }) =
|
||||||
|
containers.last_mut()
|
||||||
|
&& let Some(ConfigCompletionPathSegment::Index(index)) = schema_path.last_mut()
|
||||||
|
{
|
||||||
|
*index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
SyntaxTokenKind::RBracket => {
|
SyntaxTokenKind::RBracket => {
|
||||||
pop_container(&mut containers, |container| {
|
pop_container(&mut containers, |container| {
|
||||||
matches!(container, ConfigCompletionContainer::Array { .. })
|
matches!(container, ConfigCompletionContainer::Array { .. })
|
||||||
@@ -943,7 +1009,12 @@ fn config_field_completion_context(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let mut schema_path = schema_path.clone();
|
let mut schema_path = schema_path.clone();
|
||||||
schema_path.extend(pending_path.iter().cloned());
|
schema_path.extend(
|
||||||
|
pending_path
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(ConfigCompletionPathSegment::Field),
|
||||||
|
);
|
||||||
Some(ConfigFieldCompletionContext {
|
Some(ConfigFieldCompletionContext {
|
||||||
schema_path,
|
schema_path,
|
||||||
from: if *trailing_dot {
|
from: if *trailing_dot {
|
||||||
@@ -954,14 +1025,25 @@ fn config_field_completion_context(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec<String> {
|
fn pending_container_path(
|
||||||
|
containers: &[ConfigCompletionContainer],
|
||||||
|
) -> Vec<ConfigCompletionPathSegment> {
|
||||||
match containers.last() {
|
match containers.last() {
|
||||||
Some(ConfigCompletionContainer::Object {
|
Some(ConfigCompletionContainer::Object {
|
||||||
schema_path,
|
schema_path,
|
||||||
pending_path,
|
pending_path,
|
||||||
reading_value: true,
|
reading_value: true,
|
||||||
..
|
..
|
||||||
}) => schema_path.iter().chain(pending_path).cloned().collect(),
|
}) => schema_path
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(
|
||||||
|
pending_path
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(ConfigCompletionPathSegment::Field),
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
Some(ConfigCompletionContainer::Array { schema_path })
|
Some(ConfigCompletionContainer::Array { schema_path })
|
||||||
| Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(),
|
| Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
@@ -1201,12 +1283,37 @@ impl SnapshotEnvironment {
|
|||||||
if self.schema_bundle.is_some()
|
if self.schema_bundle.is_some()
|
||||||
&& let Some(context) = config_field_completion_context(source, utf8_byte_offset)
|
&& let Some(context) = config_field_completion_context(source, utf8_byte_offset)
|
||||||
{
|
{
|
||||||
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
let (schema_path, prefix) = if context.from == utf8_byte_offset {
|
||||||
member_source.push_str(&context.schema_path.join("."));
|
(context.schema_path.as_slice(), "")
|
||||||
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
} else {
|
||||||
member_source.push('.');
|
let Some((ConfigCompletionPathSegment::Field(prefix), schema_path)) =
|
||||||
|
context.schema_path.split_last()
|
||||||
|
else {
|
||||||
|
return self.complete(entrypoint, source, utf8_byte_offset, explicit);
|
||||||
|
};
|
||||||
|
(schema_path, prefix.as_str())
|
||||||
|
};
|
||||||
|
let has_array = schema_path
|
||||||
|
.iter()
|
||||||
|
.any(|segment| matches!(segment, ConfigCompletionPathSegment::Index(_)));
|
||||||
|
let mut member_source = WORKSPACE_CONFIG_SCHEMA_GLOBAL.to_owned();
|
||||||
|
// Ordinary object paths retain the language service's member traversal,
|
||||||
|
// but both branches install the composed authoring schema.
|
||||||
|
if !has_array {
|
||||||
|
for segment in schema_path {
|
||||||
|
if let ConfigCompletionPathSegment::Field(field) = segment {
|
||||||
|
member_source.push('.');
|
||||||
|
member_source.push_str(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut completion = LanguageService::new(self).complete(
|
member_source.push('.');
|
||||||
|
member_source.push_str(prefix);
|
||||||
|
let completion_environment = ConfigFieldCompletionEnvironment {
|
||||||
|
environment: self,
|
||||||
|
schema_path: if has_array { schema_path } else { &[] },
|
||||||
|
};
|
||||||
|
let mut completion = LanguageService::new(completion_environment).complete(
|
||||||
entrypoint.as_str(),
|
entrypoint.as_str(),
|
||||||
&member_source,
|
&member_source,
|
||||||
member_source.len(),
|
member_source.len(),
|
||||||
@@ -1270,6 +1377,91 @@ impl HostEnvironment for &SnapshotEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decodal has no indexed member syntax. Resolve array ranges only in this
|
||||||
|
// completion environment, then reuse the language service's member behavior.
|
||||||
|
struct ConfigFieldCompletionEnvironment<'a> {
|
||||||
|
environment: &'a SnapshotEnvironment,
|
||||||
|
schema_path: &'a [ConfigCompletionPathSegment],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostEnvironment for ConfigFieldCompletionEnvironment<'_> {
|
||||||
|
type Loader = SnapshotImportLoader;
|
||||||
|
|
||||||
|
fn create_loader(&self) -> Self::Loader {
|
||||||
|
self.environment.create_loader()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
|
||||||
|
use decodal::runtime::{ConcreteValue, ObjectField, ObjectValue, RuntimeValue};
|
||||||
|
|
||||||
|
let Some(bundle) = &self.environment.schema_bundle else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let module = engine.add_root_source(
|
||||||
|
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||||
|
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||||
|
&bundle.authoring_source(),
|
||||||
|
)?;
|
||||||
|
let mut schema = Some(engine.eval_module(module)?);
|
||||||
|
for segment in self.schema_path {
|
||||||
|
let Some(value) = schema else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
schema = match segment {
|
||||||
|
ConfigCompletionPathSegment::Field(field) => engine
|
||||||
|
.value_fields(&value)?
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(name, _)| name == field)
|
||||||
|
.map(|(_, value)| value),
|
||||||
|
ConfigCompletionPathSegment::Index(index) => {
|
||||||
|
let element = match value {
|
||||||
|
RuntimeValue::Concrete(ConcreteValue::Array(elements)) => {
|
||||||
|
elements.get(*index).copied()
|
||||||
|
}
|
||||||
|
RuntimeValue::Abstract(range) => {
|
||||||
|
range.constraints.iter().find_map(|entry| {
|
||||||
|
if let decodal::Constraint::ArrayItems(element) = entry.constraint {
|
||||||
|
Some(element)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(element) = element {
|
||||||
|
// value_fields is the public API for forcing lazy fields.
|
||||||
|
// Wrap the element thunk without materializing its schema.
|
||||||
|
let object = RuntimeValue::Concrete(ConcreteValue::Object(ObjectValue {
|
||||||
|
fields: vec![ObjectField {
|
||||||
|
name: "element".into(),
|
||||||
|
value: element,
|
||||||
|
span: Span::default(),
|
||||||
|
}],
|
||||||
|
rest: None,
|
||||||
|
}));
|
||||||
|
engine
|
||||||
|
.value_fields(&object)?
|
||||||
|
.and_then(|fields| fields.into_iter().next().map(|(_, value)| value))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(schema) = schema {
|
||||||
|
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
||||||
|
} else {
|
||||||
|
engine.bind_global(
|
||||||
|
WORKSPACE_CONFIG_SCHEMA_GLOBAL,
|
||||||
|
Value::object([] as [(&str, Value); 0]),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SnapshotImportLoader {
|
pub struct SnapshotImportLoader {
|
||||||
snapshot: ConfigTreeSnapshot,
|
snapshot: ConfigTreeSnapshot,
|
||||||
@@ -1890,7 +2082,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
config_field_completion_context(root, root_cursor),
|
config_field_completion_context(root, root_cursor),
|
||||||
Some(ConfigFieldCompletionContext {
|
Some(ConfigFieldCompletionContext {
|
||||||
schema_path: vec!["pro".into()],
|
schema_path: vec![ConfigCompletionPathSegment::Field("pro".into())],
|
||||||
from: root_cursor - 3,
|
from: root_cursor - 3,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1899,7 +2091,10 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
config_field_completion_context(nested, nested_cursor),
|
config_field_completion_context(nested, nested_cursor),
|
||||||
Some(ConfigFieldCompletionContext {
|
Some(ConfigFieldCompletionContext {
|
||||||
schema_path: vec!["profile".into(), "def".into()],
|
schema_path: vec![
|
||||||
|
ConfigCompletionPathSegment::Field("profile".into()),
|
||||||
|
ConfigCompletionPathSegment::Field("def".into()),
|
||||||
|
],
|
||||||
from: nested_cursor - 3,
|
from: nested_cursor - 3,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1908,7 +2103,12 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
config_field_completion_context(array, array_cursor),
|
config_field_completion_context(array, array_cursor),
|
||||||
Some(ConfigFieldCompletionContext {
|
Some(ConfigFieldCompletionContext {
|
||||||
schema_path: vec!["profile".into(), "entries".into(), "sel".into()],
|
schema_path: vec![
|
||||||
|
ConfigCompletionPathSegment::Field("profile".into()),
|
||||||
|
ConfigCompletionPathSegment::Field("entries".into()),
|
||||||
|
ConfigCompletionPathSegment::Index(0),
|
||||||
|
ConfigCompletionPathSegment::Field("sel".into()),
|
||||||
|
],
|
||||||
from: array_cursor - 3,
|
from: array_cursor - 3,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1991,6 +2191,420 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn profile_entries_completion_schema() -> WorkspaceConfigSchemaBundle {
|
||||||
|
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:profile",
|
||||||
|
"profile",
|
||||||
|
"1",
|
||||||
|
"{ profile = { default_profile = String; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_projects_array_element_and_nested_profile_fields() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot)
|
||||||
|
.with_schema_bundle(profile_entries_completion_schema());
|
||||||
|
for (marked_source, expected, prefix_len) in [
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ sel| }] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile = { entries = [{ pro| }] } } as WorkspaceConfigSchema",
|
||||||
|
"profile",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||||
|
"profile",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
|
||||||
|
"slug",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
|
||||||
|
"worker",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile.worker.mo| }] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile.worker.| }] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile = { worker = { mo| } } }] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{}, { sel| }] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ selector = \"日本語\"; profile = { slu|g } }] } as WorkspaceConfigSchema",
|
||||||
|
"slug",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ pro|file }] } as WorkspaceConfigSchema",
|
||||||
|
"profile",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [({ profile = { worker = { mo| } } })] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let cursor = marked_source.find('|').unwrap();
|
||||||
|
let source = marked_source.replace('|', "");
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||||
|
assert_eq!(completion.from, cursor - prefix_len, "{marked_source}");
|
||||||
|
assert!(
|
||||||
|
completion.items.iter().any(|item| item.label == expected),
|
||||||
|
"{marked_source}: {completion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||||
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:profile",
|
||||||
|
"profile",
|
||||||
|
"1",
|
||||||
|
"{ profile = { entries = [{ selector = String; }, { profile = { slug = String; worker = { mode = String; }; }; }]; matrix = [...[...{ selector = String; }]]; }; }",
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
|
||||||
|
for (marked_source, expected, unexpected) in [
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
"profile",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{}, { | }] } as WorkspaceConfigSchema",
|
||||||
|
"profile",
|
||||||
|
"selector",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{}, { profile.worker.mo| }] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
"selector",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.matrix = [[{}, { sel| }]] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
"profile",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.matrix = [[], [{ sel| }]] } as WorkspaceConfigSchema",
|
||||||
|
"selector",
|
||||||
|
"profile",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let cursor = marked_source.find('|').unwrap();
|
||||||
|
let source = marked_source.replace('|', "");
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||||
|
assert!(
|
||||||
|
completion.items.iter().any(|item| item.label == expected),
|
||||||
|
"{marked_source}: {completion:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!completion.items.iter().any(|item| item.label == unexpected),
|
||||||
|
"{marked_source}: {completion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for marked_source in [
|
||||||
|
"{ profile.entries = [{}, {}, { | }] } as WorkspaceConfigSchema",
|
||||||
|
"{ profile.entries = [{ missing = { | } }] } as WorkspaceConfigSchema",
|
||||||
|
] {
|
||||||
|
let cursor = marked_source.find('|').unwrap();
|
||||||
|
let source = marked_source.replace('|', "");
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
completion.is_none_or(|completion| !completion
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| matches!(item.label.as_str(), "selector" | "profile"))),
|
||||||
|
"{marked_source}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_preserves_general_schema_member_behavior() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot)
|
||||||
|
.with_schema_bundle(profile_entries_completion_schema());
|
||||||
|
for source in [
|
||||||
|
"WorkspaceConfigSchema.pro",
|
||||||
|
"WorkspaceConfigSchema.profile.",
|
||||||
|
"WorkspaceConfigSchema.profile.entries.",
|
||||||
|
"WorkspaceConfigSchema.profile.default_pro",
|
||||||
|
"let schema = WorkspaceConfigSchema; in schema.profile.def",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
environment
|
||||||
|
.complete_config(&path("main.dcdl"), source, source.len(), true)
|
||||||
|
.unwrap(),
|
||||||
|
environment
|
||||||
|
.complete(&path("main.dcdl"), source, source.len(), true)
|
||||||
|
.unwrap(),
|
||||||
|
"{source}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_array_schema_retains_nested_profile_diagnostics() {
|
||||||
|
let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema";
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot)
|
||||||
|
.with_schema_bundle(profile_entries_completion_schema());
|
||||||
|
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||||
|
for (source, field) in [
|
||||||
|
(
|
||||||
|
valid.replace("selector = \"default\"", "selector = 42"),
|
||||||
|
"selector",
|
||||||
|
),
|
||||||
|
(valid.replace("slug = \"default\"", "slug = 42"), "slug"),
|
||||||
|
(valid.replace("mode = \"interactive\"", "mode = 42"), "mode"),
|
||||||
|
(
|
||||||
|
valid.replace("mode = \"interactive\"", "typo = \"interactive\""),
|
||||||
|
"typo",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source));
|
||||||
|
assert!(
|
||||||
|
diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.kind == "constraint_violation"
|
||||||
|
&& diagnostic.path == path("main.dcdl")
|
||||||
|
&& diagnostic.revision == 7
|
||||||
|
&& diagnostic.span.end_byte > diagnostic.span.start_byte
|
||||||
|
&& (diagnostic.message.contains(field)
|
||||||
|
|| diagnostic
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.any(|label| label.message.contains(field)))
|
||||||
|
}),
|
||||||
|
"{field}: {diagnostics:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_authoring_schema_preserves_omitted_profile_fields_in_evaluation() {
|
||||||
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:profile",
|
||||||
|
"profile",
|
||||||
|
"1",
|
||||||
|
"{ profile = { defaults = {...Unknown}; entries = [...{ selector = String; profile = {...Unknown}; }]; }; }",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_authoring_source(
|
||||||
|
"{ profile = { defaults = { slug = String; worker = { mode = String; }; }; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
|
||||||
|
)])
|
||||||
|
.unwrap();
|
||||||
|
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
|
||||||
|
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||||
|
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||||
|
let evaluated = environment.evaluate_contract(&contract).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
evaluated.projections[0].data_json,
|
||||||
|
serde_json::json!({"profile": {"defaults": {}, "entries": [{"selector": "default", "profile": {}}]}})
|
||||||
|
);
|
||||||
|
// Both ordinary object paths and array-element paths use editor metadata.
|
||||||
|
for (marked_source, expected) in [
|
||||||
|
(
|
||||||
|
"{ profile.defaults = { slu| } } as WorkspaceConfigSchema",
|
||||||
|
"slug",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.defaults.worker = { mo| } } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ pro| }] } as WorkspaceConfigSchema",
|
||||||
|
"profile",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
|
||||||
|
"slug",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile.worker = { mo| } }] } as WorkspaceConfigSchema",
|
||||||
|
"mode",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
|
||||||
|
"worker",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let cursor = marked_source.find('|').unwrap();
|
||||||
|
let source = marked_source.replace('|', "");
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
|
||||||
|
assert!(
|
||||||
|
completion.items.iter().any(|item| item.label == expected),
|
||||||
|
"{marked_source}: {completion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Completion does not install its schema into later analysis/evaluation.
|
||||||
|
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
|
||||||
|
assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_authoring_sources_compose_with_validation_source_fallbacks() {
|
||||||
|
let schema = WorkspaceConfigSchemaBundle::compose([
|
||||||
|
ConfigSchemaContribution::new(
|
||||||
|
"builtin:editor",
|
||||||
|
"editor",
|
||||||
|
"1",
|
||||||
|
"{ editor = {...Unknown}; }",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_authoring_source(
|
||||||
|
"{ editor = { theme = String; settings = { enabled = Bool; }; }; }",
|
||||||
|
),
|
||||||
|
ConfigSchemaContribution::new(
|
||||||
|
"builtin:web",
|
||||||
|
"web",
|
||||||
|
"1",
|
||||||
|
"{ web = { enabled = Bool; }; }",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
|
||||||
|
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
|
||||||
|
for (marked_source, expected) in [
|
||||||
|
("{ ed| } as WorkspaceConfigSchema", "editor"),
|
||||||
|
("{ editor = { th| } } as WorkspaceConfigSchema", "theme"),
|
||||||
|
(
|
||||||
|
"{ editor.settings = { en| } } as WorkspaceConfigSchema",
|
||||||
|
"enabled",
|
||||||
|
),
|
||||||
|
("{ web = { en| } } as WorkspaceConfigSchema", "enabled"),
|
||||||
|
] {
|
||||||
|
let cursor = marked_source.find('|').unwrap();
|
||||||
|
let source = marked_source.replace('|', "");
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
completion.items.iter().any(|item| item.label == expected),
|
||||||
|
"{marked_source}: {completion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authoring_schema_metadata_is_optional_validated_and_fingerprinted() {
|
||||||
|
let contribution = ConfigSchemaContribution::new(
|
||||||
|
"builtin:editor",
|
||||||
|
"editor",
|
||||||
|
"1",
|
||||||
|
"{ editor = {...Unknown}; }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let serialized = serde_json::to_value(&contribution).unwrap();
|
||||||
|
assert!(serialized.get("authoring_source").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
|
||||||
|
contribution
|
||||||
|
);
|
||||||
|
let legacy = WorkspaceConfigSchemaBundle::compose([contribution.clone()]).unwrap();
|
||||||
|
let legacy_input = serde_json::to_vec(&(
|
||||||
|
CONFIG_SOURCE_CONTRACT_VERSION,
|
||||||
|
DECODAL_VERSION,
|
||||||
|
vec![(
|
||||||
|
contribution.provider_id.as_str(),
|
||||||
|
contribution.namespace.as_str(),
|
||||||
|
contribution.version.as_str(),
|
||||||
|
contribution.source_digest.as_str(),
|
||||||
|
contribution.projection_validator.as_ref(),
|
||||||
|
)],
|
||||||
|
digest_bytes(legacy.source.as_bytes()),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(legacy.fingerprint, digest_bytes(&legacy_input));
|
||||||
|
legacy.validate().unwrap();
|
||||||
|
|
||||||
|
let authoring = contribution
|
||||||
|
.clone()
|
||||||
|
.with_authoring_source("{ editor = { theme = String; }; }");
|
||||||
|
assert_eq!(authoring.source_digest, contribution.source_digest);
|
||||||
|
let serialized = serde_json::to_value(&authoring).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serialized["authoring_source"],
|
||||||
|
"{ editor = { theme = String; }; }"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
|
||||||
|
authoring
|
||||||
|
);
|
||||||
|
let authored = WorkspaceConfigSchemaBundle::compose([authoring]).unwrap();
|
||||||
|
authored.validate().unwrap();
|
||||||
|
assert_eq!(authored.source, legacy.source);
|
||||||
|
assert_ne!(authored.fingerprint, legacy.fingerprint);
|
||||||
|
assert_ne!(
|
||||||
|
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, authored.clone())
|
||||||
|
.fingerprint,
|
||||||
|
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, legacy)
|
||||||
|
.fingerprint,
|
||||||
|
);
|
||||||
|
let changed = WorkspaceConfigSchemaBundle::compose([contribution
|
||||||
|
.clone()
|
||||||
|
.with_authoring_source("{ editor = { theme = String; mode = String; }; }")])
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(changed.fingerprint, authored.fingerprint);
|
||||||
|
let mut tampered = authored;
|
||||||
|
tampered.contributions[0].authoring_source =
|
||||||
|
changed.contributions[0].authoring_source.clone();
|
||||||
|
assert!(tampered.validate().is_err());
|
||||||
|
for source in ["", " \n\t "] {
|
||||||
|
assert!(
|
||||||
|
matches!(WorkspaceConfigSchemaBundle::compose([contribution.clone().with_authoring_source(source)]), Err(ConfigTreeError::InvalidSchemaContribution(message)) if message.contains("authoring"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relative_imports_and_completion_share_the_snapshot_namespace() {
|
fn relative_imports_and_completion_share_the_snapshot_namespace() {
|
||||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
|||||||
@@ -4496,6 +4496,10 @@ pub struct ConfigSchemaContribution {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
pub projection_validator: Option<ConfigProjectionValidator>,
|
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,
|
pub source_digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,35 @@ pub struct ProfileSourceArchiveInput {
|
|||||||
|
|
||||||
impl ProfileSourceArchive {
|
impl ProfileSourceArchive {
|
||||||
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
|
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 {
|
if input.sources.len() > MAX_SOURCES {
|
||||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||||
}
|
}
|
||||||
@@ -145,8 +174,8 @@ impl ProfileSourceArchive {
|
|||||||
source_meta.push(ProfileSourceArchiveSource {
|
source_meta.push(ProfileSourceArchiveSource {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
source_key: path.clone(),
|
source_key: path.clone(),
|
||||||
kind: "decodal".to_string(),
|
kind: kind.to_string(),
|
||||||
content_type: default_decodal_content_type(),
|
content_type: content_type.to_string(),
|
||||||
digest: sha256_hex(source.as_bytes()),
|
digest: sha256_hex(source.as_bytes()),
|
||||||
size_bytes: size,
|
size_bytes: size,
|
||||||
});
|
});
|
||||||
@@ -288,7 +317,17 @@ impl VerifiedProfileSourceArchive {
|
|||||||
actual: source.source_key.clone(),
|
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 {
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
path: source.path.clone(),
|
path: source.path.clone(),
|
||||||
kind: source.content_type.clone(),
|
kind: source.content_type.clone(),
|
||||||
@@ -297,12 +336,6 @@ impl VerifiedProfileSourceArchive {
|
|||||||
if !manifest_paths.insert(source.path.clone()) {
|
if !manifest_paths.insert(source.path.clone()) {
|
||||||
return Err(ProfileArchiveError::DuplicateSource(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
|
let content = entries
|
||||||
.get(&source.path)
|
.get(&source.path)
|
||||||
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
|
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
|
||||||
@@ -373,30 +406,50 @@ impl VerifiedProfileSourceArchive {
|
|||||||
.entrypoints
|
.entrypoints
|
||||||
.get(selector)
|
.get(selector)
|
||||||
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
|
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
|
||||||
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
|
||||||
let source = self
|
let source = self
|
||||||
.sources
|
.sources
|
||||||
.get(path)
|
.get(path)
|
||||||
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||||
let module = engine.add_root_source(path, path, source).map_err(|err| {
|
let metadata = self
|
||||||
ProfileArchiveError::Decodal {
|
.manifest
|
||||||
path: path.clone(),
|
.sources
|
||||||
message: format!("{err:?}"),
|
.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
|
||||||
|
.eval_module(module)
|
||||||
|
.map_err(|err| ProfileArchiveError::Decodal {
|
||||||
|
path: path.clone(),
|
||||||
|
message: format!("{err:?}"),
|
||||||
|
})?;
|
||||||
|
let data =
|
||||||
|
engine
|
||||||
|
.materialize(&value)
|
||||||
|
.map_err(|err| ProfileArchiveError::Decodal {
|
||||||
|
path: path.clone(),
|
||||||
|
message: format!("{err:?}"),
|
||||||
|
})?;
|
||||||
|
decodal_data_to_json(&data)
|
||||||
}
|
}
|
||||||
})?;
|
_ => {
|
||||||
let value = engine
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
.eval_module(module)
|
path: path.clone(),
|
||||||
.map_err(|err| ProfileArchiveError::Decodal {
|
kind: metadata.kind.clone(),
|
||||||
path: path.clone(),
|
});
|
||||||
message: format!("{err:?}"),
|
}
|
||||||
})?;
|
};
|
||||||
let data = engine
|
|
||||||
.materialize(&value)
|
|
||||||
.map_err(|err| ProfileArchiveError::Decodal {
|
|
||||||
path: path.clone(),
|
|
||||||
message: format!("{err:?}"),
|
|
||||||
})?;
|
|
||||||
let json = decodal_data_to_json(&data);
|
|
||||||
let resolved = resolve_profile_artifact_value(
|
let resolved = resolve_profile_artifact_value(
|
||||||
json,
|
json,
|
||||||
ProfileSource::Archive {
|
ProfileSource::Archive {
|
||||||
@@ -674,6 +727,171 @@ mod tests {
|
|||||||
.unwrap()
|
.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]
|
#[test]
|
||||||
fn build_and_verify_archive() {
|
fn build_and_verify_archive() {
|
||||||
let archive = sample_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)?;
|
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||||
if has_provider("builtin:profile") {
|
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") {
|
if has_provider("builtin:runtime") {
|
||||||
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
|
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
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 manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use server_api::{
|
use server_api::{
|
||||||
@@ -21,24 +21,19 @@ use crate::config_source::{
|
|||||||
use crate::store::WorkspaceRecord;
|
use crate::store::WorkspaceRecord;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const PROFILE_SCHEMA_SOURCE: &str = r#"{
|
const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl");
|
||||||
profile = {
|
|
||||||
default_profile = String default "builtin:companion";
|
|
||||||
entries = [...{
|
|
||||||
selector = String;
|
|
||||||
source = String;
|
|
||||||
label = String default "";
|
|
||||||
description = String default "";
|
|
||||||
}] default [];
|
|
||||||
};
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ProfileConfigSchemaProvider;
|
pub struct ProfileConfigSchemaProvider;
|
||||||
|
|
||||||
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
|
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
|
||||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
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()))
|
.map_err(|error| Error::Config(error.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +54,7 @@ struct VirtualProfileSection {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct VirtualProfileEntry {
|
struct VirtualProfileEntry {
|
||||||
selector: String,
|
selector: String,
|
||||||
source: String,
|
profile: serde_json::Value,
|
||||||
label: String,
|
label: String,
|
||||||
description: String,
|
description: String,
|
||||||
}
|
}
|
||||||
@@ -68,7 +63,6 @@ struct VirtualProfileEntry {
|
|||||||
pub struct ProfileConfigProjection {
|
pub struct ProfileConfigProjection {
|
||||||
pub settings: ProfileSettingsResponse,
|
pub settings: ProfileSettingsResponse,
|
||||||
entries: BTreeMap<String, VirtualProfileEntry>,
|
entries: BTreeMap<String, VirtualProfileEntry>,
|
||||||
sources: BTreeMap<String, String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn project_profiles_from_workspace_config(
|
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}"
|
"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(|| {
|
let projected = evaluation.projections.first().ok_or_else(|| {
|
||||||
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
|
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())
|
let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone())
|
||||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||||
let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile));
|
let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile));
|
||||||
let mut entries = BTreeMap::new();
|
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();
|
let mut source_summaries = Vec::new();
|
||||||
for entry in config.profile.entries {
|
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(
|
return Err(profile_validation_error(
|
||||||
"profile_selector_invalid",
|
"profile_selector_invalid",
|
||||||
"Workspace Profile selectors must use project:*",
|
"Workspace Profile selectors must use project:*",
|
||||||
@@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config(
|
|||||||
"Workspace Profile selectors must be unique",
|
"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() {
|
let label = if entry.label.is_empty() {
|
||||||
entry.selector.trim_start_matches("project:").to_string()
|
entry.selector.trim_start_matches("project:").to_string()
|
||||||
} else {
|
} else {
|
||||||
entry.label.clone()
|
entry.label.clone()
|
||||||
};
|
};
|
||||||
resolve_profile_artifact_value(
|
resolve_profile_artifact_value(
|
||||||
evaluate_profile_source(&state.snapshot, &source_path)?,
|
entry.profile.clone(),
|
||||||
ProfileSource::Archive {
|
ProfileSource::Archive {
|
||||||
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
|
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
|
||||||
source: archive_source.clone(),
|
source: entry.selector.clone(),
|
||||||
},
|
},
|
||||||
Path::new("/"),
|
Path::new("/"),
|
||||||
"workspace-config-validation",
|
"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 {
|
profiles.push(WorkspaceProfileSummary {
|
||||||
profile_id: entry.selector.clone(),
|
profile_id: entry.selector.clone(),
|
||||||
selector: entry.selector.clone(),
|
selector: entry.selector.clone(),
|
||||||
label,
|
label,
|
||||||
source_kind: "project".to_string(),
|
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()),
|
description: (!entry.description.is_empty()).then(|| entry.description.clone()),
|
||||||
editable: false,
|
editable: false,
|
||||||
is_default: config.profile.default_profile == entry.selector,
|
is_default: config.profile.default_profile == entry.selector,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
});
|
});
|
||||||
|
let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes");
|
||||||
source_summaries.push(WorkspaceProfileSourceSummary {
|
source_summaries.push(WorkspaceProfileSourceSummary {
|
||||||
profile_source_id: entry.source.clone(),
|
profile_source_id: entry.selector.clone(),
|
||||||
display_path: entry.source.clone(),
|
display_path: format!("profile.entries[{}].profile", source_summaries.len()),
|
||||||
kind: "virtual_config".to_string(),
|
kind: "evaluated_config".to_string(),
|
||||||
content_type: "decodal".to_string(),
|
content_type: "application/json".to_string(),
|
||||||
content_digest: source_entry.content_digest.clone(),
|
content_digest: config_source::digest_bytes(&value_bytes),
|
||||||
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
|
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
|
||||||
editable: false,
|
editable: false,
|
||||||
revision: state.snapshot.revision.to_string(),
|
revision: state.snapshot.revision.to_string(),
|
||||||
size_bytes: source_entry.content.len() as u64,
|
size_bytes: value_bytes.len() as u64,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
});
|
});
|
||||||
entries.insert(entry.selector.clone(), entry);
|
entries.insert(entry.selector.clone(), entry);
|
||||||
@@ -198,46 +193,16 @@ pub fn project_profiles_from_workspace_config(
|
|||||||
registry_revision: format!("config:{}", state.snapshot.revision),
|
registry_revision: format!("config:{}", state.snapshot.revision),
|
||||||
config_revision: Some(state.snapshot.revision),
|
config_revision: Some(state.snapshot.revision),
|
||||||
tree_digest: Some(state.snapshot.digest.clone()),
|
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),
|
default_profile: Some(config.profile.default_profile),
|
||||||
profiles,
|
profiles,
|
||||||
sources: source_summaries,
|
sources: source_summaries,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
entries,
|
entries,
|
||||||
sources,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate_profile_source(
|
|
||||||
snapshot: &config_source::ConfigTreeSnapshot,
|
|
||||||
source_path: &VirtualPath,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
|
||||||
config_source::DEFAULT_SCHEMA_VERSION,
|
|
||||||
vec![source_path.clone()],
|
|
||||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
|
||||||
config_source::WorkspaceConfigSchemaBundle::empty(),
|
|
||||||
);
|
|
||||||
let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone())
|
|
||||||
.evaluate_contract(&contract)
|
|
||||||
.map_err(|diagnostics| {
|
|
||||||
profile_validation_error(
|
|
||||||
"profile_source_invalid",
|
|
||||||
&serde_json::to_string(&diagnostics)
|
|
||||||
.unwrap_or_else(|_| "Profile source evaluation failed".to_string()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
evaluation
|
|
||||||
.projections
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|projection| projection.data_json)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
profile_validation_error("profile_source_invalid", "Profile source has no projection")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selector_for_workspace_candidate(
|
pub fn selector_for_workspace_candidate(
|
||||||
projection: &ProfileConfigProjection,
|
projection: &ProfileConfigProjection,
|
||||||
profile: &str,
|
profile: &str,
|
||||||
@@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
|||||||
selector: &str,
|
selector: &str,
|
||||||
prompt_projection: &worker::WorkspacePromptProjection,
|
prompt_projection: &worker::WorkspacePromptProjection,
|
||||||
) -> Result<Option<ConfigBundle>> {
|
) -> 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)?;
|
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
|
||||||
let prompt_catalog = prompt_projection.catalog().clone();
|
let prompt_catalog = prompt_projection.catalog().clone();
|
||||||
let profile_selector =
|
let profile_selector =
|
||||||
@@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let archive = match projection.entries.get(selector) {
|
let archive = match projection.entries.get(selector) {
|
||||||
Some(entry) => Some(build_virtual_profile_archive(
|
Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?),
|
||||||
selector,
|
|
||||||
entry,
|
|
||||||
&projection.sources,
|
|
||||||
state,
|
|
||||||
)?),
|
|
||||||
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
|
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
|
||||||
};
|
};
|
||||||
let bundle_id = virtual_profile_bundle_id(
|
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(
|
fn build_virtual_profile_archive(
|
||||||
selector: &str,
|
selector: &str,
|
||||||
entry: &VirtualProfileEntry,
|
entry: &VirtualProfileEntry,
|
||||||
all_sources: &BTreeMap<String, String>,
|
|
||||||
state: &WorkspaceConfigState,
|
state: &WorkspaceConfigState,
|
||||||
) -> Result<ProfileSourceArchive> {
|
) -> Result<ProfileSourceArchive> {
|
||||||
let mut closure = BTreeMap::new();
|
ProfileSourceArchive::build_evaluated_profile(
|
||||||
let mut imports = BTreeMap::new();
|
format!("workspace-config-profile-r{}", state.snapshot.revision),
|
||||||
collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?;
|
selector.to_string(),
|
||||||
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
entry.profile.clone(),
|
||||||
id: format!("workspace-config-profile-r{}", state.snapshot.revision),
|
)
|
||||||
entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]),
|
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
|
||||||
imports,
|
|
||||||
sources: closure,
|
|
||||||
})
|
|
||||||
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_metadata_settings(
|
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(
|
pub fn selector_for_builtin_candidate(
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||||
@@ -711,6 +558,7 @@ pub fn selector_for_builtin_candidate(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use config_source::{ConfigContentType, VirtualPath};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
|
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
|
||||||
@@ -779,7 +627,7 @@ mod tests {
|
|||||||
config_source::ConfigEntry::new(
|
config_source::ConfigEntry::new(
|
||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
ConfigContentType::Decodal,
|
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(),
|
.unwrap(),
|
||||||
config_source::ConfigEntry::new(
|
config_source::ConfigEntry::new(
|
||||||
@@ -819,18 +667,18 @@ mod tests {
|
|||||||
.entrypoints
|
.entrypoints
|
||||||
.get("project:alpha")
|
.get("project:alpha")
|
||||||
.map(String::as_str),
|
.map(String::as_str),
|
||||||
Some("profiles/alpha.dcdl")
|
Some("profiles/evaluated.json")
|
||||||
);
|
);
|
||||||
assert_eq!(archive.reference.source_graph.source_count, 1);
|
assert_eq!(archive.reference.source_graph.source_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn virtual_config_projection_preserves_import_closure() {
|
fn virtual_config_projection_materializes_import_closure() {
|
||||||
let state = virtual_state(vec![
|
let state = virtual_state(vec![
|
||||||
config_source::ConfigEntry::new(
|
config_source::ConfigEntry::new(
|
||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
ConfigContentType::Decodal,
|
ConfigContentType::Decodal,
|
||||||
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#,
|
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
config_source::ConfigEntry::new(
|
config_source::ConfigEntry::new(
|
||||||
@@ -857,8 +705,8 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let archive = bundle.profile_source_archive.unwrap();
|
let archive = bundle.profile_source_archive.unwrap();
|
||||||
assert_eq!(archive.reference.source_graph.source_count, 2);
|
assert_eq!(archive.reference.source_graph.source_count, 1);
|
||||||
assert_eq!(archive.reference.source_graph.import_count, 1);
|
assert_eq!(archive.reference.source_graph.import_count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -955,8 +803,8 @@ mod tests {
|
|||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
ConfigContentType::Decodal,
|
ConfigContentType::Decodal,
|
||||||
r#"{ profile = { entries = [
|
r#"{ profile = { entries = [
|
||||||
{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; },
|
{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; },
|
||||||
{ selector = "project:beta"; source = "profiles/beta.dcdl"; },
|
{ selector = "project:beta"; profile = import "./profiles/beta.dcdl"; },
|
||||||
]; }; }"#,
|
]; }; }"#,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -1002,20 +850,83 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn virtual_config_projection_rejects_missing_profile_source() {
|
fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() {
|
||||||
let state = virtual_state(vec![
|
let bundle =
|
||||||
config_source::ConfigEntry::new(
|
config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider
|
||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
.contribution()
|
||||||
ConfigContentType::Decodal,
|
.unwrap()])
|
||||||
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#,
|
.unwrap();
|
||||||
)
|
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
|
||||||
.unwrap(),
|
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
|
||||||
]);
|
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
|
||||||
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
|
).unwrap()]).unwrap();
|
||||||
assert!(
|
let environment = config_source::SnapshotEnvironment::new(snapshot.clone())
|
||||||
error
|
.with_schema_bundle(bundle.clone());
|
||||||
.to_string()
|
for (body, token, label) in [
|
||||||
.contains("missing from the active config revision")
|
(
|
||||||
|
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
|
||||||
|
"sel",
|
||||||
|
"selector",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
|
||||||
|
"pro",
|
||||||
|
"profile",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
|
||||||
|
"wor",
|
||||||
|
"worker",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
|
||||||
|
"mo",
|
||||||
|
"mode",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema",
|
||||||
|
"ti",
|
||||||
|
"ticket",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let offset = body.rfind(token).unwrap() + token.len();
|
||||||
|
let completion = environment
|
||||||
|
.complete_config(
|
||||||
|
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
|
body,
|
||||||
|
offset,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
completion.items.iter().any(|item| item.label == label),
|
||||||
|
"{label}: {completion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for invalid in [
|
||||||
|
r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
|
||||||
|
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!environment
|
||||||
|
.analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid))
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let evaluation = environment
|
||||||
|
.evaluate_contract(&config_source::ToolchainContract::with_schema_bundle(
|
||||||
|
config_source::DEFAULT_SCHEMA_VERSION,
|
||||||
|
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||||
|
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||||
|
bundle,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
evaluation.projections[0]
|
||||||
|
.data_json
|
||||||
|
.pointer("/profile/entries/0/profile"),
|
||||||
|
Some(&serde_json::json!({}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12320,6 +12320,7 @@ fn config_schema_bundle_to_api(
|
|||||||
namespace: contribution.namespace,
|
namespace: contribution.namespace,
|
||||||
version: contribution.version,
|
version: contribution.version,
|
||||||
source: contribution.source,
|
source: contribution.source,
|
||||||
|
authoring_source: contribution.authoring_source,
|
||||||
projection_validator: contribution
|
projection_validator: contribution
|
||||||
.projection_validator
|
.projection_validator
|
||||||
.map(config_projection_validator_to_api),
|
.map(config_projection_validator_to_api),
|
||||||
@@ -33466,6 +33467,7 @@ impl IntoResponse for ApiError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
mod subjektiv_jobs_tests;
|
mod subjektiv_jobs_tests;
|
||||||
|
mod value_profiles_tests;
|
||||||
mod worker_operations_tests;
|
mod worker_operations_tests;
|
||||||
include!("server_workspace_config_tests.rs");
|
include!("server_workspace_config_tests.rs");
|
||||||
use super::*;
|
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(),
|
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
|
||||||
changes: vec![server_api::ConfigTreeChange::Update {
|
changes: vec![server_api::ConfigTreeChange::Update {
|
||||||
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
|
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 {
|
}, server_api::ConfigTreeChange::Create {
|
||||||
path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal,
|
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(),
|
content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(),
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
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_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#"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,33 @@ 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.
|
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; it is never evaluated as defaults or substituted for the validation schema. Decodal structural diagnostics cover registration/value shape, and the shared Backend save boundary checks full Profile/Manifest semantics (including mode, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched.
|
||||||
|
|
||||||
## Manifests
|
## Manifests
|
||||||
|
|
||||||
@@ -27,7 +53,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.
|
`--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
|
## Local stdio MCP server declarations
|
||||||
|
|
||||||
|
|||||||
@@ -1235,6 +1235,13 @@
|
|||||||
"ConfigSchemaContribution": {
|
"ConfigSchemaContribution": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"authoring_source": {
|
||||||
|
"description": "Completion-only shape for partial values; never materialized as defaults.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"namespace": {
|
"namespace": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -15474,7 +15481,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da"
|
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Completion-only shape of partial Profile values. This is never materialized or
|
||||||
|
# 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 = {
|
||||||
|
enabled = Bool; model = Model; reasoning = String;
|
||||||
|
threshold = Int; worker_max_turns = Int;
|
||||||
|
};
|
||||||
|
Scope = { 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 = {
|
||||||
|
kind = String; threshold = Unknown; request_threshold = Unknown;
|
||||||
|
prune_enabled = Bool; worker_context_max_tokens = Unknown;
|
||||||
|
worker_max_turns = Int; model = Model;
|
||||||
|
};
|
||||||
|
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": "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",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@8.3.2 build",
|
||||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export type ConfigSchemaContribution = {
|
|||||||
version: string;
|
version: string;
|
||||||
source: string;
|
source: string;
|
||||||
projection_validator?: ConfigProjectionValidator;
|
projection_validator?: ConfigProjectionValidator;
|
||||||
|
/**
|
||||||
|
* Completion-only shape for partial values; never materialized as defaults.
|
||||||
|
*/
|
||||||
|
authoring_source?: string;
|
||||||
source_digest: string;
|
source_digest: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// 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.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// 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.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// 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
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// 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.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// 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
|
// 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(
|
const item = record(
|
||||||
value,
|
value,
|
||||||
["provider_id", "namespace", "version", "source", "source_digest"],
|
["provider_id", "namespace", "version", "source", "source_digest"],
|
||||||
["projection_validator"],
|
["projection_validator", "authoring_source"],
|
||||||
);
|
);
|
||||||
const projection = item.projection_validator === undefined
|
const projection = item.projection_validator === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@@ -197,6 +197,12 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
|||||||
namespace: boundedString(item.namespace),
|
namespace: boundedString(item.namespace),
|
||||||
version: boundedString(item.version),
|
version: boundedString(item.version),
|
||||||
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
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 }),
|
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
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.
|
// 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";
|
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"));
|
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,113 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () =>
|
|||||||
assertEquals(result.from, 2);
|
assertEquals(result.from, 2);
|
||||||
assertEquals(result.items.some((item) => item.label === "profile"), true);
|
assertEquals(result.items.some((item) => item.label === "profile"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const invalidContent = content.replace("profile = {};", "profile = 42;");
|
||||||
|
const invalid = {
|
||||||
|
...tree,
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": { ...tree.entries["main.dcdl"], content: invalidContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const diagnostics = analyze_snapshot(
|
||||||
|
invalid,
|
||||||
|
"main.dcdl",
|
||||||
|
undefined,
|
||||||
|
) as Array<
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
assertEquals(diagnostics.length > 0, true);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user