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
+18
View File
@@ -16,7 +16,9 @@ mod notification;
mod plugin;
pub mod plugin_data;
pub mod plugin_host;
pub mod plugin_service;
pub use plugin_data::*;
pub use plugin_service::*;
mod plugin_types;
pub use notification::*;
pub use plugin::*;
@@ -3094,6 +3096,22 @@ pub trait ServerApi {
#[body] request: PluginDataPurgeRequest,
) -> Result<PluginDataPurgeResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/service-consumers/{consumer_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)]
async fn plugin_service_diagnostics(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] consumer_id: String,
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/service-consumers/recover", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_service_recover(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: PluginServiceRecoveryRequest,
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_operation(
&self,
+29
View File
@@ -101,6 +101,7 @@ pub enum PluginGrantMetadata {
slot: String,
},
TicketRead,
TicketEvents,
TicketDataRead {
collection: String,
schema_id: String,
@@ -124,6 +125,15 @@ pub struct PluginDataSchemaMetadata {
pub schema: serde_json::Value,
}
/// Exact artifact subscription declarations; selection and current permission are separate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceMetadata {
pub name: String,
pub subscriptions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -139,6 +149,8 @@ pub struct PluginArtifact {
pub world: String,
pub required_imports: Vec<String>,
pub tools: Vec<PluginToolMetadata>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<PluginServiceMetadata>,
pub connections: Vec<PluginConnectionMetadata>,
pub requested_grants: Vec<PluginGrantMetadata>,
#[serde(default)]
@@ -160,6 +172,11 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
world: self.world.clone(),
required_imports: self.required_imports.clone(),
tools: self.tools.iter().map(|tool| tool.name.clone()).collect(),
services: self
.services
.iter()
.map(|service| service.name.clone())
.collect(),
connections: self
.connections
.iter()
@@ -174,6 +191,7 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
plugin_plan::PluginGrant::Https { slot: slot.clone() }
}
PluginGrantMetadata::TicketRead => plugin_plan::PluginGrant::TicketRead,
PluginGrantMetadata::TicketEvents => plugin_plan::PluginGrant::TicketEvents,
PluginGrantMetadata::TicketDataRead {
collection,
schema_id,
@@ -337,6 +355,7 @@ pub enum PluginDiagnosticPinState {
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticSurface {
Tool,
Service,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -351,10 +370,20 @@ pub struct PluginWorkerDiagnostic {
pub artifact_size: u32,
pub surfaces: Vec<PluginDiagnosticSurface>,
pub selected_tools: Vec<String>,
pub selected_services: Vec<String>,
pub service_consumers: Vec<PluginServiceConsumerDiagnostics>,
pub admission: PluginDiagnosticAdmission,
pub connections: Vec<PluginDiagnosticConnection>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceConsumerDiagnostics {
pub service_name: String,
pub diagnostics: crate::PluginServiceDiagnostics,
}
/// A successful observation is not retained admission authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
+93 -35
View File
@@ -3,25 +3,44 @@ use crate::PluginHttpMethod;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostRequest {
/// Fresh export/restore admission only; issues no invocation or effect identity.
Prepare {
plan_id: String,
installation_id: String,
tools: Vec<String>,
},
Begin {
plan_id: String,
installation_id: String,
consumer: PluginConsumer,
},
/// Irreversible cancellation of this authenticated logical invocation.
Cancel {
invocation_id: String,
},
// The same operation variants define Tool transport and the narrower leased
// Service channel. Service cannot nest itself or issue Begin/Cancel, and the
// wire schema remains finite rather than recursively admitting control requests.
macro_rules! invocation_requests {
($($variant:ident { $($field:ident: $ty:ty),* $(,)? }),* $(,)?) => {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginInvocationRequest { $($variant { $($field: $ty),* }),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostRequest {
Service { request: crate::PluginServiceRequest },
Prepare { plan_id: String, installation_id: String, tools: Vec<String> },
Begin { plan_id: String, installation_id: String, consumer: PluginConsumer },
Cancel { invocation_id: String },
$($variant { $($field: $ty),* }),*
}
impl TryFrom<PluginHostRequest> for PluginInvocationRequest {
type Error = ();
fn try_from(request: PluginHostRequest) -> Result<Self, ()> {
match request {
$(PluginHostRequest::$variant { $($field),* } => Ok(Self::$variant { $($field),* }),)*
PluginHostRequest::Service { .. } | PluginHostRequest::Prepare { .. }
| PluginHostRequest::Begin { .. } | PluginHostRequest::Cancel { .. } => Err(()),
}
}
}
impl From<PluginInvocationRequest> for PluginHostRequest {
fn from(request: PluginInvocationRequest) -> Self {
match request { $(PluginInvocationRequest::$variant { $($field),* } => Self::$variant { $($field),* }),* }
}
}
};
}
invocation_requests! {
Authorize {
invocation_id: String,
operation: PluginHostOperation,
@@ -83,7 +102,7 @@ pub enum PluginConsumer {
tool_call_id: String,
tool_name: String,
},
/// Reserved consumer identity; this does not enable service/event delivery.
/// Owner-issued Service delivery identity; never grants admission by itself.
Consumer {
consumer_id: String,
event_id: String,
@@ -129,21 +148,42 @@ pub struct PluginHttpResponse {
pub body: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginHostResponse {
Invocation {
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
macro_rules! invocation_responses {
($($variant:ident $({ $($field:ident: $ty:ty),* $(,)? })?),* $(,)?) => {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginInvocationResponse { $($variant $({ $($field: $ty),* })?),* }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostResponse {
Service { response: crate::PluginServiceResponse },
Invocation {
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
$($variant $({ $($field: $ty),* })?),*
}
impl TryFrom<PluginHostResponse> for PluginInvocationResponse {
type Error = ();
fn try_from(response: PluginHostResponse) -> Result<Self, ()> {
match response {
$(PluginHostResponse::$variant $({ $($field),* })? => Ok(Self::$variant $({ $($field),* })?),)*
PluginHostResponse::Service { .. } | PluginHostResponse::Invocation { .. } => Err(()),
}
}
}
impl From<PluginInvocationResponse> for PluginHostResponse {
fn from(response: PluginInvocationResponse) -> Self {
match response { $(PluginInvocationResponse::$variant $({ $($field),* })? => Self::$variant $({ $($field),* })?),* }
}
}
};
}
invocation_responses! {
Authorized,
Opened,
Response {
@@ -360,6 +400,24 @@ mod tests {
assert!(serde_json::from_value::<PluginHostError>(serde_json::json!({"code":"denied","effect":"not_dispatched","diagnostic_id":"d","message":"secret"})).is_err());
}
#[test]
fn service_wire_rejects_nested_control_and_identity_claims() {
let lease = serde_json::json!({"consumer_id":"consumer", "lease_token":"lease"});
for nested in [
serde_json::json!({"kind":"service", "request":{"kind":"poll", "lease":lease}}),
serde_json::json!({"kind":"prepare", "plan_id":"p", "installation_id":"i", "tools":[]}),
serde_json::json!({"kind":"begin", "plan_id":"p", "installation_id":"i", "consumer":{"kind":"consumer", "consumer_id":"c", "event_id":"e", "export_name":"s"}}),
serde_json::json!({"kind":"cancel", "invocation_id":"i"}),
serde_json::json!({"kind":"authorize", "invocation_id":"i", "operation":{"kind":"admit"}, "subject":"owner"}),
] {
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":nested}});
assert!(serde_json::from_value::<PluginHostRequest>(wire).is_err());
}
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":{"kind":"read_ticket", "invocation_id":"invocation", "ticket_id":"ticket"}}});
let request: PluginHostRequest = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), wire);
}
#[test]
fn log_integer_wire_preserves_full_wit_range_without_json_precision_loss() {
for scalar in [
+238
View File
@@ -0,0 +1,238 @@
//! Host-owned Ticket Service delivery. Lease proofs fence a driver, not authorize it.
//! All requests still require the authenticated Worker and current retained plan.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
impl api_macros::openapi::OpenApiSchema for PluginServiceRecoveryRequest {}
impl api_macros::openapi::OpenApiSchema for PluginServiceDiagnostics {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceLease {
pub consumer_id: String,
pub lease_token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginTicketEventType {
Changed,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginTicketEventEnvelope {
pub event_id: String,
pub event_type: PluginTicketEventType,
pub version: u32,
pub occurred_at: String,
pub ticket_id: String,
pub domain_event_ref: String,
pub content_digest: String,
pub correlation_id: String,
pub causation_id: Option<String>,
pub attempt: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceEventResult {
Ack,
RetryAfter {
delay_ms: u32,
},
Reject,
/// Host-observed unknown effect; never automatically redelivered.
Indeterminate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginServicePauseReason {
Rejected,
AttemptsExhausted,
QueueFull,
RetentionGap,
Indeterminate,
Authorization,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceDiagnostics {
pub consumer_id: String,
pub pause_reason: Option<PluginServicePauseReason>,
pub queued: u32,
pub attempt: u32,
pub current_event_id: Option<String>,
pub lease_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginServiceLifecycleOperation {
Admit,
Publish,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceRequest {
/// Live preflight for a reserved or running Worker. Does not create consumer
/// state, acquire a lease, or admit event effects.
Prepare {
plan_id: String,
installation_id: String,
service_name: String,
operation: PluginServiceLifecycleOperation,
},
/// Lifecycle logging uses live policy but never an event invocation/lease.
LifecycleLog {
plan_id: String,
installation_id: String,
service_name: String,
entry: crate::PluginLogEntry,
},
/// Creates a consumer at the current tail, or restores the exact retained one.
Acquire {
plan_id: String,
installation_id: String,
service_name: String,
},
Poll {
lease: PluginServiceLease,
},
Complete {
lease: PluginServiceLease,
event_id: String,
result: PluginServiceEventResult,
},
Release {
lease: PluginServiceLease,
},
Diagnostics {
consumer_id: String,
},
/// The only path for Service event Host operations. Nested Service requests,
/// Tool admission and cancellation are not accepted here.
Host {
lease: PluginServiceLease,
request: Box<crate::PluginInvocationRequest>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginServiceResponse {
Prepared,
Logged,
Acquired {
lease: PluginServiceLease,
},
Idle,
Delivery {
envelope: PluginTicketEventEnvelope,
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
Completed,
Released,
Diagnostics {
diagnostics: PluginServiceDiagnostics,
},
Host {
response: Box<crate::PluginInvocationResponse>,
},
}
/// Audited recovery is an explicit Workspace management operation, not a guest import.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginServiceRecoveryAction {
Resume,
Skip {
event_id: String,
},
/// A retention gap cannot be skipped by guessing a missing event identity.
AcceptGap,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginServiceRecoveryRequest {
pub consumer_id: String,
pub expected_pause: PluginServicePauseReason,
pub action: PluginServiceRecoveryAction,
pub operation_key: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prepare_is_lease_free_and_has_no_consumer_or_event_identity() {
let request = PluginServiceRequest::Prepare {
plan_id: "plan".into(),
installation_id: "installation".into(),
service_name: "watch".into(),
operation: PluginServiceLifecycleOperation::Publish,
};
let value = serde_json::to_value(&request).unwrap();
assert_eq!(
value,
json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","operation":"publish"})
);
assert_eq!(
serde_json::from_value::<PluginServiceRequest>(value).unwrap(),
request
);
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","lease":{"consumer_id":"consumer","lease_token":"token"}})).is_err());
}
#[test]
fn leased_host_wire_type_cannot_nest_service_or_admit_tool_calls() {
for nested in [
json!({"kind":"service","request":{"kind":"diagnostics","consumer_id":"other"}}),
json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"call","tool_name":"tool"}}),
json!({"kind":"cancel","invocation_id":"invocation"}),
] {
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"host","lease":{"consumer_id":"consumer","lease_token":"token"},"request":nested})).is_err());
}
// This must stay a finite schema for OpenAPI/TypeScript generation too.
let schema = schemars::schema_for!(PluginServiceRequest);
assert!(
!serde_json::to_string(&schema)
.unwrap()
.contains("PluginHostRequest")
);
}
#[test]
fn recovery_is_typed_management_input_not_a_guest_service_request() {
let recovery = json!({"consumer_id":"consumer","expected_pause":"indeterminate","action":{"kind":"skip","event_id":"event"},"operation_key":"reviewed-skip","reason":"effect independently reconciled"});
assert!(serde_json::from_value::<PluginServiceRecoveryRequest>(recovery.clone()).is_ok());
assert!(serde_json::from_value::<PluginServiceRequest>(recovery).is_err());
}
}
+17
View File
@@ -5,6 +5,15 @@ pub fn plugin_host_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
PluginServiceLease::decl(&config),
PluginTicketEventType::decl(&config),
PluginTicketEventEnvelope::decl(&config),
PluginServiceEventResult::decl(&config),
PluginServicePauseReason::decl(&config),
PluginServiceDiagnostics::decl(&config),
PluginServiceRequest::decl(&config),
PluginServiceResponse::decl(&config),
PluginServiceRecoveryAction::decl(&config),
PluginHttpMethod::decl(&config),
PluginConsumer::decl(&config),
PluginTicketAccess::decl(&config),
@@ -19,6 +28,8 @@ pub fn plugin_host_typescript() -> String {
PluginDataMutationLookup::decl(&config),
PluginDataEntry::decl(&config),
PluginDataPage::decl(&config),
PluginInvocationRequest::decl(&config),
PluginInvocationResponse::decl(&config),
PluginHostRequest::decl(&config),
PluginHostOperation::decl(&config),
PluginHttpRequest::decl(&config),
@@ -51,6 +62,11 @@ pub fn plugin_api_typescript() -> String {
PluginHttpMethod::decl(&config),
PluginConnectionMetadata::decl(&config),
PluginGrantMetadata::decl(&config),
PluginServiceMetadata::decl(&config),
PluginServicePauseReason::decl(&config),
PluginServiceDiagnostics::decl(&config),
PluginServiceRecoveryAction::decl(&config),
PluginServiceRecoveryRequest::decl(&config),
PluginDataSchemaMetadata::decl(&config),
PluginDataPurgeState::decl(&config),
PluginDataPurgeRequest::decl(&config),
@@ -69,6 +85,7 @@ pub fn plugin_api_typescript() -> String {
PluginWorkerDiagnosticSelection::decl(&config),
PluginDiagnosticPinState::decl(&config),
PluginDiagnosticSurface::decl(&config),
PluginServiceConsumerDiagnostics::decl(&config),
PluginWorkerDiagnostic::decl(&config),
PluginDiagnosticAdmission::decl(&config),
PluginDiagnosticConnection::decl(&config),