feat: add decodal profile archives
This commit is contained in:
parent
56cac3c74a
commit
a823d414d0
|
|
@ -2,7 +2,7 @@
|
|||
title: 'Migrate Profiles to Decodal ProfileSourceArchive for Runtime launch'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-07-07T20:51:35Z'
|
||||
updated_at: '2026-07-08T09:13:16Z'
|
||||
updated_at: '2026-07-08T09:57:38Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-07-08T09:11:22Z'
|
||||
|
|
|
|||
|
|
@ -140,4 +140,30 @@ Acceptance basis:
|
|||
- Ticket の scope は Decodal Profile DSL / ProfileSourceArchive / Runtime archive-only profile resolution / compatibility fallback isolation に明確化されており、Profile editor UI、secret sync、plugin package manager、runtime-local artifact sync full implementation は非目標として明確。
|
||||
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-08T09:57:38Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented Decodal ProfileSourceArchive launch path for Browser/Workspace Backend Worker creation.
|
||||
|
||||
Changes:
|
||||
- Added worker-runtime ProfileSourceArchive tar manifest/content model with digest verification, source limits, path confinement, import-map-only ArchiveSourceLoader, and Decodal-to-profile resolution.
|
||||
- Added builtin Decodal profile sources and Backend-built archives for normal workspace and Companion launches.
|
||||
- Runtime config bundle validation now verifies/caches the profile source archive and Worker spawn passes the verified bundle to execution.
|
||||
- ProfileRuntimeWorkerFactory now resolves normal Runtime launches from archive-contained Decodal sources and keeps Runtime-local Lua/filesystem discovery as compatibility/debug fallback only.
|
||||
- Browser/runtime summaries expose only source graph summary, not archive content/digest/store paths.
|
||||
- Added tests for archive build/verify, digest mismatch, archive-only resolution, and import-map rejection.
|
||||
|
||||
Validation performed:
|
||||
- git diff --check HEAD
|
||||
- cargo test -p worker-runtime --features ws-server,fs-store
|
||||
- cargo test -p yoi-workspace-server
|
||||
- cargo check -p yoi
|
||||
- cd web/workspace && deno task check && deno task test
|
||||
- yoi ticket doctor
|
||||
- nix build .#yoi --no-link
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
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"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "decodal"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
|
|
@ -1072,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2543,7 +2549,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3469,7 +3475,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3526,7 +3532,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4114,6 +4120,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
|
|
@ -4136,7 +4153,7 @@ dependencies = [
|
|||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5472,7 +5489,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5914,6 +5931,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"decodal",
|
||||
"futures",
|
||||
"llm-engine",
|
||||
"manifest",
|
||||
|
|
@ -5922,6 +5940,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -5952,6 +5971,16 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "xml5ever"
|
||||
version = "0.17.0"
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
|||
# need `default-features = false`, which workspace inheritance cannot override.
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
decodal = "0.1.1"
|
||||
fs4 = "0.13"
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
|
|
@ -93,6 +94,7 @@ schemars = "1.2"
|
|||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9.34"
|
||||
tar = "0.4"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub use profile::{
|
|||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
||||
resolve_profile_artifact,
|
||||
resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
|
|
|
|||
|
|
@ -189,6 +189,10 @@ pub enum ProfileSource {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provenance: Option<String>,
|
||||
},
|
||||
Archive {
|
||||
archive_id: String,
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option<String> {
|
|||
.and_then(|s| s.to_str())
|
||||
.map(str::to_string),
|
||||
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> {
|
||||
|
|
@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact(
|
|||
source: ProfileSource,
|
||||
base_dir: &Path,
|
||||
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> {
|
||||
resolve_lua_profile_value(
|
||||
source,
|
||||
base_dir,
|
||||
base_dir,
|
||||
ProfileResolveOptions::with_worker_name("artifact-worker"),
|
||||
ProfileResolveOptions::with_worker_name(worker_name),
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -13,20 +13,22 @@ required-features = ["ws-server", "fs-store"]
|
|||
|
||||
[features]
|
||||
default = []
|
||||
fs-store = ["dep:serde_json"]
|
||||
http-server = ["dep:axum", "dep:serde_json", "dep:tower"]
|
||||
fs-store = []
|
||||
http-server = ["dep:axum", "dep:tower"]
|
||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
decodal.workspace = true
|
||||
manifest.workspace = true
|
||||
protocol.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
session-store.workspace = true
|
||||
sha2.workspace = true
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde_json.workspace = true
|
||||
tar.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
use crate::catalog::{ConfigBundleRef, ProfileSelector};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::profile_archive::{
|
||||
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
|
||||
VerifiedProfileSourceArchive,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
|
|
@ -18,6 +22,8 @@ pub struct ConfigBundle {
|
|||
pub profiles: Vec<ConfigProfileDescriptor>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub declarations: Vec<ConfigDeclaration>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_source_archive: Option<ProfileSourceArchive>,
|
||||
}
|
||||
|
||||
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();
|
||||
let mut hasher = Sha256::new();
|
||||
for line in lines {
|
||||
|
|
@ -86,6 +102,10 @@ impl ConfigBundle {
|
|||
provenance: self.metadata.provenance.clone(),
|
||||
profile_count: self.profiles.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 profile_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)]
|
||||
|
|
@ -222,6 +244,16 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
|||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +264,34 @@ pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<
|
|||
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(
|
||||
selector: ProfileSelector,
|
||||
bundle_id: Option<&str>,
|
||||
|
|
@ -468,6 +528,7 @@ mod tests {
|
|||
name: "credential".to_string(),
|
||||
reference: reference.to_string(),
|
||||
}],
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::WorkerRef;
|
||||
use crate::interaction::WorkerInput;
|
||||
|
|
@ -287,6 +288,7 @@ pub struct WorkerExecutionSpawnRequest {
|
|||
pub request: CreateWorkerRequest,
|
||||
pub context: WorkerExecutionContext,
|
||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||
pub config_bundle: Option<ConfigBundle>,
|
||||
}
|
||||
|
||||
/// Result of backend Worker spawn/initialization.
|
||||
|
|
|
|||
|
|
@ -849,6 +849,7 @@ mod tests {
|
|||
label: Some("test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
@ -1135,6 +1136,7 @@ mod ws_tests {
|
|||
label: Some("ws".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub mod identity;
|
|||
pub mod interaction;
|
||||
pub mod management;
|
||||
pub mod observation;
|
||||
pub mod profile_archive;
|
||||
mod runtime;
|
||||
pub mod worker_backend;
|
||||
pub mod working_directory;
|
||||
|
|
|
|||
544
crates/worker-runtime/src/profile_archive.rs
Normal file
544
crates/worker-runtime/src/profile_archive.rs
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
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)
|
||||
.or_else(|| self.manifest.entrypoints.get("default"))
|
||||
.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 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()?;
|
||||
validate_create_worker_request(&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(|| {
|
||||
RuntimeError::ExecutionBackendUnavailable {
|
||||
message: "worker creation requires an execution backend".to_string(),
|
||||
|
|
@ -289,6 +296,7 @@ impl Runtime {
|
|||
request,
|
||||
context: self.execution_context(worker_ref.clone()),
|
||||
working_directory: None,
|
||||
config_bundle: Some(config_bundle),
|
||||
};
|
||||
(backend, worker_ref, spawn_request)
|
||||
};
|
||||
|
|
@ -1619,6 +1627,7 @@ mod tests {
|
|||
name: "read".to_string(),
|
||||
reference: "capability:read".to_string(),
|
||||
}],
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config_bundle::verified_profile_source_archive;
|
||||
use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
|
|
@ -171,14 +172,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
.as_ref()
|
||||
.map(|binding| binding.cwd().to_path_buf())
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
// Profile discovery uses the runtime profile base, not the Worker working directory.
|
||||
// The working directory may be an arbitrary repository checkout that intentionally
|
||||
// does not carry Yoi profile files.
|
||||
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
|
||||
profile.as_deref(),
|
||||
&self.profile_base_dir,
|
||||
&worker_name,
|
||||
)?;
|
||||
let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() {
|
||||
let selector = profile.as_deref().unwrap_or("builtin:default");
|
||||
let archive = verified_profile_source_archive(bundle)
|
||||
.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(),
|
||||
&self.profile_base_dir,
|
||||
&worker_name,
|
||||
)?
|
||||
};
|
||||
manifest.worker.name = worker_name;
|
||||
|
||||
let store_dir = self.store_dir()?;
|
||||
|
|
@ -769,6 +790,7 @@ mod tests {
|
|||
label: Some("adapter-test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,19 @@ pub fn resolve_runtime_profile_manifest(
|
|||
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(
|
||||
path: &Path,
|
||||
explicit_worker_name: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
|
|
@ -6,6 +7,7 @@ use worker_runtime::catalog::ProfileSelector;
|
|||
use worker_runtime::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
|
||||
use crate::hosts::{
|
||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
||||
|
|
@ -452,10 +454,32 @@ fn companion_config_bundle() -> ConfigBundle {
|
|||
label: Some("Workspace Companion".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: Some(companion_profile_archive()),
|
||||
}
|
||||
.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 {
|
||||
if !worker.capabilities.can_accept_input {
|
||||
return CompanionState::Error;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use serde::de::DeserializeOwned;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
|
|
@ -41,6 +42,7 @@ use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, Runti
|
|||
use worker_runtime::observation::{
|
||||
TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
|
||||
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||
|
|
@ -2371,10 +2373,75 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
|
|||
label: embedded_profile_label(profile),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: Some(default_profile_source_archive(profile)),
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
||||
fn default_profile_source_archive(profile: &ProfileSelector) -> ProfileSourceArchive {
|
||||
let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string());
|
||||
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, embedded_profile_path(profile));
|
||||
|
||||
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,
|
||||
})
|
||||
.expect("builtin Decodal profile source archive is valid")
|
||||
}
|
||||
|
||||
fn embedded_profile_path(profile: &ProfileSelector) -> String {
|
||||
match profile {
|
||||
ProfileSelector::RuntimeDefault => "profiles/default.dcdl".to_string(),
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => {
|
||||
match name.strip_prefix("builtin:").unwrap_or(name) {
|
||||
"companion" => "profiles/companion.dcdl".to_string(),
|
||||
"intake" => "profiles/intake.dcdl".to_string(),
|
||||
"orchestrator" => "profiles/orchestrator.dcdl".to_string(),
|
||||
"coder" => "profiles/coder.dcdl".to_string(),
|
||||
"reviewer" => "profiles/reviewer.dcdl".to_string(),
|
||||
_ => "profiles/default.dcdl".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
|
||||
match intent {
|
||||
WorkerSpawnIntent::TicketRole { role, .. } => {
|
||||
|
|
@ -2900,6 +2967,7 @@ mod tests {
|
|||
name: "read".to_string(),
|
||||
reference: "capability:read".to_string(),
|
||||
}],
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3405,6 +3405,7 @@ mod tests {
|
|||
label: Some("server-test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
profile_source_archive: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-ICEIo7wR65s6CQCzCVa1KaEou7fpnaUk64zwokb92Fc=";
|
||||
cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
13
resources/profiles/coder.dcdl
Normal file
13
resources/profiles/coder.dcdl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
slug = "coder";
|
||||
description = "Ticket implementation coder profile.";
|
||||
scope = "workspace_write";
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
write = true;
|
||||
edit = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
memory = true;
|
||||
};
|
||||
15
resources/profiles/companion.dcdl
Normal file
15
resources/profiles/companion.dcdl
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
slug = "companion";
|
||||
description = "Workspace companion profile.";
|
||||
scope = "workspace_write";
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
write = true;
|
||||
edit = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
web_search = true;
|
||||
web_fetch = true;
|
||||
memory = true;
|
||||
};
|
||||
21
resources/profiles/default.dcdl
Normal file
21
resources/profiles/default.dcdl
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
slug = "default";
|
||||
description = "General Yoi companion profile.";
|
||||
scope = "workspace_write";
|
||||
|
||||
model = {
|
||||
scheme = "codex";
|
||||
model_id = "gpt-5";
|
||||
auth = { kind = "codex_oauth"; };
|
||||
};
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
write = true;
|
||||
edit = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
web_search = true;
|
||||
web_fetch = true;
|
||||
memory = true;
|
||||
};
|
||||
13
resources/profiles/intake.dcdl
Normal file
13
resources/profiles/intake.dcdl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
slug = "intake";
|
||||
description = "Ticket intake profile.";
|
||||
scope = "workspace_write";
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
write = true;
|
||||
edit = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
memory = true;
|
||||
};
|
||||
13
resources/profiles/orchestrator.dcdl
Normal file
13
resources/profiles/orchestrator.dcdl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
slug = "orchestrator";
|
||||
description = "Ticket orchestrator profile.";
|
||||
scope = "workspace_write";
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
write = true;
|
||||
edit = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
memory = true;
|
||||
};
|
||||
11
resources/profiles/reviewer.dcdl
Normal file
11
resources/profiles/reviewer.dcdl
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
slug = "reviewer";
|
||||
description = "Ticket review profile.";
|
||||
scope = "workspace_read";
|
||||
|
||||
tool_enabled = {
|
||||
bash = true;
|
||||
read = true;
|
||||
grep = true;
|
||||
glob = true;
|
||||
memory = true;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user