diff --git a/Cargo.lock b/Cargo.lock index e4f7f2bb..7a313ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2629,6 +2629,7 @@ version = "0.1.0" dependencies = [ "agen", "arc-swap", + "decodal", "protocol", "secrets", "serde", diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index c8150391..f41f4b20 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] arc-swap = "1" agen = { workspace = true } +decodal.workspace = true protocol = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs new file mode 100644 index 00000000..a1edfa25 --- /dev/null +++ b/crates/manifest/src/builtin_profile.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; + +use decodal::{Data, Engine, ImportLoader, LoadedImport}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest, Sha256}; + +use crate::profile::ProfileError; + +pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; +pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileImport { + pub specifier: &'static str, + pub resolved_path: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileResource { + pub selector: Option<&'static str>, + pub path: &'static str, + pub source: &'static str, + pub description: &'static str, + pub imports: &'static [BuiltinProfileImport], +} + +const BASE_PATH: &str = "profiles/base.dcdl"; +const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { + specifier: "./base.dcdl", + resolved_path: BASE_PATH, +}]; +const NO_IMPORTS: &[BuiltinProfileImport] = &[]; + +pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ + BuiltinProfileResource { + selector: None, + path: BASE_PATH, + source: include_str!("../../../resources/profiles/base.dcdl"), + description: "Shared built-in Profile defaults.", + imports: NO_IMPORTS, + }, + BuiltinProfileResource { + selector: Some(BUILTIN_DEFAULT_PROFILE), + path: "profiles/default.dcdl", + source: include_str!("../../../resources/profiles/default.dcdl"), + description: "Standalone Yoi coding profile.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:coder"), + path: "profiles/coder.dcdl", + source: include_str!("../../../resources/profiles/coder.dcdl"), + description: "Ticket implementation with direct Reviewer SubWorkers.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:companion"), + path: "profiles/companion.dcdl", + source: include_str!("../../../resources/profiles/companion.dcdl"), + description: "General assistance with Workspace tools.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:intake"), + path: "profiles/intake.dcdl", + source: include_str!("../../../resources/profiles/intake.dcdl"), + description: "Read-only intake and planning.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:reviewer"), + path: "profiles/reviewer.dcdl", + source: include_str!("../../../resources/profiles/reviewer.dcdl"), + description: "Independent review of a published Merge Request source.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:orchestrator"), + path: "profiles/orchestrator.dcdl", + source: include_str!("../../../resources/profiles/orchestrator.dcdl"), + description: "Workspace orchestration and Worker control.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:memory-consolidation"), + path: "profiles/memory-consolidation.dcdl", + source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"), + description: "Internal Memory consolidation service.", + imports: BASE_IMPORT, + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltinProfileCatalogSnapshot { + pub id: &'static str, + pub sources: BTreeMap, + pub entrypoints: BTreeMap, + pub imports: BTreeMap, +} + +impl BuiltinProfileCatalogSnapshot { + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.id.as_bytes()); + for (path, source) in &self.sources { + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source.as_bytes()); + } + for (selector, path) in &self.entrypoints { + hasher.update((selector.len() as u64).to_le_bytes()); + hasher.update(selector.as_bytes()); + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + } + for (request, resolved_path) in &self.imports { + hasher.update((request.len() as u64).to_le_bytes()); + hasher.update(request.as_bytes()); + hasher.update((resolved_path.len() as u64).to_le_bytes()); + hasher.update(resolved_path.as_bytes()); + } + format!("sha256:{:x}", hasher.finalize()) + } +} + +pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot { + let mut sources = BTreeMap::new(); + let mut entrypoints = BTreeMap::new(); + let mut imports = BTreeMap::new(); + + for resource in BUILTIN_PROFILE_RESOURCES { + sources.insert(resource.path.to_owned(), resource.source.to_owned()); + for import in resource.imports { + imports.insert( + format!("{}\0{}", resource.path, import.specifier), + import.resolved_path.to_owned(), + ); + } + if let Some(selector) = resource.selector { + entrypoints.insert(selector.to_owned(), resource.path.to_owned()); + } + } + + BuiltinProfileCatalogSnapshot { + id: BUILTIN_PROFILE_CATALOG_ID, + sources, + entrypoints, + imports, + } +} + +pub fn builtin_profile_entrypoints() -> impl Iterator { + BUILTIN_PROFILE_RESOURCES + .iter() + .filter(|resource| resource.selector.is_some()) +} + +pub(crate) fn resolve_builtin_profile_artifact( + selector: &str, +) -> Result, ProfileError> { + let catalog = builtin_profile_catalog_snapshot(); + let Some(entrypoint) = catalog.entrypoints.get(selector) else { + return Ok(None); + }; + let source = catalog + .sources + .get(entrypoint) + .expect("built-in Profile entrypoint must name a source") + .clone(); + let mut engine = Engine::new(BuiltinProfileImportLoader { + sources: catalog.sources, + }); + let module = engine + .add_root_source(entrypoint, entrypoint, &source) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + let value = + engine + .eval_module(module) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + let data = + engine + .materialize(&value) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + Ok(Some(data_to_json(&data))) +} + +#[derive(Debug)] +struct BuiltinProfileImportLoader { + sources: BTreeMap, +} + +impl ImportLoader for BuiltinProfileImportLoader { + fn load( + &mut self, + current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + let current_key = current_key.ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` has no source context"), + ) + })?; + let resolved = resolve_import_path(current_key, specifier).ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` from `{current_key}` is invalid"), + ) + })?; + let source = self.sources.get(&resolved).ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` from `{current_key}` was not found"), + ) + })?; + Ok(LoadedImport::source( + resolved.clone(), + resolved, + source.clone(), + )) + } +} + +fn resolve_import_path(current_key: &str, specifier: &str) -> Option { + let current_parent = current_key + .rsplit_once('/') + .map_or("", |(parent, _)| parent); + let joined = if let Some(relative) = specifier.strip_prefix("./") { + format!("{current_parent}/{relative}") + } else { + return None; + }; + if joined + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return None; + } + Some(joined) +} + +fn data_to_json(data: &Data) -> Value { + match data { + Data::Bool(value) => Value::Bool(*value), + Data::Int(value) => Value::Number(Number::from(*value)), + Data::Float(value) => Number::from_f64(*value) + .map(Value::Number) + .unwrap_or(Value::Null), + Data::String(value) => Value::String(value.clone()), + Data::Array(values) => Value::Array(values.iter().map(data_to_json).collect()), + Data::Object(fields) => Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), data_to_json(&field.value))) + .collect::>(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_has_one_explicit_entrypoint_for_each_builtin_profile() { + let catalog = builtin_profile_catalog_snapshot(); + assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len()); + assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len()); + assert_eq!( + catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE), + Some(&"profiles/default.dcdl".to_owned()) + ); + assert!(catalog.digest().starts_with("sha256:")); + } + + #[test] + fn default_profile_evaluates_from_the_shared_resource_graph() { + let value = resolve_builtin_profile_artifact(BUILTIN_DEFAULT_PROFILE) + .expect("evaluate built-in default") + .expect("default exists"); + assert_eq!(value["slug"], "default"); + assert_eq!(value["feature"]["task"]["enabled"], true); + assert_eq!(value["feature"]["sub_worker"]["enabled"], true); + assert_eq!(value["feature"]["memory"]["enabled"], false); + assert_eq!(value["feature"]["ticket"]["enabled"], false); + assert_eq!(value["feature"]["worker"]["enabled"], false); + assert_eq!(value["feature"]["manage_workdir"]["enabled"], false); + } + + #[test] + fn imports_cannot_escape_the_builtin_resource_catalog() { + assert_eq!( + resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(), + Some("profiles/base.dcdl") + ); + assert_eq!( + resolve_import_path("profiles/default.dcdl", "../outside.dcdl"), + None + ); + assert_eq!( + resolve_import_path("profiles/default.dcdl", "/outside.dcdl"), + None + ); + } +} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 9671675d..f263dd14 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -566,15 +566,16 @@ impl WorkerManifestConfig { }) } - /// Base config populated with the in-code defaults listed in - /// [`crate::defaults`]. Profile and one-file Manifest resolvers start - /// from this layer so every per-field default lives at exactly one - /// call site (the `defaults` module). + /// Base config populated with the in-code per-field defaults listed in + /// [`crate::defaults`]. This is not a selectable Profile and does not + /// enable a launch capability surface. Profile and one-file Manifest + /// resolvers start from this layer so every per-field default lives at + /// exactly one call site (the `defaults` module). /// /// `TryFrom` also reads the same constants as a /// belt-and-suspenders fallback, so a manually-constructed config /// that skips this layer still resolves to the same values. - pub fn builtin_defaults() -> Self { + pub fn resolution_defaults() -> Self { Self { engine: EngineManifestConfig { tool_output: ToolOutputLimitsPartial { @@ -1973,7 +1974,7 @@ enabled = false "#, ) .unwrap(); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(cfg) .merge(WorkerManifestConfig { worker: WorkerMetaConfig { @@ -2074,7 +2075,7 @@ enabled = true "#, ) .unwrap(); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(base) .merge(upper) .merge(WorkerManifestConfig { @@ -2137,7 +2138,7 @@ permission = "write" #[test] fn builtin_defaults_populates_worker_limit_defaults() { - let cfg = WorkerManifestConfig::builtin_defaults(); + let cfg = WorkerManifestConfig::resolution_defaults(); assert_eq!( cfg.engine.tool_output.default_max_bytes, Some(defaults::TOOL_OUTPUT_MAX_BYTES) @@ -2172,7 +2173,7 @@ permission = "write" }, ..Default::default() }; - let merged = WorkerManifestConfig::builtin_defaults().merge(overlay); + let merged = WorkerManifestConfig::resolution_defaults().merge(overlay); let manifest: WorkerManifest = merged.try_into().unwrap(); assert_eq!( manifest.engine.tool_output.default_max_bytes, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 3d057c05..7920c050 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -1,3 +1,4 @@ +mod builtin_profile; mod config; pub mod defaults; mod model; @@ -7,6 +8,11 @@ pub mod plugin; mod profile; mod scope; +pub use builtin_profile::{ + BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES, + BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource, + builtin_profile_catalog_snapshot, builtin_profile_entrypoints, +}; pub use config::{ CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, @@ -17,10 +23,11 @@ pub use model::{ }; pub use paths::user_profiles_path; pub use profile::{ - ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, - ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, - resolve_profile_artifact_value, + ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, + ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, + ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile, + WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value, + validate_profile_execution_target, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index d84c195d..64ae4050 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -6,9 +6,14 @@ //! from launch context. use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::path::{Path, PathBuf}; +use crate::builtin_profile::{ + BUILTIN_DEFAULT_PROFILE, builtin_profile_catalog_snapshot, builtin_profile_entrypoints, + resolve_builtin_profile_artifact, +}; use crate::config::{ CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, }; @@ -23,45 +28,6 @@ use crate::{ const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); -struct BuiltinProfile { - name: &'static str, - label: &'static str, - description: &'static str, -} - -const BUILTIN_PROFILES: &[BuiltinProfile] = &[ - BuiltinProfile { - name: "companion", - label: "builtin:companion", - description: "Bundled Companion role profile", - }, - BuiltinProfile { - name: "intake", - label: "builtin:intake", - description: "Bundled Intake role profile", - }, - BuiltinProfile { - name: "orchestrator", - label: "builtin:orchestrator", - description: "Bundled Orchestrator role profile", - }, - BuiltinProfile { - name: "coder", - label: "builtin:coder", - description: "Bundled Coder role profile", - }, - BuiltinProfile { - name: "reviewer", - label: "builtin:reviewer", - description: "Bundled Reviewer role profile", - }, - BuiltinProfile { - name: "memory-consolidation", - label: "builtin:memory-consolidation", - description: "Bundled Memory staging consolidation profile", - }, -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProfileRegistrySource { @@ -159,6 +125,108 @@ impl ProfileSelector { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileExecutionTarget { + Workspace, + Standalone, +} + +impl fmt::Display for ProfileExecutionTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Workspace => formatter.write_str("workspace"), + Self::Standalone => formatter.write_str("standalone"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum WorkspaceAuthorityRequirement { + Flow, + ManageWorkdir, + Memory, + MergeRequest, + Objective, + Orchestration, + Plugins, + Ticket, + Worker, +} + +impl fmt::Display for WorkspaceAuthorityRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Flow => formatter.write_str("feature.flow"), + Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"), + Self::Memory => formatter.write_str("feature.memory"), + Self::MergeRequest => formatter.write_str("feature.merge_request"), + Self::Objective => formatter.write_str("feature.objective"), + Self::Orchestration => formatter.write_str("feature.orchestration"), + Self::Plugins => formatter.write_str("feature.plugins or plugin packages"), + Self::Ticket => formatter.write_str("feature.ticket"), + Self::Worker => formatter.write_str("feature.worker"), + } + } +} + +pub fn validate_profile_execution_target( + manifest: &WorkerManifest, + target: ProfileExecutionTarget, +) -> Result<(), ProfileError> { + if target == ProfileExecutionTarget::Workspace { + return Ok(()); + } + + let feature = &manifest.feature; + let mut requirements = BTreeSet::new(); + if feature.flow.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Flow); + } + if feature.manage_workdir.enabled { + requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); + } + if feature.memory.enabled || feature.memory.staging { + requirements.insert(WorkspaceAuthorityRequirement::Memory); + } + if feature.merge_request.show + || feature.merge_request.open + || feature.merge_request.review + || feature.merge_request.readiness_check + || feature.merge_request.complete + { + requirements.insert(WorkspaceAuthorityRequirement::MergeRequest); + } + if feature.objective.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Objective); + } + if feature.orchestration.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Orchestration); + } + if feature.plugins.enabled || !manifest.plugins.is_empty() { + requirements.insert(WorkspaceAuthorityRequirement::Plugins); + } + if feature.ticket.enabled + || feature.ticket.authoring + || feature.ticket.thread + || feature.ticket.intake + || feature.ticket.workflow + { + requirements.insert(WorkspaceAuthorityRequirement::Ticket); + } + if feature.worker.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Worker); + } + + if requirements.is_empty() { + Ok(()) + } else { + Err(ProfileError::UnsupportedExecutionTarget { + target, + requirements: requirements.into_iter().collect(), + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ProfileSource { @@ -217,13 +285,14 @@ impl ProfileRegistryEntry { source: ProfileRegistrySource, name: &'static str, label: &'static str, + provenance: String, description: Option, ) -> Self { Self { source, name: name.to_string(), path: None, - provenance: label.to_string(), + provenance, description, is_default: false, artifact: ProfileRegistryArtifact::Builtin { label }, @@ -321,12 +390,16 @@ pub struct ProfileDiscovery { } impl ProfileDiscovery { - pub fn for_cwd(_cwd: &Path) -> Self { + pub fn user_settings() -> Self { Self { user_config: paths::user_profiles_path(), project_config: None, } } + + pub fn for_cwd(_cwd: &Path) -> Self { + Self::user_settings() + } pub fn with_sources(user_config: Option, project_config: Option) -> Self { Self { user_config, @@ -412,15 +485,22 @@ impl ProfileResolver { options, ), ProfileSelector::Named { .. } | ProfileSelector::Default => { - let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { - path: PathBuf::from("."), - source, - })?; - let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; + let registry = ProfileDiscovery::user_settings().discover()?; self.resolve_from_registry(selector, ®istry, options) } } } + + pub fn resolve_for_target( + &self, + selector: &ProfileSelector, + options: ProfileResolveOptions, + target: ProfileExecutionTarget, + ) -> Result { + let resolved = self.resolve(selector, options)?; + validate_profile_execution_target(&resolved.manifest, target)?; + Ok(resolved) + } /// Resolve a registry/default selector against an already-discovered /// registry. Callers such as SubWorkerSpawn use this to bind discovery to the /// Worker's cwd instead of the process current directory. @@ -503,7 +583,7 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| { + let raw_artifact = resolve_builtin_profile_artifact(label)?.ok_or_else(|| { ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`")) })?; resolve_profile_value( @@ -565,7 +645,8 @@ fn resolve_profile_value( memory: profile.memory.map(Into::into), skills: profile.skills, }; - let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); + let config = + WorkerManifestConfig::resolution_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(), @@ -759,14 +840,30 @@ fn load_profile_registry_file( } fn add_builtin_profiles(registry: &mut ProfileRegistry) { - for profile in BUILTIN_PROFILES { + let catalog = builtin_profile_catalog_snapshot(); + let digest = catalog.digest(); + for profile in builtin_profile_entrypoints() { + let label = profile + .selector + .expect("built-in Profile entrypoint must have a selector"); + let name = label + .strip_prefix("builtin:") + .expect("built-in Profile selector must be source-qualified"); registry.push_entry(ProfileRegistryEntry::embedded( ProfileRegistrySource::Builtin, - profile.name, - profile.label, + name, + label, + format!("{}#{digest}", profile.path), Some(profile.description.into()), )); } + registry.set_default(ProfileDefault { + source: Some(ProfileRegistrySource::Builtin), + name: BUILTIN_DEFAULT_PROFILE + .strip_prefix("builtin:") + .expect("built-in default selector must be source-qualified") + .to_owned(), + }); } fn parse_profile_ref(raw: &str) -> (Option, String) { @@ -804,201 +901,6 @@ fn read_profile_artifact_file(path: &Path) -> Result Option { - let mut value = builtin_base_profile_artifact(); - match label { - "builtin:companion" | "companion" => { - apply_role_profile( - &mut value, - "companion", - "Workspace companion profile.", - "workspace_write", - true, - true, - true, - true, - ); - Some(value) - } - "builtin:intake" | "intake" => { - apply_role_profile( - &mut value, - "intake", - "Ticket intake profile.", - "workspace_write", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:orchestrator" | "orchestrator" => { - apply_role_profile( - &mut value, - "orchestrator", - "Ticket orchestrator profile.", - "workspace_write", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:coder" | "coder" => { - apply_role_profile( - &mut value, - "coder", - "Ticket implementation coder profile.", - "workspace_write", - true, - true, - true, - true, - ); - Some(value) - } - "builtin:reviewer" | "reviewer" => { - apply_role_profile( - &mut value, - "reviewer", - "Ticket review profile.", - "workspace_read", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:memory-consolidation" | "memory-consolidation" => { - value["slug"] = serde_json::Value::String("memory-consolidation".to_string()); - value["description"] = - serde_json::Value::String("Memory staging consolidation profile.".to_string()); - value["feature"]["task"] = serde_json::json!({ "enabled": false }); - value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true }); - value["feature"]["web"] = serde_json::json!({ "enabled": false }); - value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false }); - value["feature"]["objective"] = serde_json::json!({ "enabled": false }); - value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false }); - Some(value) - } - _ => None, - } -} - -fn builtin_base_profile_artifact() -> serde_json::Value { - serde_json::json!({ - "slug": "default", - "description": "Default Yoi coding profile.", - "model": { "ref": "codex-oauth/gpt-5.5" }, - "session": { "record_event_trace": true }, - "engine": { "reasoning": "high" }, - "compaction": { - "kind": "tokens", - "threshold": 240000, - "request_threshold": 270000, - "worker_context_max_tokens": 100000 - }, - "feature": { - "task": { "enabled": true }, - "memory": { "enabled": true }, - "web": { "enabled": true }, - "image": { "enabled": true }, - "sub_worker": { "enabled": true }, - "worker": { "enabled": false }, - "objective": { "enabled": true }, - "ticket": { "enabled": true, "authoring": true, "thread": true } - }, - "memory": { - "extract_threshold": 50000, - "consolidation_threshold_files": 5, - "consolidation_threshold_bytes": 50000 - }, - "web": { - "enabled": true, - "search": { - "provider": "brave", - "api_key_secret": "web/brave/default" - } - } - }) -} - -#[allow(clippy::too_many_arguments)] -fn apply_role_profile( - value: &mut serde_json::Value, - slug: &str, - description: &str, - _scope: &str, - task: bool, - memory: bool, - web: bool, - sub_worker: bool, -) { - value["slug"] = serde_json::Value::String(slug.to_string()); - value["description"] = serde_json::Value::String(description.to_string()); - value["feature"]["task"] = serde_json::json!({ "enabled": task }); - value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); - value["feature"]["web"] = serde_json::json!({ "enabled": web }); - value["feature"]["image"] = serde_json::json!({ "enabled": true }); - value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker }); - value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" }); - value["feature"]["worker"] = serde_json::json!({ - "enabled": slug == "orchestrator", - "direct_spawn": slug != "orchestrator" - }); - value["feature"]["manage_workdir"] = serde_json::json!({ - "enabled": matches!(slug, "companion" | "orchestrator") - }); - value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" }); - let ticket = match slug { - "companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), - "intake" => { - serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true }) - } - "orchestrator" => { - serde_json::json!({ "enabled": true, "thread": true, "workflow": true }) - } - "coder" => serde_json::json!({ "enabled": true, "thread": true }), - "reviewer" => serde_json::json!({ "enabled": true, "thread": true }), - _ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), - }; - value["feature"]["ticket"] = ticket; - let merge_request = match slug { - "coder" => serde_json::json!({ - "show": true, - "open": true, - "review": false, - "readiness_check": false, - "complete": false - }), - "reviewer" => serde_json::json!({ - "show": true, - "open": false, - "review": true, - "readiness_check": false, - "complete": false - }), - "orchestrator" => serde_json::json!({ - "show": true, - "open": false, - "review": false, - "readiness_check": true, - "complete": true - }), - _ => serde_json::json!({ - "show": false, - "open": false, - "review": false, - "readiness_check": false, - "complete": false - }), - }; - value["feature"]["merge_request"] = merge_request; -} - fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { let Some(map) = value.as_object() else { return Err(ProfileError::InvalidProfile( @@ -1288,6 +1190,13 @@ pub enum ProfileError { #[source] source: toml::de::Error, }, + #[error("failed to evaluate built-in Profile `{selector}`: {message}")] + BuiltinProfileEvaluation { selector: String, message: String }, + #[error("Profile requires unsupported {target} launch authorities: {requirements:?}")] + UnsupportedExecutionTarget { + target: ProfileExecutionTarget, + requirements: Vec, + }, #[error("no default profile is configured")] NoDefaultProfile, #[error("profile resolution requires an explicit runtime Worker name")] @@ -1341,18 +1250,21 @@ mod tests { ); } #[test] - fn builtin_profiles_do_not_define_an_implicit_default() { + fn builtin_default_is_explicit_registry_authority() { let registry = ProfileDiscovery::with_sources(None, None) .discover() .unwrap(); - assert!(matches!( - registry.default_entry(), - Err(ProfileError::NoDefaultProfile) - )); - assert!(matches!( - registry.select(&ProfileSelector::Default), - Err(ProfileError::NoDefaultProfile) - )); + let default = registry.default_entry().unwrap(); + assert_eq!(default.source, ProfileRegistrySource::Builtin); + assert_eq!(default.name, "default"); + assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE); + assert!(default.is_default); + assert!( + default + .provenance + .starts_with("profiles/default.dcdl#sha256:") + ); + assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default); } #[test] fn builtin_role_profiles_are_registered_and_resolve() { @@ -1407,6 +1319,92 @@ mod tests { } } + #[test] + fn builtin_default_resolves_as_a_standalone_local_capability_profile() { + let tmp = TempDir::new().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve_for_target( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), + ProfileResolveOptions::with_worker_name("standalone-worker"), + ProfileExecutionTarget::Standalone, + ) + .unwrap(); + + assert!(matches!( + &resolved.source, + ProfileSource::Registry { + source: ProfileRegistrySource::Builtin, + name, + path: None, + provenance: Some(provenance), + .. + } if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:") + )); + assert!(resolved.manifest.feature.task.enabled); + assert!(resolved.manifest.feature.web.enabled); + assert!(resolved.manifest.feature.image.enabled); + assert!(resolved.manifest.feature.sub_worker.enabled); + assert!(resolved.manifest.scope.allow.iter().any(|rule| { + rule.permission == protocol::Permission::Write && rule.target == tmp.path() + })); + assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { + rule.permission == protocol::Permission::Write && rule.target == tmp.path() + })); + assert!(!resolved.manifest.feature.memory.enabled); + assert!(!resolved.manifest.feature.ticket.enabled); + assert!(!resolved.manifest.feature.objective.enabled); + assert!(!resolved.manifest.feature.flow.enabled); + assert!(!resolved.manifest.feature.worker.enabled); + assert!(!resolved.manifest.feature.manage_workdir.enabled); + assert!(!resolved.manifest.feature.plugins.enabled); + assert!(resolved.manifest.plugins.is_empty()); + } + + #[test] + fn standalone_rejects_profiles_that_require_workspace_authority() { + let tmp = TempDir::new().unwrap(); + let error = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve_for_target( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"), + ProfileResolveOptions::with_worker_name("standalone-worker"), + ProfileExecutionTarget::Standalone, + ) + .unwrap_err(); + let diagnostic = error.to_string(); + + let ProfileError::UnsupportedExecutionTarget { + target, + requirements, + } = error + else { + panic!("unexpected error: {error}"); + }; + assert_eq!(target, ProfileExecutionTarget::Standalone); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket)); + assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref())); + } + + #[test] + fn repository_markers_do_not_change_builtin_profile_authority() { + let tmp = TempDir::new().unwrap(); + let nested = tmp.path().join("repository/nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(tmp.path().join("repository/.yoi")).unwrap(); + std::fs::write( + tmp.path().join("repository/.yoi/profiles.toml"), + "default = { source = 'project', name = 'shadow' }\n", + ) + .unwrap(); + + let discovery = ProfileDiscovery::for_cwd(&nested); + assert_eq!(discovery.user_config, paths::user_profiles_path()); + assert!(discovery.project_config.is_none()); + } + #[test] fn builtin_coder_uses_sub_worker_control_without_worker_control() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d112d7df..0bf468fa 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -422,7 +422,7 @@ impl ProfileRuntimeWorkerFactory { fn restore_fallback_manifest( worker_name: &str, ) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> { - let mut config = manifest::WorkerManifestConfig::builtin_defaults(); + let mut config = manifest::WorkerManifestConfig::resolution_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = manifest::WorkerManifest::try_from(config) .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index ad345d03..957cedd3 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -184,15 +184,16 @@ fn load_spawn_config_json( ) -> Result<(WorkerManifest, PromptCatalogSource), String> { let config = serde_json::from_str::(config_json) .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; - let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config)) - .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; + let manifest = + WorkerManifest::try_from(WorkerManifestConfig::resolution_defaults().merge(config)) + .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_builtin_default_manifest( worker_name: &str, ) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let mut config = WorkerManifestConfig::builtin_defaults(); + let mut config = WorkerManifestConfig::resolution_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = WorkerManifest::try_from(config) .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; @@ -259,7 +260,7 @@ fn load_single_manifest( absolute_path.display() ) })?; - let mut config = WorkerManifestConfig::builtin_defaults().merge( + let mut config = WorkerManifestConfig::resolution_defaults().merge( WorkerManifestConfig::from_toml(&toml) .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? .resolve_paths(base_dir), diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 21a9371a..49b1fe61 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -403,11 +403,10 @@ impl Tool for SubWorkerSpawnTool { allow: scope_allow.clone(), deny: Vec::new(), }; - let mut child_manifest = - WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(child_config)) - .map_err(|error| { - ToolError::ExecutionFailed(format!("resolve child manifest: {error}")) - })?; + let mut child_manifest = WorkerManifest::try_from( + WorkerManifestConfig::resolution_defaults().merge(child_config), + ) + .map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?; // Delegated children stay bound to their scoped session and cannot use // Workspace attachment tools to replace it with parent-level authority. child_manifest.feature.manage_workdir.enabled = false; @@ -1631,7 +1630,7 @@ max_tokens = 3333 Some(true) ); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(parsed) .try_into() .unwrap(); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 8fb9cd5d..9e63f151 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -9,7 +9,6 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ - collections::BTreeMap, future::Future, path::PathBuf, pin::Pin, @@ -3722,94 +3721,6 @@ fn builtin_profile_config_bundle( .with_computed_digest()) } -fn builtin_profile_source_archive( - profile: &ProfileSelector, -) -> Result { - let selected = embedded_profile_label(profile) - .ok_or_else(|| "profile selector must identify a concrete profile".to_string())?; - let selected_path = embedded_profile_path(profile)?; - let mut entrypoints = BTreeMap::new(); - for slug in [ - "companion", - "intake", - "orchestrator", - "coder", - "reviewer", - "memory-consolidation", - ] { - entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl")); - } - entrypoints.insert(selected, selected_path); - - let mut sources = BTreeMap::new(); - sources.insert( - "profiles/base.dcdl".to_string(), - include_str!("../../../resources/profiles/base.dcdl").to_string(), - ); - sources.insert( - "profiles/companion.dcdl".to_string(), - include_str!("../../../resources/profiles/companion.dcdl").to_string(), - ); - sources.insert( - "profiles/intake.dcdl".to_string(), - include_str!("../../../resources/profiles/intake.dcdl").to_string(), - ); - sources.insert( - "profiles/orchestrator.dcdl".to_string(), - include_str!("../../../resources/profiles/orchestrator.dcdl").to_string(), - ); - sources.insert( - "profiles/coder.dcdl".to_string(), - include_str!("../../../resources/profiles/coder.dcdl").to_string(), - ); - sources.insert( - "profiles/reviewer.dcdl".to_string(), - include_str!("../../../resources/profiles/reviewer.dcdl").to_string(), - ); - sources.insert( - "profiles/memory-consolidation.dcdl".to_string(), - include_str!("../../../resources/profiles/memory-consolidation.dcdl").to_string(), - ); - - let mut imports = BTreeMap::new(); - for slug in [ - "companion", - "intake", - "orchestrator", - "coder", - "reviewer", - "memory-consolidation", - ] { - imports.insert( - format!("profiles/{slug}.dcdl\0./base.dcdl"), - "profiles/base.dcdl".to_string(), - ); - } - - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: "builtin-decodal-profiles-v1".to_string(), - entrypoints, - imports, - sources, - }) - .map_err(|err| err.to_string()) -} - -fn embedded_profile_path(profile: &ProfileSelector) -> Result { - match profile { - ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) { - "companion" => Ok("profiles/companion.dcdl".to_string()), - "intake" => Ok("profiles/intake.dcdl".to_string()), - "orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()), - "coder" => Ok("profiles/coder.dcdl".to_string()), - "reviewer" => Ok("profiles/reviewer.dcdl".to_string()), - "memory-consolidation" => Ok("profiles/memory-consolidation.dcdl".to_string()), - other => Err(format!("unknown builtin profile selector: builtin:{other}")), - }, - ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")), - } -} - fn embedded_profile_label(profile: &ProfileSelector) -> Option { Some(match profile { ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => { @@ -3825,6 +3736,33 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option { }) } +fn builtin_profile_source_archive( + profile: &ProfileSelector, +) -> Result { + let selected_profile = match profile { + ProfileSelector::Builtin(name) => name.clone(), + ProfileSelector::Named(name) => { + return Err(format!( + "embedded runtime does not provide named Profile `{name}`" + )); + } + }; + let catalog = manifest::builtin_profile_catalog_snapshot(); + if !catalog.entrypoints.contains_key(&selected_profile) { + return Err(format!( + "embedded runtime does not provide Profile `{selected_profile}`" + )); + } + + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: catalog.id.to_owned(), + sources: catalog.sources, + entrypoints: catalog.entrypoints, + imports: catalog.imports, + }) + .map_err(|error| format!("failed to build built-in Profile source archive: {error}")) +} + const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator"; @@ -4451,6 +4389,37 @@ mod tests { assert!(companion.feature.manage_workdir.enabled); assert!(companion.feature.sub_worker.enabled); assert!(!companion.feature.worker.enabled); + let default_from_archive = archive + .resolve_profile("builtin:default", root.path(), "embedded-test-default") + .unwrap(); + let default_from_native = manifest::ProfileResolver::new() + .with_workspace_base(root.path()) + .resolve( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + "default", + ), + manifest::ProfileResolveOptions::with_worker_name("embedded-test-default"), + ) + .unwrap() + .manifest; + let mut archive_value = serde_json::to_value(&default_from_archive).unwrap(); + let mut native_value = serde_json::to_value(&default_from_native).unwrap(); + let archive_profile = archive_value + .as_object_mut() + .and_then(|value| value.remove("profile")) + .expect("archive resolution records Profile provenance"); + let native_profile = native_value + .as_object_mut() + .and_then(|value| value.remove("profile")) + .expect("native resolution records Profile provenance"); + assert_eq!(archive_value, native_value); + assert_eq!(archive_profile["source"]["kind"], "archive"); + assert_eq!(native_profile["source"]["kind"], "registry"); + assert!(default_from_archive.feature.sub_worker.enabled); + assert!(!default_from_archive.feature.ticket.enabled); + assert!(!default_from_archive.feature.objective.enabled); + let coder = archive .resolve_profile("builtin:coder", root.path(), "embedded-test-coder") .unwrap(); diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl new file mode 100644 index 00000000..3d3721c2 --- /dev/null +++ b/resources/profiles/default.dcdl @@ -0,0 +1,34 @@ +import "./base.dcdl" // { + slug = "default"; + description = "Standalone Yoi coding profile."; + scope = "workspace_write"; + delegation_scope = "workspace_write"; + + feature = { + task = { enabled = true; }; + memory = { enabled = false; staging = false; }; + web = { enabled = true; }; + image = { enabled = true; }; + sub_worker = { enabled = true; }; + flow = { enabled = false; }; + worker = { enabled = false; direct_spawn = true; }; + objective = { enabled = false; }; + manage_workdir = { enabled = false; }; + ticket = { + enabled = false; + authoring = false; + thread = false; + intake = false; + workflow = false; + }; + merge_request = { + show = false; + open = false; + review = false; + readiness_check = false; + complete = false; + }; + orchestration = { enabled = false; }; + plugins = { enabled = false; }; + }; +}