merge: integrate orchestration branch
This commit is contained in:
@@ -57,6 +57,40 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Embedded starter template for Rust Component Model instance Plugins.
|
||||
pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[
|
||||
PluginTemplateResource {
|
||||
path: "Cargo.toml",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-instance/Cargo.toml"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "src/lib.rs",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-instance/src/lib.rs"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "plugin.toml",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-instance/plugin.toml"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "plugin.component.wasm",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm"
|
||||
),
|
||||
},
|
||||
PluginTemplateResource {
|
||||
path: "README.md",
|
||||
contents: include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-instance/README.md"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginConfig {
|
||||
@@ -170,6 +204,8 @@ pub enum PluginPermission {
|
||||
Surface { surface: PluginSurface },
|
||||
Tool { name: String },
|
||||
ToolNamespace { namespace: String },
|
||||
Service { name: String },
|
||||
Ingress { name: String },
|
||||
ExternalWrite,
|
||||
HostApi { api: PluginHostApi },
|
||||
}
|
||||
@@ -249,6 +285,8 @@ impl PluginPermission {
|
||||
Self::Surface { surface } => format!("surfaces.{surface}"),
|
||||
Self::Tool { name } => format!("tool.{name}"),
|
||||
Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"),
|
||||
Self::Service { name } => format!("service.{name}"),
|
||||
Self::Ingress { name } => format!("ingress.{name}"),
|
||||
Self::ExternalWrite => "external_write".to_string(),
|
||||
Self::HostApi { api } => format!("host_api.{api}"),
|
||||
}
|
||||
@@ -268,6 +306,14 @@ impl PluginPermission {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service(name: impl Into<String>) -> Self {
|
||||
Self::Service { name: name.into() }
|
||||
}
|
||||
|
||||
pub fn ingress(name: impl Into<String>) -> Self {
|
||||
Self::Ingress { name: name.into() }
|
||||
}
|
||||
|
||||
pub fn host_api(api: PluginHostApi) -> Self {
|
||||
Self::HostApi { api }
|
||||
}
|
||||
@@ -382,7 +428,7 @@ pub enum PluginIdParseError {
|
||||
InvalidLocalId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginPackageManifest {
|
||||
pub schema_version: u32,
|
||||
@@ -398,6 +444,10 @@ pub struct PluginPackageManifest {
|
||||
pub hooks: Vec<PluginHookManifest>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<PluginToolManifest>,
|
||||
#[serde(default)]
|
||||
pub services: Vec<PluginServiceManifest>,
|
||||
#[serde(default)]
|
||||
pub ingresses: Vec<PluginIngressManifest>,
|
||||
/// Permission requests declared by the package. These are requests only;
|
||||
/// enablement grants must match them before runtime surfaces are exposed.
|
||||
#[serde(default)]
|
||||
@@ -413,6 +463,12 @@ impl PluginPackageManifest {
|
||||
if !self.tools.is_empty() {
|
||||
surfaces.insert(PluginSurface::Tool);
|
||||
}
|
||||
if !self.services.is_empty() {
|
||||
surfaces.insert(PluginSurface::Service);
|
||||
}
|
||||
if !self.ingresses.is_empty() {
|
||||
surfaces.insert(PluginSurface::Ingress);
|
||||
}
|
||||
if self.runtime.is_some() {
|
||||
surfaces.insert(PluginSurface::Wasm);
|
||||
}
|
||||
@@ -429,6 +485,7 @@ pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1";
|
||||
/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`.
|
||||
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
||||
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
||||
pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -464,6 +521,34 @@ pub struct PluginToolManifest {
|
||||
pub external_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceManifest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub lifecycle: String,
|
||||
#[serde(default)]
|
||||
pub status_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub side_effects: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginIngressManifest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub event_kinds: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub side_effects: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginDiscoveryLimits {
|
||||
pub max_packages_per_store: usize,
|
||||
@@ -514,7 +599,7 @@ impl PluginDiscoveryOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiscoveredPluginPackage {
|
||||
pub identity: SourceQualifiedPluginId,
|
||||
pub package_path: PathBuf,
|
||||
@@ -529,19 +614,19 @@ pub struct DiscoveredPluginPackage {
|
||||
/// This is data-only metadata and bytes. Constructing it parses manifests and
|
||||
/// validates package/archive shape, but it does not load, instantiate, or
|
||||
/// execute Plugin code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MaterializedPluginPackage {
|
||||
pub package: DiscoveredPluginPackage,
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackedPluginPackage {
|
||||
pub output_path: PathBuf,
|
||||
pub package: DiscoveredPluginPackage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PluginDiscoveryReport {
|
||||
pub packages: Vec<DiscoveredPluginPackage>,
|
||||
pub diagnostics: Vec<PluginDiagnostic>,
|
||||
@@ -1072,7 +1157,10 @@ pub fn read_resolved_plugin_runtime_component(
|
||||
.with_package(&record.package_label)
|
||||
.with_digest(&record.digest));
|
||||
}
|
||||
if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
|
||||
if !matches!(
|
||||
runtime.world.as_deref(),
|
||||
Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
|
||||
) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
@@ -1918,7 +2006,10 @@ fn validate_manifest(
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
|
||||
if !matches!(
|
||||
runtime.world.as_deref(),
|
||||
Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
|
||||
) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
@@ -1952,6 +2043,44 @@ fn validate_manifest(
|
||||
}
|
||||
}
|
||||
}
|
||||
let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| {
|
||||
runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
|
||||
&& runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
|
||||
});
|
||||
if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Surface,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
for service in &manifest.services {
|
||||
if !is_safe_id(&service.name) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Malformed,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin service name is not safe",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
}
|
||||
for ingress in &manifest.ingresses {
|
||||
if !is_safe_id(&ingress.name) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Malformed,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin ingress name is not safe",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
}
|
||||
for hook in &manifest.hooks {
|
||||
if !is_safe_id(&hook.id) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
@@ -2425,7 +2554,13 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"])
|
||||
BTreeSet::from([
|
||||
"Cargo.toml",
|
||||
"src/lib.rs",
|
||||
"plugin.toml",
|
||||
"plugin.component.wasm",
|
||||
"README.md",
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
RUST_COMPONENT_TOOL_TEMPLATE
|
||||
@@ -2451,6 +2586,86 @@ mod tests {
|
||||
assert_eq!(manifest.tools.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_rust_component_instance_template_is_valid_package_shape() {
|
||||
let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
|
||||
.iter()
|
||||
.map(|file| file.path)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
BTreeSet::from([
|
||||
"Cargo.toml",
|
||||
"src/lib.rs",
|
||||
"plugin.toml",
|
||||
"plugin.component.wasm",
|
||||
"README.md"
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
RUST_COMPONENT_INSTANCE_TEMPLATE
|
||||
.iter()
|
||||
.all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
|
||||
);
|
||||
let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE
|
||||
.iter()
|
||||
.find(|file| file.path == "plugin.toml")
|
||||
.unwrap()
|
||||
.contents;
|
||||
let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
|
||||
assert_eq!(
|
||||
manifest.runtime.as_ref().unwrap().world.as_deref(),
|
||||
Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
|
||||
);
|
||||
assert_eq!(manifest.services.len(), 1);
|
||||
assert_eq!(manifest.ingresses.len(), 1);
|
||||
assert!(
|
||||
manifest
|
||||
.declared_surfaces()
|
||||
.contains(&PluginSurface::Service)
|
||||
);
|
||||
assert!(
|
||||
manifest
|
||||
.declared_surfaces()
|
||||
.contains(&PluginSurface::Ingress)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_ingress_require_instance_component_world() {
|
||||
let manifest: PluginPackageManifest = toml::from_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
id = "bad.service"
|
||||
name = "Bad Service"
|
||||
version = "0.1.0"
|
||||
surfaces = ["service"]
|
||||
permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
world = "yoi:plugin/tool@1.0.0"
|
||||
component = "plugin.component.wasm"
|
||||
|
||||
[[services]]
|
||||
name = "svc"
|
||||
description = "bad"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let archive = StoredArchive {
|
||||
files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]),
|
||||
};
|
||||
let err = validate_manifest(
|
||||
&manifest,
|
||||
&archive,
|
||||
"bad.service",
|
||||
PluginSourceKind::Project,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("service/ingress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_valid_user_and_workspace_packages() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -38,6 +38,8 @@ use serde_json::Value;
|
||||
|
||||
pub use wit_bindgen;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ToolError>;
|
||||
|
||||
/// Current Yoi Component Model Tool world targeted by this PDK.
|
||||
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
||||
|
||||
@@ -98,7 +100,10 @@ impl ToolOutput {
|
||||
}
|
||||
|
||||
/// Create a Tool output whose content is typed JSON.
|
||||
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self, ToolError> {
|
||||
pub fn json(
|
||||
summary: impl Into<String>,
|
||||
value: impl Serialize,
|
||||
) -> std::result::Result<Self, ToolError> {
|
||||
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
let output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
@@ -292,7 +297,7 @@ impl ToolError {
|
||||
}
|
||||
|
||||
/// Parse the WIT `input-json` string into a typed input value.
|
||||
pub fn parse_json_input<T>(input_json: &str) -> Result<T, ToolError>
|
||||
pub fn parse_json_input<T>(input_json: &str) -> std::result::Result<T, ToolError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
@@ -311,7 +316,7 @@ where
|
||||
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
||||
where
|
||||
I: DeserializeOwned,
|
||||
F: FnOnce(ToolContext, I) -> Result<ToolOutput, ToolError>,
|
||||
F: FnOnce(ToolContext, I) -> std::result::Result<ToolOutput, ToolError>,
|
||||
{
|
||||
let result = parse_json_input::<I>(input_json).and_then(|input| {
|
||||
let context = ToolContext::new(tool_name);
|
||||
@@ -474,3 +479,166 @@ mod tests {
|
||||
assert!(HOST_WIT.contains("%list: func"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned Component Model instance world handled by the host-managed
|
||||
/// PluginInstanceRegistry.
|
||||
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
||||
|
||||
/// Repository WIT for the current instance world.
|
||||
pub const INSTANCE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginIngressEvent {
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginStatus {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
pub fn ready(data: Value) -> Self {
|
||||
Self {
|
||||
state: "ready".to_string(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped() -> Self {
|
||||
Self {
|
||||
state: "stopped".to_string(),
|
||||
data: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust-facing instance Plugin contract. Hosts call `start` once, then route
|
||||
/// Tool/Ingress surfaces through the same mutable instance.
|
||||
pub trait Plugin: Sized + 'static {
|
||||
fn start(config: Value) -> Result<Self>;
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
|
||||
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<Value>;
|
||||
fn status(&self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(Value::Null))
|
||||
}
|
||||
fn stop(&mut self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::stopped())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_error(message: impl Into<String>) -> String {
|
||||
serde_json::json!({ "error": { "message": message.into() } }).to_string()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_status(status: &PluginStatus) -> String {
|
||||
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
|
||||
}
|
||||
|
||||
/// Implement the generated Component Model `Guest` trait for an instance Plugin
|
||||
/// and export it with the `wit-bindgen` generated `export!` macro.
|
||||
///
|
||||
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
|
||||
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
|
||||
/// the `Guest` trait and `export!` macro in the current module.
|
||||
#[macro_export]
|
||||
macro_rules! export_plugin_instance {
|
||||
($adapter:ident, $plugin:ty) => {
|
||||
struct $adapter;
|
||||
|
||||
thread_local! {
|
||||
static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
impl Guest for $adapter {
|
||||
fn start(config_json: ::std::string::String) -> ::std::string::String {
|
||||
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
|
||||
match <$plugin as $crate::Plugin>::start(config) {
|
||||
Ok(plugin) => {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
|
||||
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool(
|
||||
name: ::std::string::String,
|
||||
input_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_tool(&name, input) {
|
||||
Ok(output) => output.to_json_string(),
|
||||
Err(error) => error.into_tool_output().to_json_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_ingress(
|
||||
name: ::std::string::String,
|
||||
event_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
|
||||
Ok(event) => event,
|
||||
Err(error) => return $crate::plugin_instance_error(error.to_string()),
|
||||
};
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_ingress(&name, event) {
|
||||
Ok(output) => serde_json::to_string(&output)
|
||||
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn status() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let slot = slot.borrow();
|
||||
let Some(plugin) = slot.as_ref() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.status() {
|
||||
Ok(status) => $crate::plugin_instance_status(&status),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stop() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.stop() {
|
||||
Ok(status) => {
|
||||
let output = $crate::plugin_instance_status(&status);
|
||||
*slot = None;
|
||||
output
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
+1395
-87
File diff suppressed because it is too large
Load Diff
@@ -5375,6 +5375,8 @@ permission = "read"
|
||||
runtime: None,
|
||||
hooks: vec![],
|
||||
tools: vec![],
|
||||
services: vec![],
|
||||
ingresses: vec![],
|
||||
permissions: vec![],
|
||||
},
|
||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||
|
||||
+206
-1
@@ -1,3 +1,4 @@
|
||||
mod mcp_cli;
|
||||
mod memory_lint;
|
||||
mod objective_cli;
|
||||
mod plugin_cli;
|
||||
@@ -18,6 +19,7 @@ enum Mode {
|
||||
Help,
|
||||
MemoryLintHelp,
|
||||
MemoryLint(LintCliOptions),
|
||||
Mcp(mcp_cli::McpCliCommand),
|
||||
Plugin(plugin_cli::PluginCliCommand),
|
||||
Objective(objective_cli::ObjectiveCli),
|
||||
Session(session_cli::SessionCli),
|
||||
@@ -70,6 +72,13 @@ async fn main() -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Mcp(command) => match mcp_cli::run(command) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("yoi mcp: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Plugin(command) => match plugin_cli::run(command) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
@@ -186,6 +195,10 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||
let plugin_cli = parse_plugin_args(&args[1..])?;
|
||||
return Ok(Mode::Plugin(plugin_cli));
|
||||
}
|
||||
"mcp" => {
|
||||
let mcp_cli = parse_mcp_args(&args[1..])?;
|
||||
return Ok(Mode::Mcp(mcp_cli));
|
||||
}
|
||||
"panel" => {
|
||||
return Ok(Mode::Tui {
|
||||
mode: LaunchMode::Panel,
|
||||
@@ -593,6 +606,147 @@ fn plugin_usage() -> &'static str {
|
||||
"usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
}
|
||||
|
||||
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
"yoi mcp requires `list`, `show <server>`, `tools [server]`, `resources [server]`, or `prompts [server]`".to_string(),
|
||||
));
|
||||
};
|
||||
match subcommand.as_str() {
|
||||
"list" => {
|
||||
let (mcp_args, positional) = parse_mcp_common_args(rest)?;
|
||||
if !positional.is_empty() {
|
||||
return Err(ParseError(
|
||||
"yoi mcp list does not accept positional arguments".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(mcp_cli::McpCliCommand::List(mcp_args))
|
||||
}
|
||||
"show" => {
|
||||
let (mcp_args, positional) = parse_mcp_common_args(rest)?;
|
||||
match positional.as_slice() {
|
||||
[server] => Ok(mcp_cli::McpCliCommand::Show {
|
||||
server: server.clone(),
|
||||
args: mcp_args,
|
||||
}),
|
||||
[] => Err(ParseError(
|
||||
"yoi mcp show requires a server name".to_string(),
|
||||
)),
|
||||
_ => Err(ParseError(
|
||||
"yoi mcp show accepts exactly one server name".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
"tools" => {
|
||||
let (mcp_args, positional) = parse_mcp_common_args(rest)?;
|
||||
match positional.as_slice() {
|
||||
[] => Ok(mcp_cli::McpCliCommand::Tools {
|
||||
server: None,
|
||||
args: mcp_args,
|
||||
}),
|
||||
[server] => Ok(mcp_cli::McpCliCommand::Tools {
|
||||
server: Some(server.clone()),
|
||||
args: mcp_args,
|
||||
}),
|
||||
_ => Err(ParseError(
|
||||
"yoi mcp tools accepts at most one server name".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
"resources" => {
|
||||
let (mcp_args, positional) = parse_mcp_common_args(rest)?;
|
||||
match positional.as_slice() {
|
||||
[] => Ok(mcp_cli::McpCliCommand::Resources {
|
||||
server: None,
|
||||
args: mcp_args,
|
||||
}),
|
||||
[server] => Ok(mcp_cli::McpCliCommand::Resources {
|
||||
server: Some(server.clone()),
|
||||
args: mcp_args,
|
||||
}),
|
||||
_ => Err(ParseError(
|
||||
"yoi mcp resources accepts at most one server name".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
"prompts" => {
|
||||
let (mcp_args, positional) = parse_mcp_common_args(rest)?;
|
||||
match positional.as_slice() {
|
||||
[] => Ok(mcp_cli::McpCliCommand::Prompts {
|
||||
server: None,
|
||||
args: mcp_args,
|
||||
}),
|
||||
[server] => Ok(mcp_cli::McpCliCommand::Prompts {
|
||||
server: Some(server.clone()),
|
||||
args: mcp_args,
|
||||
}),
|
||||
_ => Err(ParseError(
|
||||
"yoi mcp prompts accepts at most one server name".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => Err(ParseError(mcp_usage().to_string())),
|
||||
other => Err(ParseError(format!("unknown yoi mcp command: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mcp_common_args(
|
||||
args: &[String],
|
||||
) -> Result<(mcp_cli::McpCliArgs, Vec<String>), ParseError> {
|
||||
let mut mcp_args = mcp_cli::McpCliArgs::default();
|
||||
let mut positional = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--json" {
|
||||
mcp_args.json = true;
|
||||
index += 1;
|
||||
} else if arg == "--workspace" {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| ParseError("--workspace requires a value".to_string()))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(ParseError("--workspace requires a value".to_string()));
|
||||
}
|
||||
mcp_args.workspace = Some(PathBuf::from(value));
|
||||
index += 2;
|
||||
} else if let Some(value) = arg.strip_prefix("--workspace=") {
|
||||
if value.is_empty() {
|
||||
return Err(ParseError("--workspace requires a value".to_string()));
|
||||
}
|
||||
mcp_args.workspace = Some(PathBuf::from(value));
|
||||
index += 1;
|
||||
} else if arg == "--profile" {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| ParseError("--profile requires a value".to_string()))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(ParseError("--profile requires a value".to_string()));
|
||||
}
|
||||
mcp_args.profile = Some(value.clone());
|
||||
index += 2;
|
||||
} else if let Some(value) = arg.strip_prefix("--profile=") {
|
||||
if value.is_empty() {
|
||||
return Err(ParseError("--profile requires a value".to_string()));
|
||||
}
|
||||
mcp_args.profile = Some(value.to_string());
|
||||
index += 1;
|
||||
} else if arg == "--help" || arg == "-h" {
|
||||
return Err(ParseError(mcp_usage().to_string()));
|
||||
} else if arg.starts_with('-') {
|
||||
return Err(ParseError(format!("unknown yoi mcp argument: {arg}")));
|
||||
} else {
|
||||
positional.push(arg.clone());
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
Ok((mcp_args, positional))
|
||||
}
|
||||
|
||||
fn mcp_usage() -> &'static str {
|
||||
"usage: yoi mcp list [--workspace PATH] [--profile REF] [--json]\n yoi mcp show <server> [--workspace PATH] [--profile REF] [--json]\n yoi mcp tools [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp resources [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp prompts [server] [--workspace PATH] [--profile REF] [--json]"
|
||||
}
|
||||
|
||||
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
|
||||
match args {
|
||||
[] => std::env::current_dir()
|
||||
@@ -623,7 +777,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -814,6 +968,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mcp_commands() {
|
||||
match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() {
|
||||
Mode::Mcp(mcp_cli::McpCliCommand::List(options)) => {
|
||||
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws")));
|
||||
assert!(options.json);
|
||||
}
|
||||
_ => panic!("expected MCP list mode"),
|
||||
}
|
||||
|
||||
match parse_args_from(["mcp", "show", "filesystem", "--profile", "project:mcp"]).unwrap() {
|
||||
Mode::Mcp(mcp_cli::McpCliCommand::Show { server, args }) => {
|
||||
assert_eq!(server, "filesystem");
|
||||
assert_eq!(args.profile.as_deref(), Some("project:mcp"));
|
||||
}
|
||||
_ => panic!("expected MCP show mode"),
|
||||
}
|
||||
|
||||
match parse_args_from(["mcp", "tools", "filesystem"]).unwrap() {
|
||||
Mode::Mcp(mcp_cli::McpCliCommand::Tools { server, .. }) => {
|
||||
assert_eq!(server.as_deref(), Some("filesystem"));
|
||||
}
|
||||
_ => panic!("expected MCP tools mode"),
|
||||
}
|
||||
|
||||
match parse_args_from(["mcp", "resources"]).unwrap() {
|
||||
Mode::Mcp(mcp_cli::McpCliCommand::Resources { server, .. }) => {
|
||||
assert!(server.is_none());
|
||||
}
|
||||
_ => panic!("expected MCP resources mode"),
|
||||
}
|
||||
|
||||
match parse_args_from(["mcp", "prompts", "filesystem"]).unwrap() {
|
||||
Mode::Mcp(mcp_cli::McpCliCommand::Prompts { server, .. }) => {
|
||||
assert_eq!(server.as_deref(), Some("filesystem"));
|
||||
}
|
||||
_ => panic!("expected MCP prompts mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mcp_rejects_usage_errors() {
|
||||
let err = parse_args_from(["mcp", "show"]).unwrap_err();
|
||||
assert_eq!(err.to_string(), "yoi mcp show requires a server name");
|
||||
let err = parse_args_from(["mcp", "list", "extra"]).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"yoi mcp list does not accept positional arguments"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_lint_rejects_usage_errors() {
|
||||
let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -329,6 +329,24 @@ fn static_inspection_diagnostics(
|
||||
});
|
||||
}
|
||||
}
|
||||
for service in &inspection.services {
|
||||
if let Some(message) = &service.diagnostic {
|
||||
diagnostics.push(PluginDiagnosticReport {
|
||||
kind: "grant".to_string(),
|
||||
phase: "resolution".to_string(),
|
||||
message: bound_text(format!("service `{}`: {message}", service.name)),
|
||||
});
|
||||
}
|
||||
}
|
||||
for ingress in &inspection.ingresses {
|
||||
if let Some(message) = &ingress.diagnostic {
|
||||
diagnostics.push(PluginDiagnosticReport {
|
||||
kind: "grant".to_string(),
|
||||
phase: "resolution".to_string(),
|
||||
message: bound_text(format!("ingress `{}`: {message}", ingress.name)),
|
||||
});
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
|
||||
@@ -1072,6 +1090,18 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
|
||||
.iter()
|
||||
.filter_map(|tool| tool.diagnostic.as_ref()),
|
||||
)
|
||||
.chain(
|
||||
static_runtime
|
||||
.services
|
||||
.iter()
|
||||
.filter_map(|service| service.diagnostic.as_ref()),
|
||||
)
|
||||
.chain(
|
||||
static_runtime
|
||||
.ingresses
|
||||
.iter()
|
||||
.filter_map(|ingress| ingress.diagnostic.as_ref()),
|
||||
)
|
||||
{
|
||||
builder.diagnostics.push(DiagnosticSummary {
|
||||
kind: "static_eligibility".to_string(),
|
||||
@@ -1473,6 +1503,58 @@ mod tests {
|
||||
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_only_enablement_ignores_unselected_tool_static_grants() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
let digest = write_mixed_tool_service_package(workspace, "mixed");
|
||||
let mut config = PluginConfig::default();
|
||||
config.enabled.push(PluginEnablementConfig {
|
||||
id: "project:mixed".to_string(),
|
||||
digest: Some(digest.clone()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
surfaces: vec![PluginSurface::Service],
|
||||
grants: PluginGrantConfig {
|
||||
id: Some("project:mixed".to_string()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some(digest),
|
||||
permissions: vec![
|
||||
PluginPermission::surface(PluginSurface::Service),
|
||||
PluginPermission::service("svc"),
|
||||
],
|
||||
https: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
},
|
||||
config: None,
|
||||
});
|
||||
|
||||
let snapshot = inspect_snapshot(workspace, &config);
|
||||
let item = select_item(&snapshot, "project:mixed").unwrap();
|
||||
|
||||
assert_eq!(item.status, "active");
|
||||
assert!(item.static_eligible);
|
||||
assert_eq!(item.enabled_surfaces, vec!["service"]);
|
||||
assert!(
|
||||
item.tools.is_empty(),
|
||||
"unselected Tool must not be reported"
|
||||
);
|
||||
assert!(
|
||||
item.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| !diagnostic.message.contains("tool.Echo")),
|
||||
"unselected Tool grant diagnostics must not affect service-only enablement: {:#?}",
|
||||
item.diagnostics
|
||||
);
|
||||
|
||||
let show_json = serde_json::to_value(item).unwrap();
|
||||
assert_eq!(show_json["status"], "active");
|
||||
assert_eq!(
|
||||
show_json["enabled_surfaces"],
|
||||
serde_json::json!(["service"])
|
||||
);
|
||||
assert_eq!(show_json["tools"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_list_uses_required_status_vocabulary() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -2080,6 +2162,61 @@ mod tests {
|
||||
assert!(error.len() < 160);
|
||||
}
|
||||
|
||||
fn write_mixed_tool_service_package(workspace: &Path, id: &str) -> String {
|
||||
let package_dir = workspace.join(".yoi/plugins");
|
||||
fs::create_dir_all(&package_dir).unwrap();
|
||||
let package = package_dir.join(format!("{id}.yoi-plugin"));
|
||||
let manifest = format!(
|
||||
r#"schema_version = 1
|
||||
id = "{id}"
|
||||
name = "{id}"
|
||||
version = "0.1.0"
|
||||
description = "mixed surface package"
|
||||
surfaces = ["tool", "service"]
|
||||
permissions = [
|
||||
{{ kind = "surface", surface = "tool" }},
|
||||
{{ kind = "tool", name = "Echo" }},
|
||||
{{ kind = "surface", surface = "service" }},
|
||||
{{ kind = "service", name = "svc" }},
|
||||
]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
world = "yoi:plugin/instance@1.0.0"
|
||||
component = "plugin.component.wasm"
|
||||
|
||||
[[tools]]
|
||||
name = "Echo"
|
||||
description = "unselected tool"
|
||||
input_schema = {{ type = "object" }}
|
||||
|
||||
[[services]]
|
||||
name = "svc"
|
||||
description = "selected service"
|
||||
lifecycle = "host-managed"
|
||||
"#,
|
||||
);
|
||||
write_stored_zip(
|
||||
&package,
|
||||
&[
|
||||
("plugin.toml", manifest.as_bytes()),
|
||||
("plugin.component.wasm", b"placeholder component bytes"),
|
||||
],
|
||||
);
|
||||
let discovery = discover_plugins(&PluginDiscoveryOptions {
|
||||
workspace_root: workspace.to_path_buf(),
|
||||
user_data_home: None,
|
||||
limits: PluginDiscoveryLimits::default(),
|
||||
});
|
||||
discovery
|
||||
.packages
|
||||
.iter()
|
||||
.find(|package| package.identity.local_id == id)
|
||||
.unwrap()
|
||||
.digest
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn inspect_snapshot(workspace: &Path, config: &PluginConfig) -> PluginInspectionSnapshot {
|
||||
let discovery = discover_plugins(&PluginDiscoveryOptions {
|
||||
workspace_root: workspace.to_path_buf(),
|
||||
|
||||
Reference in New Issue
Block a user