merge: 00001KVXK0WEA plugin pdk service events

This commit is contained in:
Keisuke Hirata 2026-06-25 16:55:35 +09:00
commit 8d4fee231b
No known key found for this signature in database
11 changed files with 569 additions and 80 deletions

View File

@ -468,15 +468,48 @@ mod tests {
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "".len());
}
#[test]
fn service_output_helper_builds_runtime_command_envelope() {
let event = PluginIngressEvent {
kind: "websocket_text".to_string(),
source: "websocket:wss://example.test/socket".to_string(),
ingress_name: "example_ws".to_string(),
payload: json!({"text":"ping"}),
created_at: "2026-06-25T00:00:00Z".to_string(),
attempt: 1,
correlation_id: "event-1".to_string(),
};
assert_eq!(event.websocket_text(), Some("ping"));
let output =
ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong")
.unwrap();
let value = serde_json::to_value(output).unwrap();
assert_eq!(value["accepted"], true);
assert_eq!(value["output_commands"][0]["source_event_id"], "event-1");
assert_eq!(value["output_commands"][0]["command_id"], "reply-1");
assert_eq!(value["output_commands"][0]["kind"], "websocket_send");
assert_eq!(value["output_commands"][0]["payload"]["text"], "pong");
assert_eq!(
value["output_commands"][0]["requested_at"],
"2026-06-25T00:00:00Z"
);
}
#[test]
fn wit_constants_match_current_world() {
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
assert!(TOOL_WIT.contains("world tool"));
assert!(TOOL_WIT.contains("export call"));
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
assert!(HOST_WIT.contains("interface https"));
assert!(HOST_WIT.contains("interface request"));
assert!(HOST_WIT.contains("interface websocket"));
assert!(HOST_WIT.contains("interface fs"));
assert!(HOST_WIT.contains("%list: func"));
assert!(INSTANCE_WIT.contains("world instance"));
assert!(INSTANCE_WIT.contains("export handle-ingress"));
assert!(INSTANCE_WIT.contains("websocket_send"));
}
}
@ -488,12 +521,48 @@ pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
pub const INSTANCE_WIT: &str =
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PluginIngressEvent {
pub kind: String,
pub source: String,
#[serde(default)]
pub ingress_name: String,
#[serde(default)]
pub payload: Value,
#[serde(default)]
pub created_at: String,
#[serde(default = "default_attempt")]
pub attempt: u32,
#[serde(default)]
pub correlation_id: String,
}
impl PluginIngressEvent {
/// Return the text payload carried by a host-owned WebSocket ingress event.
pub fn websocket_text(&self) -> Option<&str> {
self.payload.get("text").and_then(Value::as_str)
}
/// Build a `websocket_send` output command that replies through the
/// host-owned Service WebSocket driver. The host still validates the target
/// URL and matching grants before sending.
pub fn websocket_send(
&self,
command_id: impl Into<String>,
url: impl Into<String>,
text: impl Into<String>,
) -> Result<ServiceOutputCommand> {
ServiceOutputCommand::new(
self,
command_id,
ServiceOutputCommandKind::WebSocketSend,
serde_json::json!({ "url": url.into(), "text": text.into() }),
)
}
}
fn default_attempt() -> u32 {
1
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -519,12 +588,118 @@ impl PluginStatus {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceOutputCommandKind {
DiagnosticStatusUpdate,
HostRequestDispatch,
#[serde(rename = "websocket_send")]
WebSocketSend,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ServiceOutputCommand {
pub correlation_id: String,
pub source_event_id: String,
pub command_id: String,
pub kind: ServiceOutputCommandKind,
pub payload: Value,
pub requested_at: String,
}
impl ServiceOutputCommand {
pub fn new(
event: &PluginIngressEvent,
command_id: impl Into<String>,
kind: ServiceOutputCommandKind,
payload: impl Serialize,
) -> Result<Self> {
let command_id = sanitize_command_id(command_id.into());
if command_id.is_empty() {
return Err(ToolError::invalid_output(
"service output command_id must not be empty",
));
}
let source_event_id = event.correlation_id.clone();
if source_event_id.is_empty() {
return Err(ToolError::invalid_output(
"service output command requires ingress event correlation_id",
));
}
let requested_at = if event.created_at.is_empty() {
"1970-01-01T00:00:00Z".to_string()
} else {
event.created_at.clone()
};
Ok(Self {
correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")),
source_event_id,
command_id,
kind,
payload: serde_json::to_value(payload).map_err(ToolError::serialization)?,
requested_at,
})
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ServiceOutput {
#[serde(default)]
pub accepted: bool,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub data: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_commands: Vec<ServiceOutputCommand>,
}
impl ServiceOutput {
pub fn accepted(data: impl Serialize) -> Result<Self> {
Ok(Self {
accepted: true,
data: serde_json::to_value(data).map_err(ToolError::serialization)?,
output_commands: Vec::new(),
})
}
pub fn empty() -> Self {
Self {
accepted: true,
data: Value::Null,
output_commands: Vec::new(),
}
}
pub fn with_command(mut self, command: ServiceOutputCommand) -> Self {
self.output_commands.push(command);
self
}
pub fn websocket_send(
event: &PluginIngressEvent,
command_id: impl Into<String>,
url: impl Into<String>,
text: impl Into<String>,
) -> Result<Self> {
Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?))
}
}
fn sanitize_command_id(value: String) -> String {
bounded_text(
value
.chars()
.map(|ch| if ch.is_control() { '-' } else { ch })
.collect(),
128,
)
}
/// 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 handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
fn status(&self) -> Result<PluginStatus> {
Ok(PluginStatus::ready(Value::Null))
}

View File

@ -12,6 +12,14 @@ const TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const TEMPLATE_README: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/README.md");
const SERVICE_TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
const SERVICE_TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
const SERVICE_TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
const SERVICE_TEMPLATE_README: &str =
include_str!("../../../resources/plugin/templates/rust-component-instance/README.md");
const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs");
const PDK_CARGO: &str = include_str!("../Cargo.toml");
@ -25,7 +33,7 @@ fn rust_component_tool_template_has_expected_files() {
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-revision>\""));
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
@ -45,6 +53,71 @@ fn rust_component_tool_template_has_expected_files() {
assert!(TEMPLATE_README.contains("Component Model Tool Plugin"));
}
#[test]
fn rust_component_service_template_has_event_output_pattern() {
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
let plugin: Value =
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/instance@1.0.0")
);
assert!(
plugin["permissions"]
.as_array()
.expect("permissions array")
.iter()
.any(|permission| permission["kind"].as_str() == Some("host_api")
&& permission["api"].as_str() == Some("websocket"))
);
assert_eq!(
plugin["services"].as_array().expect("services array").len(),
1
);
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
assert!(
ingress["event_kinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("websocket_text"))
);
assert!(
ingress["sources"]
.as_array()
.expect("sources")
.iter()
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
);
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
assert_eq!(websocket["host"].as_str(), Some("example.com"));
assert!(
websocket["path_prefixes"]
.as_array()
.expect("websocket path prefixes")
.iter()
.any(|prefix| prefix.as_str() == Some("/socket"))
);
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
assert!(SERVICE_TEMPLATE_README.contains("output command"));
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
}
#[test]
fn documented_sample_uses_pdk_component_path() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
@ -57,8 +130,17 @@ fn documented_sample_uses_pdk_component_path() {
#[test]
fn embedded_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-tool");
}
#[test]
fn embedded_service_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-instance");
}
fn cargo_check_template(template: &str) {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool");
let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}"));
let manifest_path = template_dir.join("Cargo.toml");
let lock_path = template_dir.join("Cargo.lock");
let _ = fs::remove_file(&lock_path);

View File

@ -727,7 +727,7 @@ fn parse_plugin_pack_args(
}
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]"
"usage: yoi plugin new <rust-component-tool|rust-component-service> <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> {
@ -901,7 +901,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [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, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (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]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <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, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (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"
);
}

