feat: deliver committed Ticket events to plugin services

This commit is contained in:
2026-10-31 04:58:19 +09:00
parent 4f7dcfda61
commit b508fef698
94 changed files with 11852 additions and 247 deletions
+66 -6
View File
@@ -118,16 +118,20 @@ pub const RUST_COMPONENT_HTTPS_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
},
];
/// The only supported v2 surface; future surfaces are not success stubs.
/// Exact supported execution surfaces.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
Tool,
Service,
}
impl fmt::Display for PluginSurface {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("tool")
f.write_str(match self {
Self::Tool => "tool",
Self::Service => "service",
})
}
}
@@ -140,6 +144,7 @@ pub enum PluginRequestedGrant {
slot: String,
},
TicketRead,
TicketEvents,
TicketDataRead {
collection: String,
schema_id: String,
@@ -255,7 +260,10 @@ pub struct PluginPackageManifest {
pub description: Option<String>,
pub surfaces: Vec<PluginSurface>,
pub runtime: PluginRuntimeManifest,
#[serde(default)]
pub tools: Vec<PluginToolManifest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<PluginServiceManifest>,
/// Closed static DCDL type expression, not JSON Schema or guest code.
pub config_schema: String,
#[serde(default)]
@@ -272,6 +280,23 @@ impl PluginPackageManifest {
}
}
/// Closed version-1 Ticket event vocabulary; arbitrary subscriptions are rejected.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginTicketEventType {
TicketChanged,
TicketDeleted,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginServiceManifest {
pub name: String,
pub subscriptions: Vec<PluginTicketEventType>,
}
pub const PLUGIN_COMPONENT_SERVICE_WORLD: &str = "yoi:plugin/ticket-service-plugin@2.2.0";
pub const PLUGIN_COMPONENT_TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0";
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
@@ -1047,10 +1072,43 @@ fn validate_manifest(
"safe id, name and version are required".into(),
));
}
if manifest.surfaces != [PluginSurface::Tool] || manifest.tools.is_empty() {
let service_world = manifest.runtime.world == PLUGIN_COMPONENT_SERVICE_WORLD;
if if service_world {
manifest.surfaces != [PluginSurface::Service]
|| manifest.services.is_empty()
|| !manifest.tools.is_empty()
} else {
manifest.surfaces != [PluginSurface::Tool]
|| manifest.tools.is_empty()
|| !manifest.services.is_empty()
} {
return Err(reject(
PluginDiagnosticKind::Surface,
"only nonempty Tool packages are supported".into(),
"surface and nonempty declarations must match the exact Tool or Service world".into(),
));
}
let mut service_names = BTreeSet::new();
for service in &manifest.services {
if !is_safe_id(&service.name)
|| !service_names.insert(&service.name)
|| service.subscriptions.is_empty()
|| service.subscriptions.iter().collect::<BTreeSet<_>>().len()
!= service.subscriptions.len()
{
return Err(reject(
PluginDiagnosticKind::Malformed,
"invalid or duplicate Service/subscription".into(),
));
}
}
if service_world
&& !manifest
.requested_grants
.contains(&PluginRequestedGrant::TicketEvents)
{
return Err(reject(
PluginDiagnosticKind::Api,
"Service requires TicketEvents request".into(),
));
}
let runtime = &manifest.runtime;
@@ -1114,7 +1172,7 @@ fn validate_manifest(
));
}
}
let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD;
let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD || service_world;
if !ticket_world
&& (!manifest.data_collections.is_empty()
|| manifest.requested_grants.iter().any(|g| {
@@ -1128,7 +1186,7 @@ fn validate_manifest(
{
return Err(reject(
PluginDiagnosticKind::Api,
"Ticket capabilities require the exact @2.1.0 Ticket world".into(),
"Ticket capabilities require an exact Ticket world".into(),
));
}
let mut collections = BTreeSet::new();
@@ -1154,6 +1212,8 @@ fn validate_manifest(
PluginRequestedGrant::Logging if !logging => logging = true,
PluginRequestedGrant::Https { slot }
if slots.contains(slot) && requested_slots.insert(slot) => {}
PluginRequestedGrant::TicketEvents
if service_world && ticket_grants.insert(("events", "", "", "")) => {}
PluginRequestedGrant::TicketRead
if ticket_world && ticket_grants.insert(("read", "", "", "")) => {}
PluginRequestedGrant::TicketDataRead {
+12 -1
View File
@@ -37,6 +37,7 @@ pub fn validate_component(
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
"yoi:plugin/ticket-service-plugin@2.2.0" => "ticket-service-plugin",
_ => return Err(format!("unsupported Component world `{world}`")),
};
if !wasmparser::Parser::is_component(bytes) {
@@ -111,7 +112,7 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
let old_package = resolve
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
let package = if world_name == "ticket-tool-plugin" {
let package = if matches!(world_name, "ticket-tool-plugin" | "ticket-service-plugin") {
resolve
.push_str(
"yoi-host-v2.1.wit",
@@ -129,6 +130,16 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
} else {
old_package
};
let package = if world_name == "ticket-service-plugin" {
resolve
.push_str(
"yoi-plugin-v2.2.wit",
include_str!("../../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit"),
)
.map_err(|e| format!("invalid Service WIT: {e:#}"))?
} else {
package
};
let world = resolve
.select_world(&[package], Some(world_name))
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
+28
View File
@@ -36,6 +36,10 @@ pub struct PluginProfileSelection {
pub installation_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ticket_events: bool,
#[serde(default)]
pub logging: bool,
#[serde(default)]
@@ -281,3 +285,27 @@ strict = true
}
}
}
#[cfg(test)]
mod service_tests {
use super::*;
use serde_json::json;
#[test]
fn service_selection_is_explicit_and_old_profiles_remain_off() {
let old: PluginProfileSelection =
serde_json::from_value(json!({"installation_id":"i", "tools":["read"]})).unwrap();
assert!(old.services.is_empty());
assert!(!old.ticket_events);
let retained = serde_json::to_value(&old).unwrap();
assert!(retained.get("services").is_none());
assert!(retained.get("ticket_events").is_none());
let selected: PluginProfileSelection = serde_json::from_value(
json!({"installation_id":"i", "services":["watch"], "ticket_events":true}),
)
.unwrap();
assert_eq!(selected.services, ["watch"]);
assert!(selected.ticket_events);
assert_ne!(serde_json::to_value(selected).unwrap(), retained);
}
}