merge: decodal profile archives
# Conflicts: # .yoi/tickets/00001KWZ5KERY/item.md # .yoi/tickets/00001KWZ5KERY/thread.md
This commit is contained in:
commit
0334c5725e
41
Cargo.lock
generated
41
Cargo.lock
generated
|
|
@ -907,6 +907,12 @@ version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "decodal"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deltae"
|
name = "deltae"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -1072,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2543,7 +2549,7 @@ version = "0.50.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3469,7 +3475,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3526,7 +3532,7 @@ dependencies = [
|
||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4114,6 +4120,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
|
|
@ -4136,7 +4153,7 @@ dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5472,7 +5489,7 @@ version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5914,6 +5931,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"decodal",
|
||||||
"futures",
|
"futures",
|
||||||
"llm-engine",
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
|
@ -5922,6 +5940,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -5952,6 +5971,16 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xml5ever"
|
name = "xml5ever"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
||||||
# need `default-features = false`, which workspace inheritance cannot override.
|
# need `default-features = false`, which workspace inheritance cannot override.
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
decodal = "0.1.1"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
@ -93,6 +94,7 @@ schemars = "1.2"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
|
tar = "0.4"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ pub use profile::{
|
||||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||||
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
||||||
resolve_profile_artifact,
|
resolve_profile_artifact, resolve_profile_artifact_value,
|
||||||
};
|
};
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ pub enum ProfileSource {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
provenance: Option<String>,
|
provenance: Option<String>,
|
||||||
},
|
},
|
||||||
|
Archive {
|
||||||
|
archive_id: String,
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option<String> {
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.map(str::to_string),
|
.map(str::to_string),
|
||||||
ProfileSource::Registry { name, .. } => Some(name.clone()),
|
ProfileSource::Registry { name, .. } => Some(name.clone()),
|
||||||
|
ProfileSource::Archive { source, .. } => Path::new(source)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
|
fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
|
||||||
|
|
@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact(
|
||||||
source: ProfileSource,
|
source: ProfileSource,
|
||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
raw_artifact: serde_json::Value,
|
raw_artifact: serde_json::Value,
|
||||||
|
) -> Result<ResolvedProfile, ProfileError> {
|
||||||
|
resolve_profile_artifact_value(raw_artifact, source, base_dir, "artifact-worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_profile_artifact_value(
|
||||||
|
raw_artifact: serde_json::Value,
|
||||||
|
source: ProfileSource,
|
||||||
|
base_dir: &Path,
|
||||||
|
worker_name: &str,
|
||||||
) -> Result<ResolvedProfile, ProfileError> {
|
) -> Result<ResolvedProfile, ProfileError> {
|
||||||
resolve_lua_profile_value(
|
resolve_lua_profile_value(
|
||||||
source,
|
source,
|
||||||
base_dir,
|
base_dir,
|
||||||
base_dir,
|
base_dir,
|
||||||
ProfileResolveOptions::with_worker_name("artifact-worker"),
|
ProfileResolveOptions::with_worker_name(worker_name),
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
None,
|
None,
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,22 @@ required-features = ["ws-server", "fs-store"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
fs-store = ["dep:serde_json"]
|
fs-store = []
|
||||||
http-server = ["dep:axum", "dep:serde_json", "dep:tower"]
|
http-server = ["dep:axum", "dep:tower"]
|
||||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum = { workspace = true, optional = true }
|
axum = { workspace = true, optional = true }
|
||||||
futures = { workspace = true, optional = true }
|
futures = { workspace = true, optional = true }
|
||||||
|
decodal.workspace = true
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol.workspace = true
|
protocol.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
session-store.workspace = true
|
session-store.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
serde_json = { workspace = true, optional = true }
|
serde_json.workspace = true
|
||||||
|
tar.workspace = true
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||||
tower = { workspace = true, features = ["util"], optional = true }
|
tower = { workspace = true, features = ["util"], optional = true }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
use crate::catalog::{ConfigBundleRef, ProfileSelector};
|
use crate::catalog::{ConfigBundleRef, ProfileSelector};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
|
use crate::profile_archive::{
|
||||||
|
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
|
||||||
|
VerifiedProfileSourceArchive,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
|
@ -18,6 +22,8 @@ pub struct ConfigBundle {
|
||||||
pub profiles: Vec<ConfigProfileDescriptor>,
|
pub profiles: Vec<ConfigProfileDescriptor>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub declarations: Vec<ConfigDeclaration>,
|
pub declarations: Vec<ConfigDeclaration>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile_source_archive: Option<ProfileSourceArchive>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigBundle {
|
impl ConfigBundle {
|
||||||
|
|
@ -60,6 +66,16 @@ impl ConfigBundle {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(archive) = &self.profile_source_archive {
|
||||||
|
lines.push(format!(
|
||||||
|
"profile_archive\0{}\0{}\0{}",
|
||||||
|
archive.reference.id, archive.reference.digest, archive.reference.size_bytes
|
||||||
|
));
|
||||||
|
for (selector, path) in &archive.reference.source_graph.entrypoints {
|
||||||
|
lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines.sort();
|
lines.sort();
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
for line in lines {
|
for line in lines {
|
||||||
|
|
@ -86,6 +102,10 @@ impl ConfigBundle {
|
||||||
provenance: self.metadata.provenance.clone(),
|
provenance: self.metadata.provenance.clone(),
|
||||||
profile_count: self.profiles.len(),
|
profile_count: self.profiles.len(),
|
||||||
declaration_count: self.declarations.len(),
|
declaration_count: self.declarations.len(),
|
||||||
|
profile_source_archive: self
|
||||||
|
.profile_source_archive
|
||||||
|
.as_ref()
|
||||||
|
.map(|archive| archive.reference.source_graph.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +184,8 @@ pub struct ConfigBundleSummary {
|
||||||
pub provenance: ConfigBundleProvenance,
|
pub provenance: ConfigBundleProvenance,
|
||||||
pub profile_count: usize,
|
pub profile_count: usize,
|
||||||
pub declaration_count: usize,
|
pub declaration_count: usize,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile_source_archive: Option<crate::profile_archive::ProfileSourceGraphSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -222,6 +244,16 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||||
}
|
}
|
||||||
validate_declaration_reference(&bundle.metadata.id, declaration)?;
|
validate_declaration_reference(&bundle.metadata.id, declaration)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(archive) = &bundle.profile_source_archive {
|
||||||
|
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
|
||||||
|
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
|
||||||
|
})?;
|
||||||
|
archive.verify().map_err(|err| {
|
||||||
|
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +264,34 @@ pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn verified_profile_source_archive(
|
||||||
|
bundle: &ConfigBundle,
|
||||||
|
) -> Result<Option<VerifiedProfileSourceArchive>, ProfileArchiveError> {
|
||||||
|
bundle
|
||||||
|
.profile_source_archive
|
||||||
|
.as_ref()
|
||||||
|
.map(ProfileSourceArchive::verify)
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_profile_source_archive_ref(
|
||||||
|
reference: &ProfileSourceArchiveRef,
|
||||||
|
) -> Result<(), ProfileArchiveError> {
|
||||||
|
if reference.id.trim().is_empty() {
|
||||||
|
return Err(ProfileArchiveError::MissingEntrypoint(
|
||||||
|
"archive id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !reference.digest.starts_with("sha256:") {
|
||||||
|
return Err(ProfileArchiveError::ArchiveDigestMismatch {
|
||||||
|
id: reference.id.clone(),
|
||||||
|
expected: "sha256:<hex>".to_string(),
|
||||||
|
actual: reference.digest.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_profile_selector(
|
pub(crate) fn validate_profile_selector(
|
||||||
selector: ProfileSelector,
|
selector: ProfileSelector,
|
||||||
bundle_id: Option<&str>,
|
bundle_id: Option<&str>,
|
||||||
|
|
@ -468,6 +528,7 @@ mod tests {
|
||||||
name: "credential".to_string(),
|
name: "credential".to_string(),
|
||||||
reference: reference.to_string(),
|
reference: reference.to_string(),
|
||||||
}],
|
}],
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
||||||
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use crate::interaction::WorkerInput;
|
use crate::interaction::WorkerInput;
|
||||||
|
|
@ -287,6 +288,7 @@ pub struct WorkerExecutionSpawnRequest {
|
||||||
pub request: CreateWorkerRequest,
|
pub request: CreateWorkerRequest,
|
||||||
pub context: WorkerExecutionContext,
|
pub context: WorkerExecutionContext,
|
||||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||||
|
pub config_bundle: Option<ConfigBundle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of backend Worker spawn/initialization.
|
/// Result of backend Worker spawn/initialization.
|
||||||
|
|
|
||||||
|
|
@ -849,6 +849,7 @@ mod tests {
|
||||||
label: Some("test".to_string()),
|
label: Some("test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
@ -1135,6 +1136,7 @@ mod ws_tests {
|
||||||
label: Some("ws".to_string()),
|
label: Some("ws".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ pub mod identity;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod observation;
|
pub mod observation;
|
||||||
|
pub mod profile_archive;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
pub mod working_directory;
|
pub mod working_directory;
|
||||||
|
|
|
||||||
554
crates/worker-runtime/src/profile_archive.rs
Normal file
554
crates/worker-runtime/src/profile_archive.rs
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
use decodal::{Engine, LoadedSource, SourceLoader};
|
||||||
|
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use tar::{Archive, Builder, Header};
|
||||||
|
|
||||||
|
const MANIFEST_PATH: &str = "manifest.json";
|
||||||
|
const MAX_SOURCES: usize = 64;
|
||||||
|
const MAX_SOURCE_BYTES: u64 = 256 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES: u64 = 1024 * 1024;
|
||||||
|
const MAX_PATH_DEPTH: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProfileArchiveError {
|
||||||
|
#[error("profile source archive has unsupported schema version {0}")]
|
||||||
|
UnsupportedSchema(u32),
|
||||||
|
#[error("profile source archive path must be relative and confined: {0}")]
|
||||||
|
UnsafePath(String),
|
||||||
|
#[error("profile source archive contains unsupported entry type at {0}")]
|
||||||
|
UnsupportedEntry(String),
|
||||||
|
#[error("profile source archive limit exceeded: {0}")]
|
||||||
|
LimitExceeded(&'static str),
|
||||||
|
#[error("profile source archive missing manifest.json")]
|
||||||
|
MissingManifest,
|
||||||
|
#[error("profile source archive missing source {0}")]
|
||||||
|
MissingSource(String),
|
||||||
|
#[error(
|
||||||
|
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
||||||
|
)]
|
||||||
|
SourceDigestMismatch {
|
||||||
|
path: String,
|
||||||
|
expected: String,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
|
#[error("profile source archive digest mismatch for {id}: expected {expected}, got {actual}")]
|
||||||
|
ArchiveDigestMismatch {
|
||||||
|
id: String,
|
||||||
|
expected: String,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
|
#[error("profile source archive has unsupported source kind {kind} at {path}")]
|
||||||
|
UnsupportedSource { path: String, kind: String },
|
||||||
|
#[error("profile source archive has no entrypoint for selector {0}")]
|
||||||
|
MissingEntrypoint(String),
|
||||||
|
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
||||||
|
ImportNotDeclared(String),
|
||||||
|
#[error("failed to read profile source archive: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("failed to parse profile source archive manifest: {0}")]
|
||||||
|
Json(String),
|
||||||
|
#[error("failed to evaluate Decodal profile source {path}: {message}")]
|
||||||
|
Decodal { path: String, message: String },
|
||||||
|
#[error("failed to resolve archive profile artifact {profile_source}: {message}")]
|
||||||
|
Profile {
|
||||||
|
profile_source: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceArchiveRef {
|
||||||
|
pub id: String,
|
||||||
|
pub digest: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub source_graph: ProfileSourceGraphSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceGraphSummary {
|
||||||
|
pub source_count: usize,
|
||||||
|
pub total_source_bytes: u64,
|
||||||
|
pub entrypoints: BTreeMap<String, String>,
|
||||||
|
pub import_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceArchive {
|
||||||
|
pub reference: ProfileSourceArchiveRef,
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceArchiveManifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub id: String,
|
||||||
|
pub entrypoints: BTreeMap<String, String>,
|
||||||
|
pub imports: BTreeMap<String, String>,
|
||||||
|
pub sources: Vec<ProfileSourceArchiveSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceArchiveSource {
|
||||||
|
pub path: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub digest: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ProfileSourceArchiveInput {
|
||||||
|
pub id: String,
|
||||||
|
pub entrypoints: BTreeMap<String, String>,
|
||||||
|
pub imports: BTreeMap<String, String>,
|
||||||
|
pub sources: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSourceArchive {
|
||||||
|
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
|
||||||
|
if input.sources.len() > MAX_SOURCES {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||||
|
}
|
||||||
|
let mut total_source_bytes = 0u64;
|
||||||
|
let mut source_meta = Vec::new();
|
||||||
|
for (path, source) in &input.sources {
|
||||||
|
validate_archive_path(path)?;
|
||||||
|
let size = source.as_bytes().len() as u64;
|
||||||
|
if size > MAX_SOURCE_BYTES {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("source bytes"));
|
||||||
|
}
|
||||||
|
total_source_bytes = total_source_bytes
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or(ProfileArchiveError::LimitExceeded("total source bytes"))?;
|
||||||
|
if total_source_bytes > MAX_TOTAL_BYTES {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
|
||||||
|
}
|
||||||
|
source_meta.push(ProfileSourceArchiveSource {
|
||||||
|
path: path.clone(),
|
||||||
|
kind: "decodal".to_string(),
|
||||||
|
digest: sha256_hex(source.as_bytes()),
|
||||||
|
size_bytes: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for path in input.entrypoints.values().chain(input.imports.values()) {
|
||||||
|
validate_archive_path(path)?;
|
||||||
|
if !input.sources.contains_key(path) {
|
||||||
|
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let manifest = ProfileSourceArchiveManifest {
|
||||||
|
schema_version: 1,
|
||||||
|
id: input.id.clone(),
|
||||||
|
entrypoints: input.entrypoints.clone(),
|
||||||
|
imports: input.imports.clone(),
|
||||||
|
sources: source_meta,
|
||||||
|
};
|
||||||
|
let manifest_json = serde_json::to_vec_pretty(&manifest)
|
||||||
|
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut bytes);
|
||||||
|
append_bytes(&mut builder, MANIFEST_PATH, &manifest_json)?;
|
||||||
|
for (path, source) in &input.sources {
|
||||||
|
append_bytes(&mut builder, path, source.as_bytes())?;
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
|
}
|
||||||
|
let digest = sha256_hex(&bytes);
|
||||||
|
let reference = ProfileSourceArchiveRef {
|
||||||
|
id: input.id,
|
||||||
|
digest,
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
source_graph: ProfileSourceGraphSummary {
|
||||||
|
source_count: input.sources.len(),
|
||||||
|
total_source_bytes,
|
||||||
|
entrypoints: input.entrypoints,
|
||||||
|
import_count: manifest.imports.len(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
reference,
|
||||||
|
content: bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(&self) -> Result<VerifiedProfileSourceArchive, ProfileArchiveError> {
|
||||||
|
let digest = sha256_hex(&self.content);
|
||||||
|
if digest != self.reference.digest {
|
||||||
|
return Err(ProfileArchiveError::ArchiveDigestMismatch {
|
||||||
|
id: self.reference.id.clone(),
|
||||||
|
expected: self.reference.digest.clone(),
|
||||||
|
actual: digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
VerifiedProfileSourceArchive::from_bytes(self.reference.clone(), &self.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VerifiedProfileSourceArchive {
|
||||||
|
reference: ProfileSourceArchiveRef,
|
||||||
|
manifest: ProfileSourceArchiveManifest,
|
||||||
|
sources: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerifiedProfileSourceArchive {
|
||||||
|
pub fn from_bytes(
|
||||||
|
reference: ProfileSourceArchiveRef,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<Self, ProfileArchiveError> {
|
||||||
|
if bytes.len() as u64 > MAX_TOTAL_BYTES + 64 * 1024 {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("archive bytes"));
|
||||||
|
}
|
||||||
|
let mut archive = Archive::new(Cursor::new(bytes));
|
||||||
|
let mut manifest: Option<ProfileSourceArchiveManifest> = None;
|
||||||
|
let mut entries = BTreeMap::new();
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?
|
||||||
|
{
|
||||||
|
let mut entry = entry.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
|
let path = entry
|
||||||
|
.path()
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
validate_archive_path(&path)?;
|
||||||
|
if !entry.header().entry_type().is_file() {
|
||||||
|
return Err(ProfileArchiveError::UnsupportedEntry(path));
|
||||||
|
}
|
||||||
|
let size = entry.size();
|
||||||
|
if size > MAX_SOURCE_BYTES && path != MANIFEST_PATH {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("source bytes"));
|
||||||
|
}
|
||||||
|
let mut data = Vec::new();
|
||||||
|
entry
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
|
if path == MANIFEST_PATH {
|
||||||
|
manifest = Some(
|
||||||
|
serde_json::from_slice(&data)
|
||||||
|
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let source = String::from_utf8(data)
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
|
entries.insert(path, source);
|
||||||
|
}
|
||||||
|
if entries.len() > MAX_SOURCES {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let manifest = manifest.ok_or(ProfileArchiveError::MissingManifest)?;
|
||||||
|
if manifest.schema_version != 1 {
|
||||||
|
return Err(ProfileArchiveError::UnsupportedSchema(
|
||||||
|
manifest.schema_version,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if manifest.id != reference.id {
|
||||||
|
return Err(ProfileArchiveError::ArchiveDigestMismatch {
|
||||||
|
id: reference.id,
|
||||||
|
expected: "matching archive id".to_string(),
|
||||||
|
actual: manifest.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut total = 0u64;
|
||||||
|
for source in &manifest.sources {
|
||||||
|
validate_archive_path(&source.path)?;
|
||||||
|
if source.kind != "decodal" {
|
||||||
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
|
path: source.path.clone(),
|
||||||
|
kind: source.kind.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let content = entries
|
||||||
|
.get(&source.path)
|
||||||
|
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
|
||||||
|
let digest = sha256_hex(content.as_bytes());
|
||||||
|
if digest != source.digest {
|
||||||
|
return Err(ProfileArchiveError::SourceDigestMismatch {
|
||||||
|
path: source.path.clone(),
|
||||||
|
expected: source.digest.clone(),
|
||||||
|
actual: digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let size = content.as_bytes().len() as u64;
|
||||||
|
if size != source.size_bytes {
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded(
|
||||||
|
"source byte metadata mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
total += size;
|
||||||
|
}
|
||||||
|
if total != reference.source_graph.total_source_bytes
|
||||||
|
|| manifest.sources.len() != reference.source_graph.source_count
|
||||||
|
{
|
||||||
|
return Err(ProfileArchiveError::LimitExceeded(
|
||||||
|
"source graph metadata mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for path in manifest
|
||||||
|
.entrypoints
|
||||||
|
.values()
|
||||||
|
.chain(manifest.imports.values())
|
||||||
|
{
|
||||||
|
validate_archive_path(path)?;
|
||||||
|
if !entries.contains_key(path) {
|
||||||
|
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
reference,
|
||||||
|
manifest,
|
||||||
|
sources: entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reference(&self) -> &ProfileSourceArchiveRef {
|
||||||
|
&self.reference
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_profile(
|
||||||
|
&self,
|
||||||
|
selector: &str,
|
||||||
|
worker_root: &Path,
|
||||||
|
worker_name: &str,
|
||||||
|
) -> Result<WorkerManifest, ProfileArchiveError> {
|
||||||
|
let path = self
|
||||||
|
.manifest
|
||||||
|
.entrypoints
|
||||||
|
.get(selector)
|
||||||
|
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
|
||||||
|
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
||||||
|
let source = self
|
||||||
|
.sources
|
||||||
|
.get(path)
|
||||||
|
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||||
|
let module = engine.add_root_source(path, path, source).map_err(|err| {
|
||||||
|
ProfileArchiveError::Decodal {
|
||||||
|
path: path.clone(),
|
||||||
|
message: format!("{err:?}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let value = engine
|
||||||
|
.eval_module(module)
|
||||||
|
.map_err(|err| ProfileArchiveError::Decodal {
|
||||||
|
path: path.clone(),
|
||||||
|
message: format!("{err:?}"),
|
||||||
|
})?;
|
||||||
|
let data = engine
|
||||||
|
.materialize(&value)
|
||||||
|
.map_err(|err| ProfileArchiveError::Decodal {
|
||||||
|
path: path.clone(),
|
||||||
|
message: format!("{err:?}"),
|
||||||
|
})?;
|
||||||
|
let json = decodal_data_to_json(&data);
|
||||||
|
let resolved = resolve_profile_artifact_value(
|
||||||
|
json,
|
||||||
|
ProfileSource::Archive {
|
||||||
|
archive_id: self.reference.id.clone(),
|
||||||
|
source: path.clone(),
|
||||||
|
},
|
||||||
|
worker_root,
|
||||||
|
worker_name,
|
||||||
|
)
|
||||||
|
.map_err(|err| ProfileArchiveError::Profile {
|
||||||
|
profile_source: path.clone(),
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(resolved.manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ArchiveSourceLoader<'a> {
|
||||||
|
archive: &'a VerifiedProfileSourceArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ArchiveSourceLoader<'a> {
|
||||||
|
pub fn new(archive: &'a VerifiedProfileSourceArchive) -> Self {
|
||||||
|
Self { archive }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||||
|
fn load(
|
||||||
|
&mut self,
|
||||||
|
_current_key: Option<&str>,
|
||||||
|
specifier: &str,
|
||||||
|
) -> decodal::Result<LoadedSource> {
|
||||||
|
let path = self
|
||||||
|
.archive
|
||||||
|
.manifest
|
||||||
|
.imports
|
||||||
|
.get(specifier)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
decodal::Diagnostic::new(
|
||||||
|
decodal::DiagnosticKind::Import,
|
||||||
|
decodal::Span::default(),
|
||||||
|
format!("archive import not declared: {specifier}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let source = self.archive.sources.get(path).ok_or_else(|| {
|
||||||
|
decodal::Diagnostic::new(
|
||||||
|
decodal::DiagnosticKind::Import,
|
||||||
|
decodal::Span::default(),
|
||||||
|
format!("archive source missing: {path}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(LoadedSource {
|
||||||
|
key: path.clone(),
|
||||||
|
name: path.clone(),
|
||||||
|
source: source.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
||||||
|
match data {
|
||||||
|
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
||||||
|
decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)),
|
||||||
|
decodal::Data::Float(value) => serde_json::Number::from_f64(*value)
|
||||||
|
.map(serde_json::Value::Number)
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
decodal::Data::String(value) => serde_json::Value::String(value.clone()),
|
||||||
|
decodal::Data::Array(values) => {
|
||||||
|
serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect())
|
||||||
|
}
|
||||||
|
decodal::Data::Object(fields) => serde_json::Value::Object(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| (field.name.clone(), decodal_data_to_json(&field.value)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_bytes<W: Write>(
|
||||||
|
builder: &mut Builder<W>,
|
||||||
|
path: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), ProfileArchiveError> {
|
||||||
|
validate_archive_path(path)?;
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, path, Cursor::new(data))
|
||||||
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
|
||||||
|
if path.is_empty() || path == MANIFEST_PATH {
|
||||||
|
return if path == MANIFEST_PATH {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ProfileArchiveError::UnsafePath(path.to_string()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let path_ref = Path::new(path);
|
||||||
|
if path_ref.is_absolute() {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
let mut depth = 0usize;
|
||||||
|
for component in path_ref.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(_) => depth += 1,
|
||||||
|
_ => return Err(ProfileArchiveError::UnsafePath(path.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth == 0 || depth > MAX_PATH_DEPTH {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
let normalized = PathBuf::from(path).to_string_lossy().replace('\\', "/");
|
||||||
|
if normalized != path {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(data);
|
||||||
|
let mut out = String::from("sha256:");
|
||||||
|
for byte in digest.as_slice() {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(out, "{byte:02x}");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_archive() -> ProfileSourceArchive {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert(
|
||||||
|
"profiles/default.dcdl".to_string(),
|
||||||
|
r#"{
|
||||||
|
slug = "default";
|
||||||
|
description = "Default";
|
||||||
|
scope = "workspace_read";
|
||||||
|
}"#
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "builtin".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports: BTreeMap::new(),
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_and_verify_archive() {
|
||||||
|
let archive = sample_archive();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
assert_eq!(verified.reference().source_graph.source_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_digest_mismatch() {
|
||||||
|
let mut archive = sample_archive();
|
||||||
|
archive.reference.digest = "sha256:bad".to_string();
|
||||||
|
assert!(archive.verify().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_profile_from_archive_without_filesystem_loader() {
|
||||||
|
let archive = sample_archive();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = verified
|
||||||
|
.resolve_profile("default", root.path(), "archive-worker")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(manifest.worker.name, "archive-worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_selector_does_not_fallback_to_default() {
|
||||||
|
let archive = sample_archive();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
verified.resolve_profile("missing", root.path(), "archive-worker"),
|
||||||
|
Err(ProfileArchiveError::MissingEntrypoint(selector)) if selector == "missing"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_loader_rejects_undeclared_import() {
|
||||||
|
let archive = sample_archive();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||||
|
assert!(loader.load(None, "missing").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -244,6 +244,13 @@ impl Runtime {
|
||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
validate_create_worker_request(&request)?;
|
validate_create_worker_request(&request)?;
|
||||||
state.validate_worker_config_boundary(&request)?;
|
state.validate_worker_config_boundary(&request)?;
|
||||||
|
let config_bundle = state
|
||||||
|
.config_bundles
|
||||||
|
.get(&request.config_bundle.id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| RuntimeError::ConfigBundleMissing {
|
||||||
|
bundle_id: request.config_bundle.id.clone(),
|
||||||
|
})?;
|
||||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||||
RuntimeError::ExecutionBackendUnavailable {
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
message: "worker creation requires an execution backend".to_string(),
|
message: "worker creation requires an execution backend".to_string(),
|
||||||
|
|
@ -289,6 +296,7 @@ impl Runtime {
|
||||||
request,
|
request,
|
||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
|
config_bundle: Some(config_bundle),
|
||||||
};
|
};
|
||||||
(backend, worker_ref, spawn_request)
|
(backend, worker_ref, spawn_request)
|
||||||
};
|
};
|
||||||
|
|
@ -1619,6 +1627,7 @@ mod tests {
|
||||||
name: "read".to_string(),
|
name: "read".to_string(),
|
||||||
reference: "capability:read".to_string(),
|
reference: "capability:read".to_string(),
|
||||||
}],
|
}],
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config_bundle::verified_profile_source_archive;
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||||
|
|
@ -171,14 +172,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binding| binding.cwd().to_path_buf())
|
.map(|binding| binding.cwd().to_path_buf())
|
||||||
.unwrap_or_else(|| self.cwd.clone());
|
.unwrap_or_else(|| self.cwd.clone());
|
||||||
// Profile discovery uses the runtime profile base, not the Worker working directory.
|
let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() {
|
||||||
// The working directory may be an arbitrary repository checkout that intentionally
|
let selector = profile.as_deref().unwrap_or("builtin:default");
|
||||||
// does not carry Yoi profile files.
|
let archive = verified_profile_source_archive(bundle)
|
||||||
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
|
.map_err(|err| format!("failed to verify profile source archive: {err}"))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"config bundle {} does not contain a ProfileSourceArchive",
|
||||||
|
bundle.metadata.id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let manifest = archive
|
||||||
|
.resolve_profile(selector, &worker_root, &worker_name)
|
||||||
|
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||||
|
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
|
||||||
|
manifest,
|
||||||
|
&worker_root,
|
||||||
|
&worker_name,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
// Compatibility/debug fallback for direct CLI tests. Normal Browser/Backend launch
|
||||||
|
// supplies a ProfileSourceArchive inside the Runtime config bundle and must not use
|
||||||
|
// Runtime-local filesystem profile discovery.
|
||||||
|
worker::entrypoint::resolve_runtime_profile_manifest(
|
||||||
profile.as_deref(),
|
profile.as_deref(),
|
||||||
&self.profile_base_dir,
|
&self.profile_base_dir,
|
||||||
&worker_name,
|
&worker_name,
|
||||||
)?;
|
)?
|
||||||
|
};
|
||||||
manifest.worker.name = worker_name;
|
manifest.worker.name = worker_name;
|
||||||
|
|
||||||
let store_dir = self.store_dir()?;
|
let store_dir = self.store_dir()?;
|
||||||
|
|
@ -769,6 +790,7 @@ mod tests {
|
||||||
label: Some("adapter-test".to_string()),
|
label: Some("adapter-test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,19 @@ pub fn resolve_runtime_profile_manifest(
|
||||||
Ok((manifest, loader))
|
Ok((manifest, loader))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_runtime_profile_manifest_from_manifest(
|
||||||
|
mut manifest: WorkerManifest,
|
||||||
|
workspace_root: &Path,
|
||||||
|
worker_name: &str,
|
||||||
|
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||||
|
if manifest.worker.name.is_empty() {
|
||||||
|
manifest.worker.name = worker_name.to_string();
|
||||||
|
}
|
||||||
|
apply_profile_launch_policy(&mut manifest, workspace_root, None)?;
|
||||||
|
apply_plugin_resolution_plan(&mut manifest, workspace_root);
|
||||||
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
|
}
|
||||||
|
|
||||||
fn load_single_manifest(
|
fn load_single_manifest(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
explicit_worker_name: Option<&str>,
|
explicit_worker_name: Option<&str>,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
@ -6,6 +7,7 @@ use worker_runtime::catalog::ProfileSelector;
|
||||||
use worker_runtime::config_bundle::{
|
use worker_runtime::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||||
};
|
};
|
||||||
|
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||||
|
|
||||||
use crate::hosts::{
|
use crate::hosts::{
|
||||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
||||||
|
|
@ -452,10 +454,32 @@ fn companion_config_bundle() -> ConfigBundle {
|
||||||
label: Some("Workspace Companion".to_string()),
|
label: Some("Workspace Companion".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: Some(companion_profile_archive()),
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn companion_profile_archive() -> ProfileSourceArchive {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/companion.dcdl".to_string());
|
||||||
|
entrypoints.insert(
|
||||||
|
COMPANION_PROFILE_ID.to_string(),
|
||||||
|
"profiles/companion.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert(
|
||||||
|
"profiles/companion.dcdl".to_string(),
|
||||||
|
include_str!("../../../resources/profiles/companion.dcdl").to_string(),
|
||||||
|
);
|
||||||
|
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "workspace-companion-profile-archive-v1".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports: BTreeMap::new(),
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.expect("builtin Companion Decodal profile source archive is valid")
|
||||||
|
}
|
||||||
|
|
||||||
fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
|
fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
|
||||||
if !worker.capabilities.can_accept_input {
|
if !worker.capabilities.can_accept_input {
|
||||||
return CompanionState::Error;
|
return CompanionState::Error;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, RwLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
|
|
@ -41,6 +42,7 @@ use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, Runti
|
||||||
use worker_runtime::observation::{
|
use worker_runtime::observation::{
|
||||||
TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole,
|
TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole,
|
||||||
};
|
};
|
||||||
|
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||||
|
|
||||||
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||||
|
|
@ -1337,13 +1339,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||||
.profile
|
.profile
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
||||||
let config_bundle = match self
|
let config_bundle = match default_embedded_config_bundle(&profile).and_then(|bundle| {
|
||||||
.runtime
|
self.runtime
|
||||||
.store_config_bundle(default_embedded_config_bundle(&profile))
|
.store_config_bundle(bundle)
|
||||||
{
|
.map_err(|err| err.to_string())
|
||||||
|
}) {
|
||||||
Ok(availability) => availability.reference,
|
Ok(availability) => availability.reference,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
diagnostics.push(embedded_runtime_diagnostic(&error));
|
diagnostics.push(diagnostic(
|
||||||
|
"embedded_profile_source_archive_invalid",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
error,
|
||||||
|
));
|
||||||
return WorkerSpawnResult {
|
return WorkerSpawnResult {
|
||||||
state: WorkerOperationState::Rejected,
|
state: WorkerOperationState::Rejected,
|
||||||
worker: None,
|
worker: None,
|
||||||
|
|
@ -2018,7 +2025,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||||
.profile
|
.profile
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
|
||||||
let sync = self.sync_config_bundle(default_embedded_config_bundle(&profile));
|
let sync = match default_embedded_config_bundle(&profile) {
|
||||||
|
Ok(bundle) => self.sync_config_bundle(bundle),
|
||||||
|
Err(error) => ConfigBundleSyncResult {
|
||||||
|
state: WorkerOperationState::Rejected,
|
||||||
|
availability: None,
|
||||||
|
diagnostics: vec![diagnostic(
|
||||||
|
"remote_profile_source_archive_invalid",
|
||||||
|
DiagnosticSeverity::Error,
|
||||||
|
error,
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
};
|
||||||
let Some(config_bundle) = sync.availability.map(|availability| availability.reference)
|
let Some(config_bundle) = sync.availability.map(|availability| availability.reference)
|
||||||
else {
|
else {
|
||||||
return WorkerSpawnResult {
|
return WorkerSpawnResult {
|
||||||
|
|
@ -2347,14 +2365,14 @@ fn embedded_worker_execution_status_label(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
|
fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result<ConfigBundle, String> {
|
||||||
let id = format!(
|
let id = format!(
|
||||||
"workspace-runtime-{}",
|
"workspace-runtime-{}",
|
||||||
embedded_profile_label(profile)
|
embedded_profile_label(profile)
|
||||||
.unwrap_or_else(|| "default".to_string())
|
.unwrap_or_else(|| "default".to_string())
|
||||||
.replace([':', '/', ' '], "-")
|
.replace([':', '/', ' '], "-")
|
||||||
);
|
);
|
||||||
ConfigBundle {
|
Ok(ConfigBundle {
|
||||||
metadata: ConfigBundleMetadata {
|
metadata: ConfigBundleMetadata {
|
||||||
id,
|
id,
|
||||||
digest: String::new(),
|
digest: String::new(),
|
||||||
|
|
@ -2371,8 +2389,76 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
|
||||||
label: embedded_profile_label(profile),
|
label: embedded_profile_label(profile),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: Some(default_profile_source_archive(profile)?),
|
||||||
|
}
|
||||||
|
.with_computed_digest())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_profile_source_archive(
|
||||||
|
profile: &ProfileSelector,
|
||||||
|
) -> Result<ProfileSourceArchive, String> {
|
||||||
|
let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string());
|
||||||
|
let selected_path = embedded_profile_path(profile)?;
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
|
||||||
|
entrypoints.insert(
|
||||||
|
"builtin:default".to_string(),
|
||||||
|
"profiles/default.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
for slug in ["companion", "intake", "orchestrator", "coder", "reviewer"] {
|
||||||
|
entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl"));
|
||||||
|
}
|
||||||
|
entrypoints.insert(selected, selected_path);
|
||||||
|
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert(
|
||||||
|
"profiles/default.dcdl".to_string(),
|
||||||
|
include_str!("../../../resources/profiles/default.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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "builtin-decodal-profiles-v1".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports: BTreeMap::new(),
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
|
||||||
|
match profile {
|
||||||
|
ProfileSelector::RuntimeDefault => Ok("profiles/default.dcdl".to_string()),
|
||||||
|
ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) {
|
||||||
|
"default" => Ok("profiles/default.dcdl".to_string()),
|
||||||
|
"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()),
|
||||||
|
other => Err(format!("unknown builtin profile selector: builtin:{other}")),
|
||||||
|
},
|
||||||
|
ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")),
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
|
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
|
||||||
|
|
@ -2878,6 +2964,49 @@ mod tests {
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for selector in [
|
||||||
|
ProfileSelector::RuntimeDefault,
|
||||||
|
ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||||
|
ProfileSelector::Builtin("builtin:intake".to_string()),
|
||||||
|
ProfileSelector::Builtin("builtin:orchestrator".to_string()),
|
||||||
|
ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||||
|
ProfileSelector::Builtin("builtin:reviewer".to_string()),
|
||||||
|
] {
|
||||||
|
let bundle = default_embedded_config_bundle(&selector).unwrap();
|
||||||
|
let archive = bundle
|
||||||
|
.profile_source_archive
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.verify()
|
||||||
|
.unwrap();
|
||||||
|
let selector_key = match &selector {
|
||||||
|
ProfileSelector::RuntimeDefault => "default".to_string(),
|
||||||
|
ProfileSelector::Builtin(name) => name.clone(),
|
||||||
|
ProfileSelector::Named(name) => name.clone(),
|
||||||
|
};
|
||||||
|
let manifest = archive
|
||||||
|
.resolve_profile(&selector_key, root.path(), "embedded-test-worker")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(manifest.worker.name, "embedded-test-worker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_archive_rejects_unknown_selectors() {
|
||||||
|
assert!(
|
||||||
|
default_embedded_config_bundle(&ProfileSelector::Builtin(
|
||||||
|
"builtin:missing".to_string()
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
default_embedded_config_bundle(&ProfileSelector::Named("custom".to_string())).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_config_bundle() -> ConfigBundle {
|
fn test_config_bundle() -> ConfigBundle {
|
||||||
ConfigBundle {
|
ConfigBundle {
|
||||||
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
|
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
|
||||||
|
|
@ -2900,6 +3029,7 @@ mod tests {
|
||||||
name: "read".to_string(),
|
name: "read".to_string(),
|
||||||
reference: "capability:read".to_string(),
|
reference: "capability:read".to_string(),
|
||||||
}],
|
}],
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3405,6 +3405,7 @@ mod tests {
|
||||||
label: Some("server-test".to_string()),
|
label: Some("server-test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
profile_source_archive: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-ICEIo7wR65s6CQCzCVa1KaEou7fpnaUk64zwokb92Fc=";
|
cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
12
resources/profiles/coder.dcdl
Normal file
12
resources/profiles/coder.dcdl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
slug = "coder";
|
||||||
|
description = "Ticket implementation coder profile.";
|
||||||
|
scope = "workspace_write";
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = true; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = false; };
|
||||||
|
ticket = { enabled = false; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
12
resources/profiles/companion.dcdl
Normal file
12
resources/profiles/companion.dcdl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
slug = "companion";
|
||||||
|
description = "Workspace companion profile.";
|
||||||
|
scope = "workspace_write";
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = true; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = true; };
|
||||||
|
ticket = { enabled = false; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
38
resources/profiles/default.dcdl
Normal file
38
resources/profiles/default.dcdl
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
slug = "default";
|
||||||
|
description = "Default Yoi coding profile.";
|
||||||
|
scope = "workspace_write";
|
||||||
|
|
||||||
|
model = {
|
||||||
|
ref = "codex-oauth/gpt-5.5";
|
||||||
|
};
|
||||||
|
|
||||||
|
session = {
|
||||||
|
record_event_trace = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
engine = {
|
||||||
|
reasoning = "high";
|
||||||
|
};
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = true; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = true; };
|
||||||
|
ticket = { enabled = false; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
|
|
||||||
|
memory = {
|
||||||
|
extract_threshold = 50000;
|
||||||
|
consolidation_threshold_files = 5;
|
||||||
|
consolidation_threshold_bytes = 50000;
|
||||||
|
};
|
||||||
|
|
||||||
|
web = {
|
||||||
|
enabled = true;
|
||||||
|
search = {
|
||||||
|
provider = "brave";
|
||||||
|
api_key_secret = "web/brave/default";
|
||||||
|
};
|
||||||
|
};
|
||||||
12
resources/profiles/intake.dcdl
Normal file
12
resources/profiles/intake.dcdl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
slug = "intake";
|
||||||
|
description = "Ticket intake profile.";
|
||||||
|
scope = "workspace_write";
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = false; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = false; };
|
||||||
|
ticket = { enabled = true; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
12
resources/profiles/orchestrator.dcdl
Normal file
12
resources/profiles/orchestrator.dcdl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
slug = "orchestrator";
|
||||||
|
description = "Ticket orchestrator profile.";
|
||||||
|
scope = "workspace_write";
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = true; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = true; };
|
||||||
|
ticket = { enabled = true; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = true; };
|
||||||
|
};
|
||||||
12
resources/profiles/reviewer.dcdl
Normal file
12
resources/profiles/reviewer.dcdl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
slug = "reviewer";
|
||||||
|
description = "Ticket review profile.";
|
||||||
|
scope = "workspace_read";
|
||||||
|
|
||||||
|
feature = {
|
||||||
|
task = { enabled = true; };
|
||||||
|
memory = { enabled = true; };
|
||||||
|
web = { enabled = true; };
|
||||||
|
workers = { enabled = false; };
|
||||||
|
ticket = { enabled = false; access = "lifecycle"; };
|
||||||
|
ticket_orchestration = { enabled = false; };
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user