View File

@ -9,10 +9,10 @@ use manifest::plugin::{
MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind,
PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport,
PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission,
PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE,
ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins,
read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins,
write_plugin_package_file,
PluginResolution, PluginSourceKind, PluginSurface, PluginTemplateResource,
RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, ResolvedPlugin,
ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, read_plugin_directory,
read_plugin_package_file, resolve_enabled_plugins, write_plugin_package_file,
};
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
@ -85,27 +85,29 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
}
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
if template != "rust-component-tool" {
return Err(format!(
"unsupported plugin template `{template}` (supported: rust-component-tool)"
)
.into());
let (template_name, resources) = embedded_template_resources(template)?;
materialize_template(destination, resources)?;
let mut next_steps = vec![
"Review plugin.toml and generated Rust source.".to_string(),
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
];
if template == "rust-component-service" {
next_steps.insert(
1,
"Implement Service ingress logic in handle_ingress and return ServiceOutput output_commands for host-owned WebSocket sends.".to_string(),
);
}
materialize_template(destination)?;
let report = NewReport {
command: "new",
template: "rust-component-tool",
template: template_name,
destination: destination.display().to_string(),
files: RUST_COMPONENT_TOOL_TEMPLATE
files: resources
.iter()
.map(|resource| resource.path.to_string())
.collect(),
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Review plugin.toml and generated Rust source.".to_string(),
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
],
next_steps,
};
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
@ -113,7 +115,23 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul
render_new_human(&report)
}
fn materialize_template(destination: &Path) -> Result<()> {
fn embedded_template_resources(
template: &str,
) -> Result<(&'static str, &'static [PluginTemplateResource])> {
match template {
"rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)),
"rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)),
_ => Err(format!(
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)"
)
.into()),
}
}
fn materialize_template(
destination: &Path,
resources: &'static [PluginTemplateResource],
) -> Result<()> {
match fs::symlink_metadata(destination) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
@ -144,7 +162,7 @@ fn materialize_template(destination: &Path) -> Result<()> {
Err(error) => return Err(error.into()),
}
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
for resource in resources {
let relative = safe_template_relative_path(resource.path)?;
let path = destination.join(relative);
if let Some(parent) = path.parent() {
@ -2136,6 +2154,65 @@ mod tests {
let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap();
assert!(human_check.contains("[partial]"));
assert!(human_check.contains("not ready to enable"));
let service_destination = dir.path().join("my-service-plugin");
let service_json = render_new(
"rust-component-service",
&service_destination,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let service_value: serde_json::Value = serde_json::from_str(&service_json).unwrap();
assert_eq!(service_value["template"], "rust-component-service");
assert!(
service_value["next_steps"]
.as_array()
.unwrap()
.iter()
.any(|step| step
.as_str()
.unwrap_or_default()
.contains("Service ingress"))
);
for resource in RUST_COMPONENT_INSTANCE_TEMPLATE {
assert!(
service_destination.join(resource.path).is_file(),
"missing service {}",
resource.path
);
}
let manifest = fs::read_to_string(service_destination.join("plugin.toml")).unwrap();
assert!(manifest.contains("kind = \"wasm-component\""));
assert!(manifest.contains("[[services]]"));
assert!(manifest.contains("[[ingresses]]"));
assert!(manifest.contains("{ kind = \"host_api\", api = \"websocket\" }"));
assert!(manifest.contains("[[websocket]]"));
assert!(manifest.contains("host = \"example.com\""));
assert!(manifest.contains("path_prefixes = [\"/socket\"]"));
let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap();
assert!(source.contains("ServiceOutput::websocket_send"));
assert!(!source.contains("recv(timeout"));
let service_check = render_check(&service_destination, &PluginCliArgs::default()).unwrap();
assert!(service_check.contains("plugin check:"));
assert!(service_check.contains("service"));
let service_package = dir.path().join("my-service-plugin.yoi-plugin");
let service_pack_json = render_pack(
&service_destination,
Some(&service_package),
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let service_pack_value: serde_json::Value =
serde_json::from_str(&service_pack_json).unwrap();
assert_eq!(service_pack_value["status"], "packed");
assert!(service_package.is_file());
let error = render_new(
"rust-component-tool",
&destination,

View File

@ -23,7 +23,7 @@ Yoi's Plugin platform is meant to make extension behavior reviewable before it b
Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries.
Yoi's preferred Plugin shape is **Tool first**. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. Long-running services, inbound events, and autonomous routing are future Service/Ingress work; they should not be hidden inside a Tool package.
Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands.
Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead.
@ -42,10 +42,10 @@ Implemented foundation:
- read-only `yoi plugin list/show` inspection;
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
Still intentionally separate/future work:
Still intentionally limited or separate from this guide:
- multi-language SDK/PDK crates;
- Service / Ingress surfaces;
- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`;
- WebSocket or inbound HTTP for bidirectional external event integrations;
- public registry/install/update/signature tooling.
@ -78,6 +78,8 @@ Create a Rust Component Tool starter from embedded resources:
```bash
yoi plugin new rust-component-tool ./my-plugin
# or, for a host-dispatched Service/Ingress example:
yoi plugin new rust-component-service ./my-service-plugin
```
`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin.
@ -115,7 +117,7 @@ For Tool Plugins:
- return bounded summaries and content that are useful as Tool results;
- avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call.
A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, treat that as future Service/Ingress design rather than stretching the Tool surface.
A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop.
Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand:
@ -163,6 +165,8 @@ Create a starter with:
```bash
yoi plugin new rust-component-tool ./my-plugin
# or, for a host-dispatched Service/Ingress example:
yoi plugin new rust-component-service ./my-service-plugin
```
The generated package contains:
@ -327,6 +331,62 @@ path_prefixes = ["/v1/"]
Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected.
## Service ingress and output commands
Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`.
Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper:
```rust
ServiceOutput::websocket_send(
&event,
"reply-1",
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
"pong",
)
```
This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events.
A minimal manifest shape is:
```toml
surfaces = ["tool", "service", "ingress"]
permissions = [
{ kind = "surface", surface = "service" },
{ kind = "service", name = "example_service" },
{ kind = "surface", surface = "ingress" },
{ kind = "ingress", name = "example_ws" },
{ kind = "host_api", api = "websocket" },
]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/instance@1.0.0"
component = "plugin.component.wasm"
[[services]]
name = "example_service"
description = "Host-managed service instance."
lifecycle = "host-managed"
[[ingresses]]
name = "example_ws"
description = "Handles host-owned WebSocket text events."
event_kinds = ["websocket_text", "websocket_close", "websocket_error"]
sources = ["websocket:wss://gateway.example.com/gateway"]
input_schema = { type = "object" }
[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority.
Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`.
## `websocket` host API
The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection.

View File

@ -1,14 +1,22 @@
[workspace]
[package]
name = "example-yoi-instance-plugin"
name = "yoi-rust-component-service-template"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
# Keep the embedded template checkable in-place without making it a member of
# Yoi's root workspace. A copied starter remains a normal standalone package.
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Out-of-tree Plugin packages should replace the local path with a pinned
# Yoi source revision. Use rev, not branch, for reproducible builds:
# yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-commit-sha>" }

View File

@ -1,9 +1,10 @@
# Yoi instance Plugin template
# Rust Service 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.
This template targets the Component Model-only runtime (`runtime.kind = "wasm-component"`) and exports the `yoi:plugin/instance@1.0.0` world.
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.
It demonstrates both authoring surfaces supported by a shared Plugin instance:
- `example_echo` is an ordinary request/response Tool handler.
- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. The manifest declares `host_api.websocket` plus a matching `[[websocket]]` target for the example URL. Enablement grants must explicitly allow the same WebSocket target before the host will send output commands.
Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory.

View File

@ -1,16 +1,17 @@
schema_version = 1
id = "example.rust_instance_plugin"
name = "Rust Instance Plugin Template"
id = "example.rust_service_plugin"
name = "Rust Service Plugin Template"
version = "0.1.0"
description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state."
description = "Example Component Model Plugin with Tool and Service ingress handlers."
surfaces = ["tool", "service", "ingress"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_instance_tool" },
{ kind = "tool", name = "example_echo" },
{ kind = "surface", surface = "service" },
{ kind = "service", name = "example_instance_service" },
{ kind = "service", name = "example_service" },
{ kind = "surface", surface = "ingress" },
{ kind = "ingress", name = "example_instance_ingress" },
{ kind = "ingress", name = "example_ws" },
{ kind = "host_api", api = "websocket" },
]
[runtime]
@ -19,17 +20,23 @@ 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."
name = "example_echo"
description = "Echo input text through the shared Plugin instance."
input_schema = { type = "object" }
[[services]]
name = "example_instance_service"
description = "Reports shared plugin instance lifecycle status."
name = "example_service"
description = "Host-managed service instance for bounded ingress events."
lifecycle = "host-managed"
[[ingresses]]
name = "example_instance_ingress"
description = "Accepts bounded in-process ingress events."
event_kinds = ["example"]
name = "example_ws"
description = "Handles host-owned WebSocket text events and returns websocket_send output commands."
event_kinds = ["websocket_text"]
sources = ["websocket:wss://example.com/socket"]
input_schema = { type = "object" }
[[websocket]]
scheme = "wss"
host = "example.com"
path_prefixes = ["/socket"]

View File

@ -1,6 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput};
use yoi_plugin_pdk::{
export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError,
ToolOutput,
};
wit_bindgen::generate!({
world: "instance",
@ -9,24 +13,42 @@ wit_bindgen::generate!({
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Default)]
struct ExamplePlugin {
calls: u64,
count: u64,
}
#[derive(Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Serialize)]
struct EchoOutput {
text: String,
count: u64,
}
impl Plugin for ExamplePlugin {
fn start(_config: Value) -> yoi_plugin_pdk::Result<Self> {
Ok(Self { calls: 0 })
fn start(config: Value) -> Result<Self, ToolError> {
Ok(Self {
count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0),
})
}
fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> {
self.calls += 1;
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput, ToolError> {
if name != "example_echo" {
return Err(ToolError::invalid_input(format!("unknown tool: {name}")));
}
let input: EchoInput =
serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?;
self.count += 1;
ToolOutput::json(
format!("{name} handled by shared instance"),
json!({
"tool": name,
"calls": self.calls,
"input": input
}),
format!("echoed {} bytes", input.text.len()),
EchoOutput {
text: input.text,
count: self.count,
},
)
}
@ -34,18 +56,29 @@ impl Plugin for ExamplePlugin {
&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
}))
) -> Result<ServiceOutput, ToolError> {
if name != "example_ws" {
return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?);
}
fn status(&self) -> yoi_plugin_pdk::Result<PluginStatus> {
Ok(PluginStatus::ready(json!({ "calls": self.calls })))
let Some(text) = event.websocket_text() else {
return Ok(ServiceOutput::accepted(json!({
"accepted": true,
"kind": event.kind,
}))?);
};
self.count += 1;
ServiceOutput::websocket_send(
&event,
format!("example-reply-{}", self.count),
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
format!("echo({}): {text}", self.count),
)
}
fn status(&self) -> Result<PluginStatus, ToolError> {
Ok(PluginStatus::ready(json!({ "count": self.count })))
}
}

View File

@ -5,9 +5,55 @@ world instance {
import yoi:host/websocket@1.0.0;
import yoi:host/fs@1.0.0;
/// Start one host-managed Plugin instance. `config-json` is the opaque
/// enablement config copied from the Profile/plugin grant record. The return
/// string is PluginStatus JSON: `{ "state": "ready|running|stopped|...",
/// "data": <json> }`.
export start: func(config-json: string) -> string;
/// Execute a manifest-declared Tool on the shared instance. `input-json` is
/// ordinary Tool input JSON and the return string is ToolOutput JSON.
export handle-tool: func(name: string, input-json: string) -> string;
/// Handle one host-dispatched Service/Ingress event. `event-json` is an
/// ingress event envelope with at least:
///
/// ```json
/// {
/// "kind": "websocket_text|websocket_close|websocket_error|...",
/// "source": "websocket:wss://host/path|...",
/// "ingress_name": "manifest_ingress_name",
/// "payload": { "text": "..." },
/// "created_at": "RFC3339 timestamp",
/// "attempt": 1,
/// "correlation_id": "host event id"
/// }
/// ```
///
/// The return string is ServiceOutput JSON. To request host-mediated side
/// effects, return top-level `output_commands`, for example:
///
/// ```json
/// {
/// "accepted": true,
/// "output_commands": [{
/// "correlation_id": "command correlation id",
/// "source_event_id": "matching ingress correlation_id",
/// "command_id": "guest command id",
/// "kind": "websocket_send",
/// "payload": { "url": "wss://host/path", "text": "reply" },
/// "requested_at": "RFC3339 timestamp"
/// }]
/// }
/// ```
///
/// Output commands are parsed, bounded, grant-checked, and executed by the
/// host. They are not ordinary ToolOutput and do not inject hidden context.
export handle-ingress: func(name: string, event-json: string) -> string;
/// Return PluginStatus JSON for the shared host-managed instance.
export status: func() -> string;
/// Stop the shared instance and return final PluginStatus JSON.
export stop: func() -> string;
}

View File

@ -5,8 +5,8 @@ world tool {
import yoi:host/websocket@1.0.0;
import yoi:host/fs@1.0.0;
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
/// JSON and the returned string is the same ToolOutput JSON accepted by the
/// legacy raw-Wasm ABI.
/// Execute a manifest-declared Tool. `input-json` is ordinary Tool input JSON
/// and the returned string is ToolOutput JSON accepted by the current
/// Component Model Plugin runtime.
export call: func(tool-name: string, input-json: string) -> string;
}