server: derive profiles from virtual config
This commit is contained in:
@@ -19,8 +19,8 @@ pub use paths::user_profiles_path;
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
||||
resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
resolve_profile_artifact_value,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::{
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
||||
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
|
||||
|
||||
struct BuiltinProfile {
|
||||
name: &'static str,
|
||||
@@ -322,10 +321,10 @@ pub struct ProfileDiscovery {
|
||||
}
|
||||
|
||||
impl ProfileDiscovery {
|
||||
pub fn for_cwd(cwd: &Path) -> Self {
|
||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||
Self {
|
||||
user_config: paths::user_profiles_path(),
|
||||
project_config: find_project_profiles_from(cwd),
|
||||
project_config: None,
|
||||
}
|
||||
}
|
||||
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
||||
@@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot {
|
||||
pub source: ProfileSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<ProfileMetadata>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_override: Option<WorkspaceOverrideSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceOverrideSnapshot {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WorkspaceOverrideLayer {
|
||||
path: PathBuf,
|
||||
config: WorkerManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -495,7 +481,6 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)?;
|
||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
||||
let raw_artifact = read_profile_artifact_file(&absolute_path)?;
|
||||
resolve_profile_value(
|
||||
source,
|
||||
@@ -504,7 +489,6 @@ impl ProfileResolver {
|
||||
options,
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
workspace_override,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,7 +503,6 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)?;
|
||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
||||
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
||||
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
||||
})?;
|
||||
@@ -530,7 +513,6 @@ impl ProfileResolver {
|
||||
options,
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
workspace_override,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +524,6 @@ fn resolve_profile_value(
|
||||
options: ProfileResolveOptions,
|
||||
value: serde_json::Value,
|
||||
raw_artifact: serde_json::Value,
|
||||
workspace_override: Option<WorkspaceOverrideLayer>,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
if !workspace_base.is_absolute() {
|
||||
return Err(ProfileError::InvalidPath {
|
||||
@@ -585,29 +566,11 @@ fn resolve_profile_value(
|
||||
memory: profile.memory,
|
||||
skills: profile.skills,
|
||||
};
|
||||
let mut config =
|
||||
WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
|
||||
let override_base =
|
||||
override_layer
|
||||
.path
|
||||
.parent()
|
||||
.ok_or_else(|| ProfileError::InvalidPath {
|
||||
path: override_layer.path.clone(),
|
||||
message: "workspace override path has no parent directory".into(),
|
||||
})?;
|
||||
config = config.merge(override_layer.config.resolve_paths(override_base));
|
||||
Some(WorkspaceOverrideSnapshot {
|
||||
path: override_layer.path,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
profile: profile_meta.clone(),
|
||||
workspace_override: workspace_override_snapshot,
|
||||
});
|
||||
let manifest_snapshot =
|
||||
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
|
||||
@@ -751,70 +714,6 @@ fn load_profile_registry_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_workspace_override_from(
|
||||
workspace_base: &Path,
|
||||
) -> Result<Option<WorkspaceOverrideLayer>, ProfileError> {
|
||||
find_workspace_override_from(workspace_base)
|
||||
.map(|path| load_workspace_override_file(&path))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, ProfileError> {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
|
||||
ProfileError::WorkspaceOverrideParse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if config.worker.name.is_some() {
|
||||
return Err(ProfileError::InvalidWorkspaceOverride {
|
||||
path: path.to_path_buf(),
|
||||
message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(),
|
||||
});
|
||||
}
|
||||
Ok(WorkspaceOverrideLayer {
|
||||
path: path.to_path_buf(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_workspace_override_from(start: &Path) -> Option<PathBuf> {
|
||||
let start = start
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.unwrap_or_else(|| start.to_path_buf());
|
||||
let mut cur: Option<&Path> = Some(start.as_path());
|
||||
while let Some(dir) = cur {
|
||||
let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_project_profiles_from(start: &Path) -> Option<PathBuf> {
|
||||
let start = start
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.unwrap_or_else(|| start.to_path_buf());
|
||||
let mut cur: Option<&Path> = Some(start.as_path());
|
||||
while let Some(dir) = cur {
|
||||
let candidate = dir.join(".yoi").join("profiles.toml");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
||||
for profile in BUILTIN_PROFILES {
|
||||
registry.push_entry(ProfileRegistryEntry::embedded(
|
||||
@@ -1285,7 +1184,6 @@ pub fn resolve_profile_artifact_value(
|
||||
ProfileResolveOptions::with_worker_name(worker_name),
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1313,20 +1211,6 @@ pub enum ProfileError {
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("failed to read workspace local manifest override {}: {source}", .path.display())]
|
||||
WorkspaceOverrideRead {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to parse workspace local manifest override {}: {source}", .path.display())]
|
||||
WorkspaceOverrideParse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("invalid workspace local manifest override {}: {message}", .path.display())]
|
||||
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
||||
#[error("no default profile is configured")]
|
||||
NoDefaultProfile,
|
||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||
@@ -1594,7 +1478,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
|
||||
ProfileResolveOptions::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
|
||||
}
|
||||
@@ -1857,134 +1744,6 @@ worker_context_max_tokens = 68000
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_local_override_layers_over_profile_defaults() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("project");
|
||||
let nested = workspace.join("nested");
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
std::fs::write(
|
||||
&override_path,
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "prompts.toml"
|
||||
[engine]
|
||||
language = "ja"
|
||||
[session]
|
||||
record_event_trace = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(&nested)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert_eq!(resolved.manifest.engine.language, "ja");
|
||||
assert!(!resolved.manifest.session.record_event_trace);
|
||||
assert_eq!(
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(yoi_dir.join("prompts.toml").as_path())
|
||||
);
|
||||
assert!(resolved.manifest.scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
resolved
|
||||
.manifest
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.workspace_override.as_ref())
|
||||
.map(|snapshot| snapshot.path.as_path()),
|
||||
Some(override_path.as_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_override_uses_nearest_ancestor() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("project");
|
||||
let nested = workspace.join("nested");
|
||||
let child = nested.join("child");
|
||||
let parent_yoi = workspace.join(".yoi");
|
||||
let nested_yoi = nested.join(".yoi");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
std::fs::create_dir_all(&parent_yoi).unwrap();
|
||||
std::fs::create_dir_all(&nested_yoi).unwrap();
|
||||
std::fs::write(
|
||||
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "parent-prompts.toml"
|
||||
[engine]
|
||||
language = "parent"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
std::fs::write(
|
||||
&nested_override_path,
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "nested-prompts.toml"
|
||||
[engine]
|
||||
language = "nested"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(&child)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.engine.language, "nested");
|
||||
assert_eq!(
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(nested_yoi.join("nested-prompts.toml").as_path())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved
|
||||
.manifest
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.workspace_override.as_ref())
|
||||
.map(|snapshot| snapshot.path.as_path()),
|
||||
Some(nested_override_path.as_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_override_rejects_runtime_worker_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let yoi_dir = tmp.path().join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
std::fs::write(
|
||||
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||
"[worker]\nname = \"not-local\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
||||
assert!(err.to_string().contains("worker.name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_profile_extension_has_clear_diagnostic() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2003,7 +1762,7 @@ language = "nested"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
|
||||
fn explicit_discovery_sources_preserve_non_workspace_tooling_contract() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_config = tmp.path().join("profiles.toml");
|
||||
let project_dir = tmp.path().join("project/.yoi");
|
||||
|
||||
+12
-52
@@ -1,7 +1,7 @@
|
||||
//! Inline-viewport "spawn Worker and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `yoi` is invoked
|
||||
//! with no positional argument. Discovers `.yoi/profiles.toml` profile
|
||||
//! with no positional argument. Uses user-configured and bundled Profile
|
||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||
//! the Worker's name, and on confirmation launches the Worker runtime command as an
|
||||
//! independent process. Once the process reports its socket via the
|
||||
@@ -654,68 +654,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_use_project_registry_default() {
|
||||
fn profile_choices_ignore_repository_local_profile_registry() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let yoi = project.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi).unwrap();
|
||||
std::fs::write(
|
||||
yoi.join("profiles.toml"),
|
||||
r#"
|
||||
default = "coder"
|
||||
[profile]
|
||||
coder = "profiles/coder.toml"
|
||||
"#,
|
||||
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
let default_choice = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
||||
.expect("project default choice is present");
|
||||
assert_eq!(default_index, default_choice);
|
||||
let selected = &choices[default_index];
|
||||
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(selected.label, "project:coder (default)");
|
||||
assert!(selected.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_include_builtin_and_project_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let yoi = project.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi).unwrap();
|
||||
std::fs::write(
|
||||
yoi.join("profiles.toml"),
|
||||
r#"
|
||||
default = "coder"
|
||||
[profile.coder]
|
||||
path = "profiles/coder.toml"
|
||||
description = "Project coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("builtin:companion"));
|
||||
assert_eq!(
|
||||
choices[0].label,
|
||||
"builtin:companion — Bundled Companion role profile"
|
||||
assert_eq!(default_index, 0);
|
||||
assert!(
|
||||
choices
|
||||
.iter()
|
||||
.all(|choice| { choice.selector.as_deref() != Some("project:coder") })
|
||||
);
|
||||
let project_index = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
||||
.expect("project default choice is present");
|
||||
assert_eq!(default_index, project_index);
|
||||
assert_eq!(
|
||||
choices[project_index].selector.as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
assert_eq!(
|
||||
choices[project_index].label,
|
||||
"project:coder (default) — Project coder"
|
||||
assert!(
|
||||
choices
|
||||
.iter()
|
||||
.any(|choice| { choice.selector.as_deref() == Some("builtin:companion") })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -764,7 +764,7 @@ permission = "write"
|
||||
let yoi_dir = tmp.path().join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
write(
|
||||
&yoi_dir.join("override.local.toml"),
|
||||
&yoi_dir.join("ignored-local-file.toml"),
|
||||
r#"
|
||||
[worker]
|
||||
name = "from-local-override"
|
||||
@@ -822,7 +822,7 @@ permission = "write"
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
write(
|
||||
&yoi_dir.join("override.local.toml"),
|
||||
&yoi_dir.join("ignored-local-file.toml"),
|
||||
r#"
|
||||
[worker]
|
||||
name = "from-local-override"
|
||||
|
||||
@@ -1550,8 +1550,7 @@ extract_threshold = 4000
|
||||
default: Option<&str>,
|
||||
profiles: &[(&str, &str, &str)],
|
||||
) -> AvailableProfiles {
|
||||
let yoi = project.join(".yoi");
|
||||
let profile_dir = yoi.join("profiles");
|
||||
let profile_dir = project.join("explicit-project-profiles");
|
||||
std::fs::create_dir_all(&profile_dir).unwrap();
|
||||
let mut registry_toml = String::new();
|
||||
if let Some(default) = default {
|
||||
@@ -1560,9 +1559,9 @@ extract_threshold = 4000
|
||||
registry_toml.push_str("[profile]\n");
|
||||
for (name, file, body) in profiles {
|
||||
std::fs::write(profile_dir.join(file), body).unwrap();
|
||||
registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n"));
|
||||
registry_toml.push_str(&format!("{name} = \"explicit-project-profiles/{file}\"\n"));
|
||||
}
|
||||
let registry_path = yoi.join("profiles.toml");
|
||||
let registry_path = project.join("explicit-project-profiles.toml");
|
||||
std::fs::write(®istry_path, registry_toml).unwrap();
|
||||
AvailableProfiles {
|
||||
registry: Some(
|
||||
@@ -1944,7 +1943,7 @@ max_tokens = 3333
|
||||
|
||||
let user_config = tmp.path().join("user-profiles.toml");
|
||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
||||
let project_config = project.join(".yoi/profiles.toml");
|
||||
let project_config = project.join("explicit-project-profiles.toml");
|
||||
let ambiguous = AvailableProfiles {
|
||||
registry: Some(
|
||||
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
|
||||
|
||||
@@ -45,6 +45,30 @@ impl WorkspaceConfigSchemaRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_state(
|
||||
state: &WorkspaceConfigState,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluationResult> {
|
||||
let expected_fingerprint = state.contract.fingerprint.clone();
|
||||
let contract = main_config_contract_with_schema(schema_bundle);
|
||||
if !state.contract.schema_bundle.contributions.is_empty()
|
||||
&& contract.fingerprint != expected_fingerprint
|
||||
{
|
||||
return Err(Error::RegistryInconsistency(
|
||||
"active Workspace config schema fingerprint does not match the current provider bundle"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.map_err(|diagnostics| {
|
||||
Error::InvalidInput(
|
||||
serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "virtual config evaluation failed".to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn main_config_contract_with_schema(
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> ToolchainContract {
|
||||
@@ -873,6 +897,44 @@ mod tests {
|
||||
assert!(error.to_string().contains("toolchain fingerprint mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_state_evaluation_rejects_provider_fingerprint_drift() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[ConfigEntry::new(
|
||||
path(MAIN_CONFIG_ENTRYPOINT),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let persisted_bundle =
|
||||
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:test",
|
||||
"test",
|
||||
"1",
|
||||
r#"{ test = { value = String default "one"; }; }"#,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let state = WorkspaceConfigState {
|
||||
projection_digest: "persisted".to_string(),
|
||||
contract: main_config_contract_with_schema(persisted_bundle),
|
||||
snapshot,
|
||||
};
|
||||
let changed_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:test",
|
||||
"test",
|
||||
"2",
|
||||
r#"{ test = { value = String default "two"; }; }"#,
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let error = evaluate_workspace_config_state(&state, changed_bundle).unwrap_err();
|
||||
assert!(error.to_string().contains("schema fingerprint"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_materializes_main_entrypoint() {
|
||||
let store = open_store().await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,12 +84,7 @@ use crate::observation::{
|
||||
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
||||
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
||||
};
|
||||
use crate::profile_settings::{
|
||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
||||
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
|
||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||
};
|
||||
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
|
||||
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||
use crate::repositories::{
|
||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||
@@ -756,9 +751,17 @@ impl WorkspaceApi {
|
||||
let config_store = Arc::new(crate::SqliteWorkspaceStore::open(
|
||||
config.database_path.clone(),
|
||||
)?);
|
||||
config_store.ensure_workspace_config_materialized(
|
||||
&config.workspace_id,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
)?;
|
||||
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||
));
|
||||
let api = Self {
|
||||
config_store,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(),
|
||||
config_schema_registry,
|
||||
authority: SqliteWorkspaceAuthority::new(
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
@@ -1171,27 +1174,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles",
|
||||
get(scoped_get_profile_settings).post(scoped_create_profile_source),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/registry",
|
||||
put(scoped_update_profile_registry),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
|
||||
get(scoped_get_profile_source_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
|
||||
get(scoped_get_profile_tree_file)
|
||||
.put(scoped_write_profile_tree_file)
|
||||
.delete(scoped_delete_profile_tree_file),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
||||
get(scoped_get_profile_source)
|
||||
.put(scoped_update_profile_source)
|
||||
.delete(scoped_delete_profile_source),
|
||||
get(scoped_get_profile_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/flows",
|
||||
@@ -2577,130 +2560,19 @@ async fn scoped_get_profile_settings(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::load_profile_settings(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scoped_create_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::create_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_registry(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<UpdateWorkspaceProfileRegistryRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_registry(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source_tree(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
query,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_write_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::write_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceDetailResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<UpdateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::InvalidRecordId("virtual config source tree".into()))
|
||||
})?;
|
||||
Ok(Json(
|
||||
crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&path.workspace_id,
|
||||
&state,
|
||||
)?
|
||||
.settings,
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_list_tickets(
|
||||
@@ -5134,12 +5006,7 @@ fn start_memory_staging_consolidation(
|
||||
}
|
||||
|
||||
let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string());
|
||||
let resolved_config_bundle = crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
MEMORY_CONSOLIDATION_PROFILE,
|
||||
)?;
|
||||
let resolved_config_bundle = None;
|
||||
let initial_submit = vec![Segment::text(input_content)];
|
||||
let result = api.spawn_workspace_worker(
|
||||
&runtime_id,
|
||||
@@ -8142,7 +8009,7 @@ async fn test_remote_runtime_connection(
|
||||
async fn get_worker_launch_options(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
|
||||
Ok(Json(worker_launch_options_response(&api)))
|
||||
Ok(Json(worker_launch_options_response(&api)?))
|
||||
}
|
||||
|
||||
fn working_directory_request_from_repository(
|
||||
@@ -8298,18 +8165,20 @@ async fn create_workspace_worker(
|
||||
initial_submit,
|
||||
working_directory,
|
||||
} = request;
|
||||
let config_state = api
|
||||
.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)?
|
||||
.ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?;
|
||||
let profile_projection = crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&api.config.workspace_id,
|
||||
&config_state,
|
||||
)?;
|
||||
let profile = profile
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile| !profile.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
crate::profile_settings::load_profile_settings(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
)
|
||||
.default_profile
|
||||
})
|
||||
.or_else(|| profile_projection.settings.default_profile.clone())
|
||||
.ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"workspace_default_profile_missing",
|
||||
@@ -8317,24 +8186,20 @@ async fn create_workspace_worker(
|
||||
)
|
||||
})?;
|
||||
let profile_selector =
|
||||
profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else(
|
||||
|| {
|
||||
crate::profile_settings::selector_for_workspace_candidate(&profile_projection, &profile)
|
||||
.ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"unsupported_worker_profile",
|
||||
"profile must be selected from Backend-published worker profile candidates",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
let resolved_config_bundle = if profile.starts_with("project:") {
|
||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&profile,
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
})?;
|
||||
let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle(
|
||||
&profile_projection,
|
||||
&config_state,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&profile,
|
||||
)?;
|
||||
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"invalid_worker_display_name",
|
||||
@@ -10437,7 +10302,7 @@ async fn probe_remote_json(
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse {
|
||||
fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchOptionsResponse> {
|
||||
let runtimes = api
|
||||
.runtime
|
||||
.list_runtimes(api.config.max_records.min(200))
|
||||
@@ -10456,10 +10321,17 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let profile_settings = crate::profile_settings::load_profile_settings(
|
||||
let config_state = api
|
||||
.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::InvalidRecordId("virtual config source tree".into()))
|
||||
})?;
|
||||
let profile_settings = crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
);
|
||||
&config_state,
|
||||
)?
|
||||
.settings;
|
||||
let profiles = profile_settings
|
||||
.profiles
|
||||
.into_iter()
|
||||
@@ -10477,7 +10349,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
.unwrap_or_else(|| "Workspace profile.".to_string()),
|
||||
})
|
||||
.collect();
|
||||
WorkerLaunchOptionsResponse {
|
||||
Ok(WorkerLaunchOptionsResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtimes,
|
||||
default_profile: profile_settings.default_profile,
|
||||
@@ -10485,7 +10357,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
repositories: working_directory_repository_options(api),
|
||||
working_directories: available_working_directory_summaries(api).unwrap_or_default(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn working_directory_repository_options(
|
||||
@@ -11069,27 +10941,6 @@ fn working_directory_request_for_browser(
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_selector_for_candidate_with_root(
|
||||
workspace_root: &Path,
|
||||
profile: &str,
|
||||
) -> Option<ProfileSelector> {
|
||||
if let Some(selector @ ProfileSelector::Builtin(_)) =
|
||||
crate::profile_settings::selector_for_builtin_candidate(profile)
|
||||
{
|
||||
return Some(selector);
|
||||
}
|
||||
crate::profile_settings::project_profile_candidates(workspace_root)
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
candidate.profile_id == profile
|
||||
&& !candidate
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
||||
})
|
||||
.and_then(|_| crate::profile_settings::selector_for_builtin_candidate(profile))
|
||||
}
|
||||
|
||||
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> {
|
||||
worker_id.parse::<u64>().map_err(|_| {
|
||||
settings_bad_request(
|
||||
@@ -12776,93 +12627,6 @@ mod tests {
|
||||
assert!(!serialized.contains("materialized_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_profile_candidates_are_backend_published_and_mapped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"default = \"builtin:companion\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = crate::profile_settings::load_profile_settings("workspace-test", dir.path());
|
||||
for expected in [
|
||||
"builtin:companion",
|
||||
"builtin:intake",
|
||||
"builtin:orchestrator",
|
||||
"builtin:coder",
|
||||
"builtin:reviewer",
|
||||
] {
|
||||
assert!(
|
||||
settings
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.profile_id == expected),
|
||||
"missing {expected}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
settings
|
||||
.profiles
|
||||
.iter()
|
||||
.all(|profile| profile.profile_id != "builtin:default")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.default_profile.as_deref(),
|
||||
Some("builtin:companion")
|
||||
);
|
||||
assert!(matches!(
|
||||
profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"),
|
||||
Some(ProfileSelector::Builtin(value)) if value == "builtin:coder"
|
||||
));
|
||||
assert!(
|
||||
profile_selector_for_candidate_with_root(dir.path(), "free-text-profile").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_launch_options_publish_every_valid_workspace_profile_and_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
concat!(
|
||||
"default = \"builtin:companion\"\n",
|
||||
"[profile.custom]\n",
|
||||
"path = \".yoi/profiles/custom.dcdl\"\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles/custom.dcdl"),
|
||||
"slug = \"custom\"; description = \"Custom profile\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_app(dir.path()).await;
|
||||
|
||||
let response = get_json(api, "/api/workers/launch-options").await;
|
||||
assert_eq!(response["default_profile"], "builtin:companion");
|
||||
let profiles = response["profiles"].as_array().unwrap();
|
||||
for expected in [
|
||||
"builtin:companion",
|
||||
"builtin:intake",
|
||||
"builtin:orchestrator",
|
||||
"builtin:coder",
|
||||
"builtin:reviewer",
|
||||
"project:custom",
|
||||
] {
|
||||
assert!(
|
||||
profiles.iter().any(|profile| profile["id"] == expected),
|
||||
"missing {expected}: {profiles:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
profiles
|
||||
.iter()
|
||||
.all(|profile| profile["id"] != "builtin:default")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_connection_request_validation_bounds_browser_input() {
|
||||
let ok = AddRemoteRuntimeConnectionRequest {
|
||||
@@ -12911,218 +12675,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "coder",
|
||||
"content": valid_profile_source("coder"),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_selector_duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "bad",
|
||||
"content": "not decodal",
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
diagnostic_codes(&response.1)
|
||||
.iter()
|
||||
.any(|code| code.starts_with("profile_source_"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "schema",
|
||||
"content": valid_profile_source("schema"),
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml"))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_registry_schema_invalid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "alpha",
|
||||
"content": valid_profile_source("alpha"),
|
||||
"registry_revision": "stale"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::CONFLICT);
|
||||
assert_diagnostic(&response.1, "profile_registry_revision_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "large",
|
||||
"content": "x".repeat((256 * 1024) + 1),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_too_large");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_redacts_symlink_escape_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
||||
let outside = dir.path().join("outside.dcdl");
|
||||
fs::write(&outside, valid_profile_source("escape")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"PUT",
|
||||
"/settings/profiles/registry",
|
||||
json!({
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")),
|
||||
"default_profile": null,
|
||||
"profiles": [{ "name": "escape", "profile_source_id": "project:escape" }]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_symlink_escape");
|
||||
let rendered = response.1.to_string();
|
||||
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_launch_rejects_invalid_project_profile_candidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"[profile.bad]\npath = \"profiles/bad.dcdl\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
|
||||
let candidates =
|
||||
crate::profile_settings::load_profile_settings("workspace-test", dir.path())
|
||||
.profiles
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
!profile
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
||||
})
|
||||
.map(|profile| profile.profile_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!candidates.iter().any(|profile| profile == "project:bad"));
|
||||
assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none());
|
||||
}
|
||||
|
||||
fn valid_profile_source(slug: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
slug = "{slug}";
|
||||
description = "Test";
|
||||
scope = "workspace_read";
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn test_file_revision(path: &Path) -> String {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return "missing".to_string();
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or_default();
|
||||
format!("rev:{modified}:{}", metadata.len())
|
||||
}
|
||||
|
||||
async fn profile_settings_request(
|
||||
workspace_root: &Path,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let app = test_app(workspace_root.to_path_buf()).await;
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json = serde_json::from_slice::<Value>(&bytes).unwrap();
|
||||
(status, json)
|
||||
}
|
||||
|
||||
fn diagnostic_codes(response: &Value) -> Vec<String> {
|
||||
response["diagnostics"]
|
||||
.as_array()
|
||||
.expect("diagnostics array")
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_diagnostic(response: &Value, code: &str) {
|
||||
let codes = diagnostic_codes(response);
|
||||
assert!(
|
||||
!codes.is_empty(),
|
||||
"diagnostics must not be empty: {response}"
|
||||
);
|
||||
assert!(
|
||||
codes.iter().any(|actual| actual == code),
|
||||
"missing {code}: {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
struct DeterministicExecutionBackend {
|
||||
contexts: std::sync::Mutex<
|
||||
std::collections::HashMap<
|
||||
@@ -13405,14 +12957,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved_config_bundle =
|
||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
MEMORY_CONSOLIDATION_PROFILE,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved_config_bundle = None;
|
||||
let existing = api
|
||||
.runtime
|
||||
.spawn_worker(
|
||||
@@ -16064,12 +15609,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"default = \"builtin:coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let created = post_json(
|
||||
app.clone(),
|
||||
@@ -16090,7 +15629,7 @@ mod tests {
|
||||
.find(|worker| worker["worker_id"] == created["worker_id"])
|
||||
.expect("created Worker should be listed");
|
||||
assert_eq!(worker["label"], "Worker");
|
||||
assert_eq!(worker["profile"], "builtin:coder");
|
||||
assert_eq!(worker["profile"], "builtin:companion");
|
||||
assert!(worker.get("role").is_none());
|
||||
assert_eq!(worker["worker_id"], created["worker_id"]);
|
||||
let detail_path = format!(
|
||||
@@ -17745,6 +17284,37 @@ mod tests {
|
||||
assert_eq!(shown["linked_tickets"], json!(["00000000001J3"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_are_read_only_virtual_config_projection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles");
|
||||
let settings = get_json(app.clone(), &path).await;
|
||||
assert_eq!(settings["default_profile"], "builtin:companion");
|
||||
assert_eq!(settings["config_revision"], 0);
|
||||
assert!(settings["tree_digest"].as_str().is_some());
|
||||
assert!(settings["projection_digest"].as_str().is_some());
|
||||
assert!(
|
||||
settings["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|profile| profile["editable"] == false)
|
||||
);
|
||||
let mutation = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mutation.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
async fn get_json(app: Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
|
||||
Reference in New Issue
Block a user