plugin: add instance lifecycle surface
This commit is contained in:
parent
5ec8bae983
commit
147a600577
|
|
@ -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)]
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct PluginConfig {
|
pub struct PluginConfig {
|
||||||
|
|
@ -170,6 +204,8 @@ pub enum PluginPermission {
|
||||||
Surface { surface: PluginSurface },
|
Surface { surface: PluginSurface },
|
||||||
Tool { name: String },
|
Tool { name: String },
|
||||||
ToolNamespace { namespace: String },
|
ToolNamespace { namespace: String },
|
||||||
|
Service { name: String },
|
||||||
|
Ingress { name: String },
|
||||||
ExternalWrite,
|
ExternalWrite,
|
||||||
HostApi { api: PluginHostApi },
|
HostApi { api: PluginHostApi },
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +285,8 @@ impl PluginPermission {
|
||||||
Self::Surface { surface } => format!("surfaces.{surface}"),
|
Self::Surface { surface } => format!("surfaces.{surface}"),
|
||||||
Self::Tool { name } => format!("tool.{name}"),
|
Self::Tool { name } => format!("tool.{name}"),
|
||||||
Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"),
|
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::ExternalWrite => "external_write".to_string(),
|
||||||
Self::HostApi { api } => format!("host_api.{api}"),
|
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 {
|
pub fn host_api(api: PluginHostApi) -> Self {
|
||||||
Self::HostApi { api }
|
Self::HostApi { api }
|
||||||
}
|
}
|
||||||
|
|
@ -382,7 +428,7 @@ pub enum PluginIdParseError {
|
||||||
InvalidLocalId,
|
InvalidLocalId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PluginPackageManifest {
|
pub struct PluginPackageManifest {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
|
@ -398,6 +444,10 @@ pub struct PluginPackageManifest {
|
||||||
pub hooks: Vec<PluginHookManifest>,
|
pub hooks: Vec<PluginHookManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tools: Vec<PluginToolManifest>,
|
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;
|
/// Permission requests declared by the package. These are requests only;
|
||||||
/// enablement grants must match them before runtime surfaces are exposed.
|
/// enablement grants must match them before runtime surfaces are exposed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -413,6 +463,12 @@ impl PluginPackageManifest {
|
||||||
if !self.tools.is_empty() {
|
if !self.tools.is_empty() {
|
||||||
surfaces.insert(PluginSurface::Tool);
|
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() {
|
if self.runtime.is_some() {
|
||||||
surfaces.insert(PluginSurface::Wasm);
|
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"`.
|
/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`.
|
||||||
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
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_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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|
@ -464,6 +521,34 @@ pub struct PluginToolManifest {
|
||||||
pub external_write: bool,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PluginDiscoveryLimits {
|
pub struct PluginDiscoveryLimits {
|
||||||
pub max_packages_per_store: usize,
|
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 struct DiscoveredPluginPackage {
|
||||||
pub identity: SourceQualifiedPluginId,
|
pub identity: SourceQualifiedPluginId,
|
||||||
pub package_path: PathBuf,
|
pub package_path: PathBuf,
|
||||||
|
|
@ -529,19 +614,19 @@ pub struct DiscoveredPluginPackage {
|
||||||
/// This is data-only metadata and bytes. Constructing it parses manifests and
|
/// This is data-only metadata and bytes. Constructing it parses manifests and
|
||||||
/// validates package/archive shape, but it does not load, instantiate, or
|
/// validates package/archive shape, but it does not load, instantiate, or
|
||||||
/// execute Plugin code.
|
/// execute Plugin code.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct MaterializedPluginPackage {
|
pub struct MaterializedPluginPackage {
|
||||||
pub package: DiscoveredPluginPackage,
|
pub package: DiscoveredPluginPackage,
|
||||||
pub files: BTreeMap<String, Vec<u8>>,
|
pub files: BTreeMap<String, Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct PackedPluginPackage {
|
pub struct PackedPluginPackage {
|
||||||
pub output_path: PathBuf,
|
pub output_path: PathBuf,
|
||||||
pub package: DiscoveredPluginPackage,
|
pub package: DiscoveredPluginPackage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct PluginDiscoveryReport {
|
pub struct PluginDiscoveryReport {
|
||||||
pub packages: Vec<DiscoveredPluginPackage>,
|
pub packages: Vec<DiscoveredPluginPackage>,
|
||||||
pub diagnostics: Vec<PluginDiagnostic>,
|
pub diagnostics: Vec<PluginDiagnostic>,
|
||||||
|
|
@ -1072,7 +1157,10 @@ pub fn read_resolved_plugin_runtime_component(
|
||||||
.with_package(&record.package_label)
|
.with_package(&record.package_label)
|
||||||
.with_digest(&record.digest));
|
.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(
|
return Err(PluginDiagnostic::new(
|
||||||
PluginDiagnosticKind::Api,
|
PluginDiagnosticKind::Api,
|
||||||
PluginDiagnosticPhase::Manifest,
|
PluginDiagnosticPhase::Manifest,
|
||||||
|
|
@ -1918,7 +2006,10 @@ fn validate_manifest(
|
||||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||||
.with_package(label));
|
.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(
|
return Err(PluginDiagnostic::new(
|
||||||
PluginDiagnosticKind::Api,
|
PluginDiagnosticKind::Api,
|
||||||
PluginDiagnosticPhase::Manifest,
|
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 {
|
for hook in &manifest.hooks {
|
||||||
if !is_safe_id(&hook.id) {
|
if !is_safe_id(&hook.id) {
|
||||||
return Err(PluginDiagnostic::new(
|
return Err(PluginDiagnostic::new(
|
||||||
|
|
@ -2425,7 +2554,13 @@ mod tests {
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
paths,
|
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!(
|
assert!(
|
||||||
RUST_COMPONENT_TOOL_TEMPLATE
|
RUST_COMPONENT_TOOL_TEMPLATE
|
||||||
|
|
@ -2451,6 +2586,86 @@ mod tests {
|
||||||
assert_eq!(manifest.tools.len(), 1);
|
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]
|
#[test]
|
||||||
fn discovers_valid_user_and_workspace_packages() {
|
fn discovers_valid_user_and_workspace_packages() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ use serde_json::Value;
|
||||||
|
|
||||||
pub use wit_bindgen;
|
pub use wit_bindgen;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, ToolError>;
|
||||||
|
|
||||||
/// Current Yoi Component Model Tool world targeted by this PDK.
|
/// Current Yoi Component Model Tool world targeted by this PDK.
|
||||||
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
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.
|
/// 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 content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||||
let output = Self {
|
let output = Self {
|
||||||
summary: normalize_summary(summary.into()),
|
summary: normalize_summary(summary.into()),
|
||||||
|
|
@ -292,7 +297,7 @@ impl ToolError {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the WIT `input-json` string into a typed input value.
|
/// 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
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
{
|
{
|
||||||
|
|
@ -311,7 +316,7 @@ where
|
||||||
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
||||||
where
|
where
|
||||||
I: DeserializeOwned,
|
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 result = parse_json_input::<I>(input_json).and_then(|input| {
|
||||||
let context = ToolContext::new(tool_name);
|
let context = ToolContext::new(tool_name);
|
||||||
|
|
@ -474,3 +479,169 @@ mod tests {
|
||||||
assert!(HOST_WIT.contains("%list: func"));
|
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";
|
||||||
|
|
||||||
|
#[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.into() }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the simple string-json instance ABI used by
|
||||||
|
/// `yoi:plugin/instance@1.0.0`.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! export_plugin_instance {
|
||||||
|
($plugin:ty) => {
|
||||||
|
mod __yoi_plugin_instance_export {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static INSTANCE: RefCell<Option<$plugin>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(export_name = "start")]
|
||||||
|
pub extern "C" fn __yoi_start(
|
||||||
|
_config_json_ptr: *const u8,
|
||||||
|
_config_json_len: usize,
|
||||||
|
) -> usize {
|
||||||
|
// This low-level symbol is a placeholder for non-component raw builds.
|
||||||
|
// Component builds should bind this macro through generated WIT glue.
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InstanceGuest;
|
||||||
|
|
||||||
|
impl InstanceGuest {
|
||||||
|
pub fn start(config_json: 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) => {
|
||||||
|
INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
|
||||||
|
serde_json::to_string(&$crate::PluginStatus::ready(
|
||||||
|
serde_json::Value::Null,
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_tool(name: String, input_json: String) -> String {
|
||||||
|
let input =
|
||||||
|
serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
|
||||||
|
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) => serde_json::to_string(&output).unwrap(),
|
||||||
|
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_ingress(name: String, event_json: 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()),
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> String {
|
||||||
|
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) => serde_json::to_string(&status).unwrap(),
|
||||||
|
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop() -> String {
|
||||||
|
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) => serde_json::to_string(&status).unwrap(),
|
||||||
|
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5375,6 +5375,8 @@ permission = "read"
|
||||||
runtime: None,
|
runtime: None,
|
||||||
hooks: vec![],
|
hooks: vec![],
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
|
services: vec![],
|
||||||
|
ingresses: vec![],
|
||||||
permissions: vec![],
|
permissions: vec![],
|
||||||
},
|
},
|
||||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||||
|
|
|
||||||
|
|
@ -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
|
diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,3 +179,26 @@ semantics while moving package authors onto WIT/canonical ABI bindings.
|
||||||
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS
|
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS
|
||||||
payloads are deferred to a follow-up API-design step rather than accidentally
|
payloads are deferred to a follow-up API-design step rather than accidentally
|
||||||
omitted.
|
omitted.
|
||||||
|
|
||||||
|
## Instance lifecycle surface
|
||||||
|
|
||||||
|
The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves
|
||||||
|
runtime ownership from per-Tool artifact execution to a host-managed
|
||||||
|
`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces,
|
||||||
|
so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal
|
||||||
|
authority model.
|
||||||
|
|
||||||
|
Important boundaries:
|
||||||
|
|
||||||
|
- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput`
|
||||||
|
that is visible in the Worker history path.
|
||||||
|
- Service and Ingress grants are separate from Tool grants. Sharing an instance
|
||||||
|
does not authorize a surface that lacks its own `surface.*` and per-surface
|
||||||
|
permission/grant.
|
||||||
|
- Ingress delivery accepts bounded typed untrusted events and returns explicit
|
||||||
|
JSON to the host. It does not call model Tools or mutate LLM context/history.
|
||||||
|
- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted
|
||||||
|
behind `PluginInstanceRegistry` for compatibility rather than executed through
|
||||||
|
a separate authority path.
|
||||||
|
- Host APIs such as `https` and `fs` remain independently grant-gated and still
|
||||||
|
reject ambient filesystem/network authority.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "example-yoi-instance-plugin"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Yoi instance Plugin template
|
||||||
|
|
||||||
|
This template targets `yoi:plugin/instance@1.0.0`. The host creates one
|
||||||
|
`PluginInstance` for the package; Tool, Service, and Ingress surfaces share that
|
||||||
|
instance state while each surface keeps separate permissions/grants.
|
||||||
|
|
||||||
|
Tools still run only through ordinary model/user-initiated Tool calls. Ingress
|
||||||
|
handlers receive bounded typed untrusted events and must return explicit JSON
|
||||||
|
for host-mediated visible/durable paths.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Build with:
|
||||||
|
# cargo component build --release
|
||||||
|
# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
schema_version = 1
|
||||||
|
id = "example.rust_instance_plugin"
|
||||||
|
name = "Rust Instance Plugin Template"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state."
|
||||||
|
surfaces = ["tool", "service", "ingress"]
|
||||||
|
permissions = [
|
||||||
|
{ kind = "surface", surface = "tool" },
|
||||||
|
{ kind = "tool", name = "example_instance_tool" },
|
||||||
|
{ kind = "surface", surface = "service" },
|
||||||
|
{ kind = "service", name = "example_instance_service" },
|
||||||
|
{ kind = "surface", surface = "ingress" },
|
||||||
|
{ kind = "ingress", name = "example_instance_ingress" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
kind = "wasm-component"
|
||||||
|
world = "yoi:plugin/instance@1.0.0"
|
||||||
|
component = "plugin.component.wasm"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "example_instance_tool"
|
||||||
|
description = "Return the input and increment shared instance state."
|
||||||
|
input_schema = { type = "object" }
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
name = "example_instance_service"
|
||||||
|
description = "Reports shared plugin instance lifecycle status."
|
||||||
|
lifecycle = "host-managed"
|
||||||
|
|
||||||
|
[[ingresses]]
|
||||||
|
name = "example_instance_ingress"
|
||||||
|
description = "Accepts bounded in-process ingress events."
|
||||||
|
event_kinds = ["example"]
|
||||||
|
input_schema = { type = "object" }
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput};
|
||||||
|
|
||||||
|
struct ExamplePlugin {
|
||||||
|
calls: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for ExamplePlugin {
|
||||||
|
fn start(_config: Value) -> yoi_plugin_pdk::Result<Self> {
|
||||||
|
Ok(Self { calls: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> {
|
||||||
|
self.calls += 1;
|
||||||
|
Ok(ToolOutput::text(json!({
|
||||||
|
"tool": name,
|
||||||
|
"calls": self.calls,
|
||||||
|
"input": input
|
||||||
|
}).to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> yoi_plugin_pdk::Result<Value> {
|
||||||
|
Ok(json!({
|
||||||
|
"ingress": name,
|
||||||
|
"kind": event.kind,
|
||||||
|
"source": event.source,
|
||||||
|
"calls": self.calls,
|
||||||
|
"accepted": true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(&self) -> yoi_plugin_pdk::Result<PluginStatus> {
|
||||||
|
Ok(PluginStatus::ready(json!({ "calls": self.calls })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export_plugin_instance!(ExamplePlugin);
|
||||||
12
resources/plugin/wit/yoi-plugin-instance-v1.wit
Normal file
12
resources/plugin/wit/yoi-plugin-instance-v1.wit
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package yoi:plugin@1.0.0;
|
||||||
|
|
||||||
|
world instance {
|
||||||
|
import yoi:host/https@1.0.0;
|
||||||
|
import yoi:host/fs@1.0.0;
|
||||||
|
|
||||||
|
export start: func(config-json: string) -> string;
|
||||||
|
export handle-tool: func(name: string, input-json: string) -> string;
|
||||||
|
export handle-ingress: func(name: string, event-json: string) -> string;
|
||||||
|
export status: func() -> string;
|
||||||
|
export stop: func() -> string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user