feat: complete Plugin controller lifecycle and owner diagnostics with real runtime coverage

This commit is contained in:
2026-10-30 07:15:03 +09:00
parent 9f13446afd
commit b824195b36
36 changed files with 2942 additions and 139 deletions
+10
View File
@@ -226,6 +226,7 @@ impl_openapi_schema!(
PluginOperationResponse, PluginOperationResponse,
PluginArtifactDeliveryRequest, PluginArtifactDeliveryRequest,
PluginArtifactDeliveryMetadata, PluginArtifactDeliveryMetadata,
PluginWorkerDiagnostics,
SubjectPermissionsResponse, SubjectPermissionsResponse,
SubjectPermissionsMutationResponse, SubjectPermissionsMutationResponse,
SubjectPermissionInterruption, SubjectPermissionInterruption,
@@ -2973,6 +2974,15 @@ pub trait ServerApi {
#[query] query: PluginListRequest, #[query] query: PluginListRequest,
) -> Result<PluginListResponse, RepositoryApiError>; ) -> Result<PluginListResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/worker-diagnostics/{runtime_id}/{worker_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,503], bearer_auth = true, browser_auth = true)]
async fn plugin_worker_diagnostics(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
) -> Result<PluginWorkerDiagnostics, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/{installation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)] #[get("/api/w/{workspace_id}/plugins/{installation_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_show( async fn plugin_show(
&self, &self,
+120
View File
@@ -221,6 +221,126 @@ pub struct PluginOperationResponse {
pub installation: PluginInstallation, pub installation: PluginInstallation,
} }
/// Point-in-time owner observation only, never a permission or execution lease.
/// Deliberately excludes configuration, credentials and artifact locations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginWorkerDiagnostics {
pub workspace_id: String,
pub runtime_id: String,
pub worker_id: String,
pub runtime_limits: PluginRuntimeLimits,
pub selection: PluginWorkerDiagnosticSelection,
}
#[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 PluginWorkerDiagnosticSelection {
/// No sealed launch selection exists; do not infer current Profile selection.
Unrecorded,
/// The owner sealed an explicitly empty selection (a NULL plan).
Empty,
Plan {
plan_id: String,
fingerprint: String,
pin_state: PluginDiagnosticPinState,
plugins: Vec<PluginWorkerDiagnostic>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticPinState {
Pending,
Active,
Restorable,
Released,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticSurface {
Tool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginWorkerDiagnostic {
pub installation_id: String,
pub package_id: String,
pub package_version: String,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub surfaces: Vec<PluginDiagnosticSurface>,
pub selected_tools: Vec<String>,
pub admission: PluginDiagnosticAdmission,
pub connections: Vec<PluginDiagnosticConnection>,
}
/// A successful observation is not retained admission authority.
#[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 PluginDiagnosticAdmission {
Admissible,
Refused {
reason_code: crate::PluginHostErrorCode,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginDiagnosticConnection {
pub slot: String,
pub effective_limits: PluginDiagnosticConnectionLimits,
}
/// Limits are absent when the current binding cannot be admitted; never fall
/// back to a launch-time policy or a permissive default.
#[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 PluginDiagnosticConnectionLimits {
Available {
timeout_ms: u32,
max_request_bytes: u32,
max_response_bytes: u32,
max_header_bytes: u32,
},
Unavailable {
reason_code: crate::PluginHostErrorCode,
},
}
/// Fixed Tool Runtime budgets, not usage counters or caller-selectable limits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginRuntimeLimits {
pub payload_bytes: u32,
pub config_bytes: u32,
pub memory_bytes: u32,
pub table_elements: u32,
pub instances: u32,
pub memories: u32,
pub tables: u32,
pub resources: u32,
pub fuel: u32,
pub wall_time_ms: u32,
pub host_operations: u32,
pub http_body_bytes: u32,
pub http_header_bytes: u32,
pub http_header_pairs: u32,
pub log_bytes: u32,
}
/// Namespace a byte cache by the authenticated Server origin and signing key. /// Namespace a byte cache by the authenticated Server origin and signing key.
/// This is not a package hash or an authorization decision. Hosts derive the /// This is not a package hash or an authorization decision. Hosts derive the
/// bundle from their current registered authority, never from a delivery reply. /// bundle from their current registered authority, never from a delivery reply.
+10
View File
@@ -48,6 +48,16 @@ pub fn plugin_api_typescript() -> String {
PluginOperationResponse::decl(&config), PluginOperationResponse::decl(&config),
PluginArtifactDeliveryRequest::decl(&config), PluginArtifactDeliveryRequest::decl(&config),
PluginArtifactDeliveryMetadata::decl(&config), PluginArtifactDeliveryMetadata::decl(&config),
PluginHostErrorCode::decl(&config),
PluginWorkerDiagnostics::decl(&config),
PluginWorkerDiagnosticSelection::decl(&config),
PluginDiagnosticPinState::decl(&config),
PluginDiagnosticSurface::decl(&config),
PluginWorkerDiagnostic::decl(&config),
PluginDiagnosticAdmission::decl(&config),
PluginDiagnosticConnection::decl(&config),
PluginDiagnosticConnectionLimits::decl(&config),
PluginRuntimeLimits::decl(&config),
]; ];
format!( format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n", "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n",
+1 -1
View File
@@ -275,7 +275,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
); );
let plugins = worker_runtime::plugin::PinnedPluginToolRuntime::new( let plugins = worker_runtime::plugin::PinnedPluginToolRuntime::new(
providers, providers,
&runtime_store_dir.join("plugin-cache"), &runtime_store_dir.with_extension("plugin-cache"),
) )
.map_err(ProcessError::WorkerAdapter)?; .map_err(ProcessError::WorkerAdapter)?;
factory = factory.with_plugin_runtime(Arc::new(plugins)); factory = factory.with_plugin_runtime(Arc::new(plugins));
+2 -8
View File
@@ -196,7 +196,7 @@ pub mod remote {
plugin_delivery::{CurrentArtifactServer, RemotePluginArtifactSource}, plugin_delivery::{CurrentArtifactServer, RemotePluginArtifactSource},
}, },
}; };
use std::{collections::HashMap, sync::Arc, time::Duration}; use std::{collections::HashMap, sync::Arc};
pub type CurrentPluginServer = dyn Fn(&str) -> Result<WorkspaceIssuerTrustRecord, yoi_plugin_runtime::delivery::DeliveryError> pub type CurrentPluginServer = dyn Fn(&str) -> Result<WorkspaceIssuerTrustRecord, yoi_plugin_runtime::delivery::DeliveryError>
+ Send + Send
+ Sync; + Sync;
@@ -268,13 +268,7 @@ pub mod remote {
.map_err(|_| "Plugin Server trust unavailable")?, .map_err(|_| "Plugin Server trust unavailable")?,
); );
let generated = client let generated = client
.server_api_client( .plugin_host_client(worker.worker_id.to_string())
crate::auth::WORKSPACE_REQUEST_PERMISSION,
Some(worker.worker_id.to_string()),
reqwest::header::HeaderMap::new(),
Duration::from_secs(30),
8 * 1024 * 1024,
)
.map_err(|_| "Plugin Host client unavailable")?; .map_err(|_| "Plugin Host client unavailable")?;
let owner = Arc::new(RegisteredOwner { let owner = Arc::new(RegisteredOwner {
source: source.clone(), source: source.clone(),
@@ -191,6 +191,37 @@ impl RuntimeWorkspaceRequestClient {
}) })
} }
pub(crate) fn plugin_host_client(
&self,
worker_id: String,
) -> Result<
server_api::ServerApiClient<RuntimeWorkspaceRequestAuthorizer>,
RuntimeWorkspaceRequestError,
> {
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.timeout(Duration::from_secs(30))
.build()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let authorizer = RuntimeWorkspaceRequestAuthorizer {
workspace_id: self.workspace_id.clone(),
runtime_id: self.runtime_id.clone(),
request_source: self.request_source.clone(),
permission: crate::auth::WORKSPACE_REQUEST_PERMISSION.into(),
worker_id: Some(worker_id),
extra_headers: HeaderMap::new(),
};
server_api::ServerApiClient::builder(&self.base_url)
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))?
.client(http)
.authorizer(authorizer)
.request_timeout(Duration::from_secs(30))
.response_body_limit(yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT)
.build()
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))
}
pub(crate) fn server_api_client( pub(crate) fn server_api_client(
&self, &self,
permission: impl Into<String>, permission: impl Into<String>,
+18 -1
View File
@@ -351,7 +351,24 @@ impl Tool for PluginTool {
&self.config_json, &self.config_json,
) )
.await .await
.map_err(|_| failure("Plugin invocation failed; no automatic retry"))? .map_err(|error| {
let reason = match error {
yoi_plugin_runtime::ExecutionError::Artifact(_) => "artifact",
yoi_plugin_runtime::ExecutionError::UnsupportedComponent(_) => {
"unsupported-component"
}
yoi_plugin_runtime::ExecutionError::InvalidInput(_) => "invalid-input",
yoi_plugin_runtime::ExecutionError::InvalidOutput(_) => "invalid-output",
yoi_plugin_runtime::ExecutionError::Cancelled => "cancelled",
yoi_plugin_runtime::ExecutionError::Deadline => "deadline",
yoi_plugin_runtime::ExecutionError::Host(_) => "host-refused-or-unavailable",
yoi_plugin_runtime::ExecutionError::Guest { .. } => "guest-error",
yoi_plugin_runtime::ExecutionError::Trap(_) => "trap-or-limit",
};
failure(&format!(
"Plugin invocation failed ({reason}); no automatic retry"
))
})?
.output; .output;
// Fence again at the adapter publication boundary, not just on guest return. // Fence again at the adapter publication boundary, not just on guest return.
host.authorize(host.context(), Operation::Publish) host.authorize(host.context(), Operation::Publish)
@@ -517,7 +517,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
"Subject Ticket responsibility and current-body notification routing" "Subject Ticket responsibility and current-body notification routing"
.into() .into()
), ),
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()) (109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
(110, "Worker Plugin launch selection associations".into())
] ]
); );
for (table, rows) in retained.iter().zip(original.iter()) { for (table, rows) in retained.iter().zip(original.iter()) {
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
// Plugin catalog remains migration 106 even when startup applies later steps. // Plugin catalog remains migration 106 even when startup applies later steps.
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(), assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
assert_eq!(after.last().unwrap(), &(109, "Durable Plugin Host invocations and HTTPS effect receipts".into())); assert_eq!(after.last().unwrap(), &(110, "Worker Plugin launch selection associations".into()));
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
@@ -76,6 +76,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
109, 109,
"Durable Plugin Host invocations and HTTPS effect receipts".into(), "Durable Plugin Host invocations and HTTPS effect receipts".into(),
)); ));
expected.push((110, "Worker Plugin launch selection associations".into()));
assert_eq!(workspace_schema_migration_history(c)?, expected); assert_eq!(workspace_schema_migration_history(c)?, expected);
crate::plugin_catalog::verify(c)?; crate::plugin_catalog::verify(c)?;
Ok(()) Ok(())
+1 -1
View File
@@ -134,7 +134,7 @@ pub fn authorize(
Ok(AdmittedPluginOperation { plan, pin, binding }) Ok(AdmittedPluginOperation { plan, pin, binding })
} }
fn live_binding( pub(crate) fn live_binding(
tx: &Transaction<'_>, tx: &Transaction<'_>,
workspace: &str, workspace: &str,
reference: &BindingPolicyReference, reference: &BindingPolicyReference,
@@ -12,16 +12,17 @@ fn plugin_receipt_tail_migrates_true_predecessor_reopens_and_matches_fresh_schem
assert!(!table_exists(&conn, "plugin_invocations").unwrap()); assert!(!table_exists(&conn, "plugin_invocations").unwrap());
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 108); assert_eq!(plan.current_schema_version, 108);
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109]); assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110]);
assert_eq!(current_schema_version(&conn).unwrap(), 108); assert_eq!(current_schema_version(&conn).unwrap(), 108);
drop(conn); drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| { store.with_conn(|conn| {
crate::plugin_host::verify_receipt_schema(conn)?; crate::plugin_host::verify_receipt_schema(conn)?;
assert_eq!(current_schema_version(conn)?, 109); assert_eq!(current_schema_version(conn)?, 110);
let upgraded = workspace_schema_migration_history(conn)?; let upgraded = workspace_schema_migration_history(conn)?;
assert_eq!(&upgraded[..history.len()], history.as_slice()); assert_eq!(&upgraded[..history.len()], history.as_slice());
assert_eq!(upgraded.last().unwrap().1, "Durable Plugin Host invocations and HTTPS effect receipts"); assert_eq!(upgraded.iter().find(|(version, _)| *version == 109).unwrap().1, "Durable Plugin Host invocations and HTTPS effect receipts");
assert_eq!(upgraded.last().unwrap().1, "Worker Plugin launch selection associations");
assert_eq!(conn.query_row("SELECT count(*) FROM workspaces WHERE workspace_id='space'", [], |row| row.get::<_, i64>(0))?, 1); assert_eq!(conn.query_row("SELECT count(*) FROM workspaces WHERE workspace_id='space'", [], |row| row.get::<_, i64>(0))?, 1);
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0);
Ok(()) Ok(())
@@ -300,7 +300,11 @@ impl Peer {
} }
pub(crate) async fn observed(&mut self) -> Vec<u8> { pub(crate) async fn observed(&mut self) -> Vec<u8> {
tokio::time::timeout(Duration::from_secs(5), self.observations.recv()) self.observed_within(Duration::from_secs(5)).await
}
pub(crate) async fn observed_within(&mut self, deadline: Duration) -> Vec<u8> {
tokio::time::timeout(deadline, self.observations.recv())
.await .await
.unwrap() .unwrap()
.unwrap() .unwrap()
@@ -7,3 +7,11 @@ CREATE TABLE worker_plugin_launches (
FOREIGN KEY(workspace_id, worker_id) REFERENCES worker_create_reservations(workspace_id, worker_id) ON DELETE CASCADE, FOREIGN KEY(workspace_id, worker_id) REFERENCES worker_create_reservations(workspace_id, worker_id) ON DELETE CASCADE,
FOREIGN KEY(workspace_id, plan_id) REFERENCES plugin_execution_plans(workspace_id, plan_id) FOREIGN KEY(workspace_id, plan_id) REFERENCES plugin_execution_plans(workspace_id, plan_id)
); );
CREATE TRIGGER worker_plugin_launch_immutable BEFORE UPDATE ON worker_plugin_launches
BEGIN SELECT RAISE(ABORT,'Worker Plugin launch selection is immutable'); END;
CREATE TRIGGER worker_plugin_launch_retained BEFORE DELETE ON worker_plugin_launches
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
BEGIN SELECT RAISE(ABORT,'Worker Plugin launch selection must be retained'); END;
CREATE TRIGGER worker_plugin_launch_identity BEFORE INSERT ON worker_plugin_launches
WHEN NOT EXISTS(SELECT 1 FROM worker_create_reservations WHERE workspace_id=NEW.workspace_id AND worker_id=NEW.worker_id AND runtime_id=NEW.runtime_id)
BEGIN SELECT RAISE(ABORT,'Worker Plugin launch reservation identity mismatch'); END;
@@ -34,6 +34,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
109, 109,
"Durable Plugin Host invocations and HTTPS effect receipts".into(), "Durable Plugin Host invocations and HTTPS effect receipts".into(),
)); ));
expected.push((110, "Worker Plugin launch selection associations".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected); assert_eq!(workspace_schema_migration_history(conn)?, expected);
subject_assignment_migration::verify(conn)?; subject_assignment_migration::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?; crate::plugin_plan::verify_plan_schema(conn)?;
@@ -352,19 +352,22 @@ impl SqliteWorkspaceStore {
pub(crate) fn verify_launch_schema(conn: &Connection) -> Result<()> { pub(crate) fn verify_launch_schema(conn: &Connection) -> Result<()> {
let expected = Connection::open_in_memory()?; let expected = Connection::open_in_memory()?;
expected.execute_batch(LAUNCH_SCHEMA_SQL)?; expected.execute_batch(LAUNCH_SCHEMA_SQL)?;
let sql: String = expected.query_row( let mut statement = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name='worker_plugin_launches' ORDER BY name")?;
"SELECT sql FROM sqlite_schema WHERE name='worker_plugin_launches'", for row in statement.query_map([], |row| {
[], Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|row| row.get(0), })? {
)?; let (name, sql) = row?;
let actual: String = conn.query_row( let actual = conn
"SELECT sql FROM sqlite_schema WHERE name='worker_plugin_launches'", .query_row(
[], "SELECT sql FROM sqlite_schema WHERE name=?1",
|row| row.get(0), [&name],
)?; |row| row.get::<_, String>(0),
if actual != sql { )
.optional()?;
if actual.as_deref() != Some(&sql) {
return Err(Error::Store("Worker Plugin launch schema mismatch".into())); return Err(Error::Store("Worker Plugin launch schema mismatch".into()));
} }
}
Ok(()) Ok(())
} }
@@ -116,6 +116,10 @@ mod schema_convergence {
version: 109, version: 109,
name: "Durable Plugin Host invocations and HTTPS effect receipts".into(), name: "Durable Plugin Host invocations and HTTPS effect receipts".into(),
}, },
WorkspaceSchemaMigrationStep {
version: 110,
name: "Worker Plugin launch selection associations".into(),
},
] ]
); );
assert_eq!( assert_eq!(
@@ -136,6 +140,7 @@ mod schema_convergence {
expected.push((107, "Immutable Worker Plugin plans and retained pins".into())); expected.push((107, "Immutable Worker Plugin plans and retained pins".into()));
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into())); expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into())); expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
expected.push((110, "Worker Plugin launch selection associations".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected); assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
+45 -3
View File
@@ -50,6 +50,8 @@ mod drive_grants;
mod plugin; mod plugin;
#[path = "server_plugin_delivery.rs"] #[path = "server_plugin_delivery.rs"]
mod plugin_delivery; mod plugin_delivery;
#[path = "server_plugin_diagnostics.rs"]
mod plugin_diagnostics;
#[path = "server_plugin_host.rs"] #[path = "server_plugin_host.rs"]
mod plugin_host; mod plugin_host;
#[path = "server_plugin_runtime.rs"] #[path = "server_plugin_runtime.rs"]
@@ -4153,6 +4155,23 @@ impl WorkspaceApi {
config: ServerConfig, config: ServerConfig,
store: Arc<dyn ControlPlaneStore>, store: Arc<dyn ControlPlaneStore>,
host_executors: HostExecutors, host_executors: HostExecutors,
) -> Result<Self> {
Self::new_with_embedded_configurators(
config,
store,
host_executors,
|factory| factory,
|_| {},
)
.await
}
async fn new_with_embedded_configurators(
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
host_executors: HostExecutors,
configure_factory: impl FnOnce(ProfileRuntimeWorkerFactory) -> ProfileRuntimeWorkerFactory,
configure_api: impl FnOnce(&mut WorkspaceApi),
) -> Result<Self> { ) -> Result<Self> {
let resource_broker = BackendResourceBroker::default(); let resource_broker = BackendResourceBroker::default();
if store.get_workspace(&config.workspace_id).await?.is_none() { if store.get_workspace(&config.workspace_id).await?.is_none() {
@@ -4190,10 +4209,12 @@ impl WorkspaceApi {
let plugin_providers = Arc::new(plugin_runtime::EmbeddedPluginRuntimeProviders::default()); let plugin_providers = Arc::new(plugin_runtime::EmbeddedPluginRuntimeProviders::default());
let plugins = worker_runtime::plugin::PinnedPluginToolRuntime::new( let plugins = worker_runtime::plugin::PinnedPluginToolRuntime::new(
plugin_providers.clone(), plugin_providers.clone(),
&config.embedded_runtime_store_root.join("plugin-cache"), &config
.embedded_runtime_store_root
.with_extension("plugin-cache"),
) )
.map_err(crate::Error::Config)?; .map_err(crate::Error::Config)?;
let execution_backend = WorkerRuntimeExecutionBackend::new( let execution_backend = WorkerRuntimeExecutionBackend::new(configure_factory(
ProfileRuntimeWorkerFactory::new(config.workspace_execution_root.clone()) ProfileRuntimeWorkerFactory::new(config.workspace_execution_root.clone())
.with_plugin_runtime(Arc::new(plugins)) .with_plugin_runtime(Arc::new(plugins))
.with_embedded_worker_mutation_dispatcher( .with_embedded_worker_mutation_dispatcher(
@@ -4210,7 +4231,7 @@ impl WorkspaceApi {
) )
.with_runtime_store_dir(config.embedded_runtime_store_root.clone()) .with_runtime_store_dir(config.embedded_runtime_store_root.clone())
.with_resource_client(Arc::new(resource_broker.clone())), .with_resource_client(Arc::new(resource_broker.clone())),
) ))
.map_err(|err| { .map_err(|err| {
crate::Error::Store(format!( crate::Error::Store(format!(
"failed to initialize embedded Worker backend: {err}" "failed to initialize embedded Worker backend: {err}"
@@ -4282,6 +4303,7 @@ impl WorkspaceApi {
{ {
api.register_workspace_runtime_binding(binding)?; api.register_workspace_runtime_binding(binding)?;
} }
configure_api(&mut api);
let plugin_owner = Arc::new(api.clone()); let plugin_owner = Arc::new(api.clone());
plugin_providers.bind(&plugin_owner); plugin_providers.bind(&plugin_owner);
api.plugin_runtime_owner = Some(plugin_owner); api.plugin_runtime_owner = Some(plugin_owner);
@@ -13622,6 +13644,24 @@ impl server_api::ServerApi for ServerApiContractService {
plugin::list(api, actor, workspace_id, query).await plugin::list(api, actor, workspace_id, query).await
} }
async fn plugin_worker_diagnostics(
&self,
actor: RequestActor,
workspace_id: String,
runtime_id: String,
worker_id: String,
) -> std::result::Result<server_api::PluginWorkerDiagnostics, server_api::RepositoryApiError>
{
plugin_diagnostics::diagnostics(
self.workspace_api()?,
actor,
workspace_id,
runtime_id,
worker_id,
)
.await
}
async fn plugin_show( async fn plugin_show(
&self, &self,
actor: RequestActor, actor: RequestActor,
@@ -38755,6 +38795,8 @@ mod tests {
include!("server_plugin_host_tests.rs"); include!("server_plugin_host_tests.rs");
include!("server_plugin_runtime_tests.rs"); include!("server_plugin_runtime_tests.rs");
} }
include!("server_plugin_controller_tests.rs");
include!("server_plugin_remote_controller_tests.rs");
mod ticket_tags_tests { mod ticket_tags_tests {
include!("server_ticket_tags_tests.rs"); include!("server_ticket_tags_tests.rs");
@@ -2662,17 +2662,19 @@ async fn delegated_subject_child_cannot_downgrade_ticket_start_to_manual_by_omit
); );
} }
const CONNECTED_RUNTIME: &str = "connected-subject-authorization"; pub(super) const CONNECTED_RUNTIME: &str = "connected-subject-authorization";
/// Shared signed Backend/Runtime connection. Only the supplied model is replaced; /// Shared signed Backend/Runtime connection. Only the supplied model is replaced;
/// temporary roots, request authentication and Controller/tool dispatch stay real. /// temporary roots, request authentication and Controller/tool dispatch stay real.
struct ConnectedWorkerHarness { pub(super) struct ConnectedWorkerHarness {
api: WorkspaceApi, pub(super) api: WorkspaceApi,
// Expose only the persisted root needed to reopen ordinary Session history.
pub(super) runtime_root: PathBuf,
runtime: worker_runtime::Runtime, runtime: worker_runtime::Runtime,
factory: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory, factory: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory,
identity: RuntimeIdentityMaterial, identity: RuntimeIdentityMaterial,
backend_url: String, backend_url: String,
owner: String, pub(super) owner: String,
materializer: worker_runtime::working_directory::RuntimeGitMaterializer, materializer: worker_runtime::working_directory::RuntimeGitMaterializer,
authorizations: Arc<Mutex<Vec<(Value, StatusCode)>>>, authorizations: Arc<Mutex<Vec<(Value, StatusCode)>>>,
runtime_errors: Arc<Mutex<Vec<String>>>, runtime_errors: Arc<Mutex<Vec<String>>>,
@@ -2681,7 +2683,12 @@ struct ConnectedWorkerHarness {
_dir: tempfile::TempDir, _dir: tempfile::TempDir,
} }
impl ConnectedWorkerHarness { impl ConnectedWorkerHarness {
async fn human(&self, method: reqwest::Method, path: &str, body: Value) -> (StatusCode, Value) { pub(super) async fn human(
&self,
method: reqwest::Method,
path: &str,
body: Value,
) -> (StatusCode, Value) {
let response = reqwest::Client::builder() let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.build() .build()
@@ -2709,6 +2716,23 @@ impl ConnectedWorkerHarness {
} }
async fn start<M: agen::llm_client::LlmClient + Clone + 'static>(model: M) -> Self { async fn start<M: agen::llm_client::LlmClient + Clone + 'static>(model: M) -> Self {
Self::start_configured(model, None).await
}
/// Keep the existing signed RuntimeHTTP assembly, adding only the production
/// Plugin bridge and a TLS-peer transport on the Server before either router
/// can handle a request. No guest, delivery or owner implementation is replaced.
pub(super) async fn start_with_plugins<M: agen::llm_client::LlmClient + Clone + 'static>(
model: M,
transport: Arc<crate::plugin_https::HttpsTransport>,
) -> Self {
Self::start_configured(model, Some(transport)).await
}
async fn start_configured<M: agen::llm_client::LlmClient + Clone + 'static>(
model: M,
plugin_https_transport: Option<Arc<crate::plugin_https::HttpsTransport>>,
) -> Self {
const RUNTIME: &str = CONNECTED_RUNTIME; const RUNTIME: &str = CONNECTED_RUNTIME;
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
@@ -2721,49 +2745,24 @@ impl ConnectedWorkerHarness {
let materializer = worker_runtime::working_directory::RuntimeGitMaterializer::new( let materializer = worker_runtime::working_directory::RuntimeGitMaterializer::new(
dir.path().join("connected-workdirs"), dir.path().join("connected-workdirs"),
); );
let factory = worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new(
dir.path().join("connected-profiles"),
)
.with_remote_worker_mutation_identity(identity.clone())
.with_workspace_request_client(
worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new(
TEST_WORKSPACE_ID,
&backend_url,
RUNTIME,
)
.with_runtime_request_source(&identity, &backend_url),
)
.with_runtime_store_dir(&runtime_root)
.with_spawn_model_client_factory(move || Box::new(model.clone()));
let execution =
worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory.clone())
.unwrap()
.with_working_directory_materializer(materializer.clone());
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
worker_runtime::fs_store::FsRuntimeStoreOptions {
root: runtime_root,
runtime_id: RUNTIME.into(),
display_name: None,
},
Arc::new(execution),
)
.unwrap();
let mut config = test_server_config(dir.path()); let mut config = test_server_config(dir.path());
config.backend_base_url = Some(backend_url.clone()); config.backend_base_url = Some(backend_url.clone());
// The embedded Runtime is unused. Both source and child below execute on the // The embedded Runtime is unused. Connected Workers execute on the real
// connected real Runtime above, not on this existing Host fixture backend. // Runtime assembled below, not on this Host fixture's embedded backend.
let control = Arc::new(test_control_store(&config)); let control = Arc::new(test_control_store(&config));
seed_test_registered_workspace(control.as_ref(), &config) seed_test_registered_workspace(control.as_ref(), &config)
.await .await
.unwrap(); .unwrap();
let api = WorkspaceApi::new_with_execution_backend( let mut api = WorkspaceApi::new_with_execution_backend(
config, config,
control, control,
Arc::new(DeterministicExecutionBackend::default()), Arc::new(DeterministicExecutionBackend::default()),
) )
.await .await
.unwrap(); .unwrap();
let plugins_enabled = plugin_https_transport.is_some();
api.plugin_https_transport = plugin_https_transport;
let now = now_registry_timestamp(); let now = now_registry_timestamp();
api.store api.store
.upsert_workspace_runtime_binding_record( .upsert_workspace_runtime_binding_record(
@@ -2796,29 +2795,84 @@ impl ConnectedWorkerHarness {
.signing_identities .signing_identities
.provision_existing(TEST_WORKSPACE_ID, &test_owner_actor().account_id) .provision_existing(TEST_WORKSPACE_ID, &test_owner_actor().account_id)
.unwrap(); .unwrap();
let verifier = worker_runtime::workspace_issuer::WorkspaceCapabilityVerifier::new( let registration = worker_runtime::workspace_issuer::WorkspaceIssuerTrustRecord {
vec![
worker_runtime::workspace_issuer::WorkspaceIssuerTrustRecord {
workspace_id: TEST_WORKSPACE_ID.into(), workspace_id: TEST_WORKSPACE_ID.into(),
backend_url: backend_url.clone(), backend_url: backend_url.clone(),
key_id: workspace_identity.key_id.clone(), key_id: workspace_identity.key_id.clone(),
algorithm: workspace_identity.algorithm.clone(), algorithm: workspace_identity.algorithm.clone(),
public_key: workspace_identity.public_key.clone().unwrap(), public_key: workspace_identity.public_key.clone().unwrap(),
public_key_fingerprint: workspace_identity public_key_fingerprint: workspace_identity.public_key_fingerprint.clone().unwrap(),
.public_key_fingerprint
.clone()
.unwrap(),
trust_id: "connected-authorization-workspace-trust".into(), trust_id: "connected-authorization-workspace-trust".into(),
state: worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Active, state: worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Active,
registered_at_unix: 1, registered_at_unix: 1,
updated_at_unix: 1, updated_at_unix: 1,
}, };
], let verifier = worker_runtime::workspace_issuer::WorkspaceCapabilityVerifier::new(
vec![registration.clone()],
Arc::new( Arc::new(
worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default(), worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default(),
), ),
) )
.unwrap(); .unwrap();
let workspace_client =
worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new(
TEST_WORKSPACE_ID,
&backend_url,
RUNTIME,
)
.with_runtime_request_source(&identity, &backend_url);
let mut factory = worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new(
dir.path().join("connected-profiles"),
)
.with_remote_worker_mutation_identity(identity.clone())
.with_workspace_request_client(workspace_client.clone())
.with_runtime_store_dir(&runtime_root)
.with_spawn_model_client_factory(move || Box::new(model.clone()));
if plugins_enabled {
// This is the Runtime's registered Server, also used by the HTTP
// verifier above, not a fabricated Plugin admission response. All
// admission/delivery/Host operations still traverse the signed client.
let current: Arc<worker_runtime::plugin::remote::CurrentPluginServer> =
Arc::new(move |workspace| {
if workspace == registration.workspace_id {
Ok(registration.clone())
} else {
Err(yoi_plugin_runtime::delivery::DeliveryError::Denied)
}
});
// Remote providers build the standard signed Host client, retaining
// PLUGIN_HOST_RESPONSE_BODY_LIMIT (finite 4.5 MiB); do not substitute
// an unbounded test client or an in-process owner fallback here.
let providers = Arc::new(
worker_runtime::plugin::remote::RemotePluginRuntimeProviders::new(
std::collections::HashMap::from([(
TEST_WORKSPACE_ID.to_string(),
workspace_client,
)]),
current,
),
);
let plugins = worker_runtime::plugin::PinnedPluginToolRuntime::new(
providers,
&runtime_root.with_extension("plugin-cache"),
)
.unwrap();
factory = factory.with_plugin_runtime(Arc::new(plugins));
}
// Freeze the fully configured factory only after installing the bridge.
let execution =
worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory.clone())
.unwrap()
.with_working_directory_materializer(materializer.clone());
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
worker_runtime::fs_store::FsRuntimeStoreOptions {
root: runtime_root.clone(),
runtime_id: RUNTIME.into(),
display_name: None,
},
Arc::new(execution),
)
.unwrap();
let runtime_errors = Arc::new(Mutex::new(Vec::<String>::new())); let runtime_errors = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_errors = runtime_errors.clone(); let captured_errors = runtime_errors.clone();
let runtime_server = ConnectedRuntimeServer::start( let runtime_server = ConnectedRuntimeServer::start(
@@ -2915,6 +2969,7 @@ impl ConnectedWorkerHarness {
Self { Self {
api, api,
runtime_root,
runtime, runtime,
factory, factory,
identity, identity,
+3 -2
View File
@@ -16,6 +16,7 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
) )
.merge(server_api_axum::plugin_install(service.clone())) .merge(server_api_axum::plugin_install(service.clone()))
.merge(server_api_axum::plugin_list(service.clone())) .merge(server_api_axum::plugin_list(service.clone()))
.merge(server_api_axum::plugin_worker_diagnostics(service.clone()))
.merge(server_api_axum::plugin_show(service.clone())) .merge(server_api_axum::plugin_show(service.clone()))
.merge(server_api_axum::plugin_artifact_add(service.clone())) .merge(server_api_axum::plugin_artifact_add(service.clone()))
.merge(server_api_axum::plugin_revoke(service.clone())) .merge(server_api_axum::plugin_revoke(service.clone()))
@@ -60,7 +61,7 @@ pub(super) fn is_catalog_path(path: &str) -> bool {
}) })
} }
fn failure(error: Error) -> RepositoryApiError { pub(super) fn failure(error: Error) -> RepositoryApiError {
let (status, code) = match error { let (status, code) = match error {
Error::PluginCatalog(error) => (error.status(), error.code()), Error::PluginCatalog(error) => (error.status(), error.code()),
Error::WorkspacePermissionDenied(_) => (403, "access_denied"), Error::WorkspacePermissionDenied(_) => (403, "access_denied"),
@@ -73,7 +74,7 @@ fn failure(error: Error) -> RepositoryApiError {
RepositoryApiError::new(status, code, format!("Plugin catalog: {code}"), vec![]) RepositoryApiError::new(status, code, format!("Plugin catalog: {code}"), vec![])
} }
async fn authorize( pub(super) async fn authorize(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace: &str, workspace: &str,
actor: &RequestActor, actor: &RequestActor,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
//! Owner-only observations reuse live execution policy without issuing authority.
//! No artifact bytes, guest, invocation or receipt is involved. The DB snapshot
//! and live Worker checks are point-in-time observations, not an execution lease.
use super::*;
use crate::plugin_host as policy;
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
use server_api::*;
fn reason(error: Error) -> PluginHostErrorCode {
policy::map_error(error).code
}
fn wire_limit(value: impl TryInto<u32>) -> Result<u32> {
value
.try_into()
.map_err(|_| Error::Config("Plugin Runtime budget exceeds diagnostic wire range".into()))
}
fn runtime_limits(limits: &yoi_plugin_runtime::Limits) -> Result<PluginRuntimeLimits> {
Ok(PluginRuntimeLimits {
payload_bytes: wire_limit(limits.payload_bytes)?,
config_bytes: wire_limit(limits.config_bytes)?,
memory_bytes: wire_limit(limits.memory_bytes)?,
table_elements: wire_limit(limits.table_elements)?,
instances: wire_limit(limits.instances)?,
memories: wire_limit(limits.memories)?,
tables: wire_limit(limits.tables)?,
resources: wire_limit(limits.resources)?,
fuel: wire_limit(limits.fuel)?,
wall_time_ms: wire_limit(limits.wall_time.as_millis())?,
host_operations: wire_limit(limits.host_operations)?,
http_body_bytes: wire_limit(limits.http_body_bytes)?,
http_header_bytes: wire_limit(limits.http_header_bytes)?,
http_header_pairs: wire_limit(limits.http_header_pairs)?,
log_bytes: wire_limit(limits.log_bytes)?,
})
}
fn effective_limits(
runtime: &yoi_plugin_runtime::Limits,
binding: &crate::plugin_binding::PluginConnectionLimits,
) -> PluginDiagnosticConnectionLimits {
PluginDiagnosticConnectionLimits::Available {
timeout_ms: u64::from(binding.timeout_ms).min(runtime.wall_time.as_millis() as u64) as u32,
max_request_bytes: u64::from(binding.max_request_bytes).min(runtime.http_body_bytes as u64)
as u32,
max_response_bytes: u64::from(binding.max_response_bytes)
.min(runtime.http_body_bytes as u64) as u32,
max_header_bytes: u64::from(binding.max_header_bytes).min(runtime.http_header_bytes as u64)
as u32,
}
}
pub(super) async fn diagnostics(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
runtime: String,
worker: String,
) -> std::result::Result<PluginWorkerDiagnostics, RepositoryApiError> {
// Management authority permits reading this projection only. Never turn the
// human actor into a Worker source or borrow its authority for live policy.
plugin::authorize(api, &workspace, &actor, true).await?;
let store = api.config_store.clone();
let w = workspace.clone();
let r = runtime.clone();
let k = worker.clone();
let selection = store
.db_executor()
.run(move || {
store.with_conn(|conn| crate::plugin_plan::worker_launch_selection(conn, &w, &r, &k))
})
.await
.map_err(plugin::failure)?;
let limits = yoi_plugin_runtime::Limits::default();
let selection = match selection {
None => PluginWorkerDiagnosticSelection::Unrecorded,
Some(None) => PluginWorkerDiagnosticSelection::Empty,
Some(Some(saved)) => {
let worker_ref = RuntimeWorkerRef::new(&runtime, &worker);
let execution = api
.plugin_execution_identity(&worker_ref)
.await
.map_err(reason);
let api = api.clone();
let store = api.config_store.clone();
let w = workspace.clone();
store
.db_executor()
.run(move || {
// Keep the existing Subject/delegation owner outside the
// config-store lock, as in the live Host boundary.
let scopes: Vec<_> = saved
.plan
.plugins()
.iter()
.map(|pin| {
subject_authorization::authorize_scope(
&api,
&worker_ref,
&OperationAuthorizationRequest {
operation: "plugin.admit".into(),
resource: PermissionResource::PluginInstallation {
workspace_id: w.clone(),
installation_id: pin.installation_id().into(),
},
},
)
.map(|_| ())
.map_err(|error| reason(error.error))
})
.collect();
store.with_conn_mut(|conn| {
// Deferred read transaction: policy::authorize and
// live_binding only SELECT; dropping it writes nothing.
let tx = conn
.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
let saved = crate::plugin_plan::plugin_plan_load(&tx, &w, &saved.plan_id)?;
let runtime = yoi_plugin_runtime::Limits::default();
let mut plugins = Vec::with_capacity(saved.plan.plugins().len());
for (pin, scope) in saved.plan.plugins().iter().zip(scopes) {
let observed =
execution
.as_ref()
.map_err(|code| *code)
.and_then(|execution| {
scope?;
policy::authorize(
&tx,
&w,
execution,
&saved.plan_id,
pin.installation_id(),
&pin.projection().tools,
&PluginHostOperation::Admit,
)
.map(|_| ())
.map_err(reason)
});
let admission = match observed {
Ok(()) => PluginDiagnosticAdmission::Admissible,
Err(reason_code) => {
PluginDiagnosticAdmission::Refused { reason_code }
}
};
let connections = pin
.projection()
.bindings
.iter()
.map(|reference| {
let effective_limits =
match policy::live_binding(&tx, &w, reference) {
Ok(binding) => {
effective_limits(&runtime, &binding.limits)
}
Err(error) => {
PluginDiagnosticConnectionLimits::Unavailable {
reason_code: reason(error),
}
}
};
PluginDiagnosticConnection {
slot: reference.slot.clone(),
effective_limits,
}
})
.collect();
plugins.push(PluginWorkerDiagnostic {
installation_id: pin.installation_id().into(),
package_id: pin.package_id().into(),
package_version: pin.package_version().into(),
package_digest: pin.package_digest().into(),
artifact_digest: pin.artifact_digest().into(),
artifact_size: pin.artifact_size(),
surfaces: pin
.projection()
.surfaces
.iter()
.map(|surface| match surface {
crate::plugin_plan::PluginSurface::Tool => {
PluginDiagnosticSurface::Tool
}
})
.collect(),
selected_tools: pin.projection().tools.clone(),
admission,
connections,
});
}
let pin_state = match saved.pin_state {
crate::plugin_plan::PlanPinState::Pending => {
PluginDiagnosticPinState::Pending
}
crate::plugin_plan::PlanPinState::Active => {
PluginDiagnosticPinState::Active
}
crate::plugin_plan::PlanPinState::Restorable => {
PluginDiagnosticPinState::Restorable
}
crate::plugin_plan::PlanPinState::Released => {
PluginDiagnosticPinState::Released
}
};
Ok(PluginWorkerDiagnosticSelection::Plan {
plan_id: saved.plan_id,
fingerprint: saved.fingerprint,
pin_state,
plugins,
})
})
})
.await
.map_err(plugin::failure)?
}
};
Ok(PluginWorkerDiagnostics {
workspace_id: workspace,
runtime_id: runtime,
worker_id: worker,
runtime_limits: runtime_limits(&limits).map_err(plugin::failure)?,
selection,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_limits_intersect_each_runtime_and_binding_budget() {
let runtime = yoi_plugin_runtime::Limits {
wall_time: std::time::Duration::from_millis(200),
http_body_bytes: 300,
http_header_bytes: 100,
..Default::default()
};
for (binding, expected) in [
((150, 200, 250, 50), (150, 200, 250, 50)),
((250, 400, 500, 150), (200, 300, 300, 100)),
((150, 400, 250, 150), (150, 300, 250, 100)),
] {
let binding = crate::plugin_binding::PluginConnectionLimits {
timeout_ms: binding.0,
max_request_bytes: binding.1,
max_response_bytes: binding.2,
max_header_bytes: binding.3,
};
assert_eq!(
effective_limits(&runtime, &binding),
PluginDiagnosticConnectionLimits::Available {
timeout_ms: expected.0,
max_request_bytes: expected.1,
max_response_bytes: expected.2,
max_header_bytes: expected.3,
}
);
}
}
}
@@ -295,8 +295,11 @@ impl EmbeddedPluginHostOwner {
} }
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn with_transport(mut self, transport: https::HttpsTransport) -> Self { pub(crate) fn with_transport(
self.transport_override = Some(Arc::new(transport)); mut self,
transport: impl Into<Arc<https::HttpsTransport>>,
) -> Self {
self.transport_override = Some(transport.into());
self self
} }
} }
@@ -86,7 +86,8 @@ impl Fixture {
let upload = api let upload = api
.config_store .config_store
.plugin_upload( .plugin_upload(
&ServerConfig::server_data_root_for_data_dir(&api.config.data_root).join("plugin-artifacts"), &ServerConfig::server_data_root_for_data_dir(&api.config.data_root)
.join("plugin-artifacts"),
TEST_WORKSPACE_ID, TEST_WORKSPACE_ID,
"fixture-owner", "fixture-owner",
&package.bytes, &package.bytes,
@@ -1372,7 +1373,7 @@ async fn prepare_unavailable_owner_foreign_workspace_and_unsigned_embedded_never
f.api f.api
.config_store .config_store
.with_conn(|conn| { .with_conn(|conn| {
conn.execute_batch("DROP TABLE plugin_execution_plans")?; conn.execute_batch("ALTER TABLE plugin_execution_plans RENAME COLUMN plan_json TO unavailable_plan_json")?;
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
@@ -0,0 +1,403 @@
// Include directly in server::tests (this file owns its module):
// include!("server_plugin_remote_controller_tests.rs");
// Reuses subject_authorization_tests' signed RuntimeHTTP/Controller harness.
// There is no second listener/scheduler/bootstrap implementation here. Only the
// external model and TLS peer DNS/socket/trust are substituted, not the guest,
// Remote Plugin providers, authorization, artifact delivery or effect receipts.
// Run: cargo test -p yoi-workspace-server --lib plugin_remote_controller_tests
// Prerequisites: openssl and the existing connected Runtime test environment.
mod plugin_remote_controller_tests {
use super::plugin_host_tests::plugin_runtime_tests::{Model, assert_https_history};
use super::plugin_tests::request;
use super::subject_authorization_tests::{CONNECTED_RUNTIME, ConnectedWorkerHarness};
use super::*;
use crate::plugin_https::test_support::{HOST, Peer, response};
use crate::plugin_plan::PlanPinState;
use agen::llm_client::{ClientError, LlmClient, Request as ModelRequest};
use session_store::Store;
use sha2::Digest;
use std::sync::Mutex;
use std::time::Duration;
const PROFILE: &str = "project:remote-plugin-controller";
const TOOL: &str = "example_status";
const WATCHDOG: Duration = Duration::from_secs(60);
// Installation allocates the public namespace after the harness is started.
// Defer only that name, reusing the existing scripted model implementation.
#[derive(Clone)]
struct InstalledToolModel {
tool: Arc<Mutex<String>>,
requests: Arc<Mutex<Vec<ModelRequest>>>,
}
#[async_trait::async_trait]
impl LlmClient for InstalledToolModel {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: ModelRequest,
) -> std::result::Result<agen::llm_client::client::ResponseStream, ClientError> {
let model = Model {
tool_name: self.tool.lock().unwrap().clone(),
requests: self.requests.clone(),
};
model.stream(request).await
}
}
// Administrative setup only; all mutations pass the existing authenticated
// upload/install/config handlers, with no manually saved plan or Worker row.
async fn install_and_configure(
f: &ConnectedWorkerHarness,
peer: &Peer,
) -> (server_api::PluginOperationResponse, Vec<u8>) {
use manifest::plugin::*;
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("plugin.toml"),
include_str!("../../../resources/plugin/templates/rust-component-https/plugin.toml"),
)
.unwrap();
fs::write(
directory.path().join("plugin.component.wasm"),
include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
)
.unwrap();
let limits = PluginPackageLimits::default();
let package = pack_plugin_package(
&read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits).unwrap(),
&limits,
)
.unwrap();
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
let upload = request(
build_router(f.api.clone()),
Some(&f.owner),
"POST",
&format!("{prefix}/plugin-uploads"),
package.bytes.clone(),
true,
StatusCode::OK,
)
.await;
let (status, installed) = f
.human(
reqwest::Method::POST,
&format!("{prefix}/plugins"),
json!({"upload_id":upload["upload_id"], "alias":"remote_controller",
"operation_key":"remote-controller-install"}),
)
.await;
assert_eq!(status, StatusCode::OK, "{installed}");
let installed: server_api::PluginOperationResponse =
serde_json::from_value(installed).unwrap();
let artifact = &installed.installation.artifacts[0];
assert_eq!(artifact.tools[0].name, TOOL);
let mut binding = peer.binding();
binding.methods = vec![server_api::PluginHttpMethod::Get];
binding.path_prefixes = vec!["/status".into()];
let plugins = json!({"enabled":{"remote_controller":{
"installation_id":installed.installation.installation_id,
"expected_last_operation":installed.operation_id,
"package_digest":artifact.package_digest, "artifact_digest":artifact.artifact_digest,
"tools":[TOOL], "logging":false, "config":{}, "connections":{"api":"remote"}
}}, "bindings":{"remote":binding}});
let selection = json!({"selections":[{
"installation_id":installed.installation.installation_id,
"tools":[TOOL], "logging":false, "https_slots":["api"]
}]});
let content = format!(
r#"{{
plugins = {};
profile = {{ default_profile = "{PROFILE}"; entries = [{{
selector = "{PROFILE}"; label = "Remote controller"; description = "";
profile = {{ slug = "remote-plugin-controller"; scope = "workspace_read";
delegation_scope = "workspace_read";
model = {{ scheme = "anthropic"; model_id = "scripted"; auth = {{ kind = "none"; }}; }};
plugins = {};
}};
}}]; }};
}}"#,
crate::plugin_settings::render_value(&plugins).unwrap(),
crate::plugin_settings::render_value(&selection).unwrap()
);
let state = f
.api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let path = config_source::VirtualPath::parse("main.dcdl").unwrap();
let commit = ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: state.contract.entrypoints.clone(),
changes: vec![config_source::ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content,
}],
};
let (status, result) = f
.human(
reqwest::Method::POST,
&format!("{prefix}/config/source-tree/commit"),
serde_json::to_value(commit).unwrap(),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{result}");
(installed, package.bytes)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn remote_runtime_http_create_runs_wasmtime_https_and_persists_ordinary_tool_history() {
let mut peer = Peer::new(HOST, vec![response(503, &[], b"peer data")]).await;
let requests = Arc::new(Mutex::new(Vec::new()));
let tool = Arc::new(Mutex::new(String::new()));
let f = ConnectedWorkerHarness::start_with_plugins(
InstalledToolModel {
tool: tool.clone(),
requests: requests.clone(),
},
Arc::new(peer.transport()),
)
.await;
assert!(
!f.api
.store
.is_hosting_admin("user-connected-authorization-owner")
.unwrap(),
"Plugin setup must use a non-operator Workspace owner"
);
let (installed, package) = install_and_configure(&f, &peer).await;
let public_name = format!("{}__{TOOL}", installed.installation.installation_id);
*tool.lock().unwrap() = public_name.clone();
let (status, created) = tokio::time::timeout(
WATCHDOG,
f.human(
reqwest::Method::POST,
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
json!({"runtime_id":CONNECTED_RUNTIME, "display_name":"Remote Plugin controller",
"profile":PROFILE, "initial_submit":[], "workdir_attachments":[],
"feature_connections":{}}),
),
)
.await
.expect("signed RuntimeHTTP create timed out");
assert_eq!(status, StatusCode::OK, "{created}");
let worker = RuntimeWorkerRef::new(
created["runtime_id"].as_str().unwrap(),
created["worker_id"].as_str().unwrap(),
);
assert_eq!(worker.runtime_id, CONNECTED_RUNTIME);
let saved = f
.api
.config_store
.with_conn(|conn| {
crate::plugin_plan::worker_launch_plan(
conn,
TEST_WORKSPACE_ID,
&worker.runtime_id,
&worker.worker_id,
)
})
.unwrap()
.unwrap();
assert_eq!(saved.pin_state, PlanPinState::Active);
assert_eq!(saved.plan.plugins().len(), 1);
assert_eq!(
saved.plan.plugins()[0].installation_id(),
installed.installation.installation_id
);
let aggregate = f.runtime_root.join("workers").join(&worker.worker_id);
let persisted: Value =
serde_json::from_slice(&fs::read(aggregate.join("worker.json")).unwrap()).unwrap();
assert_eq!(persisted["execution_state"]["state"], "available");
assert_eq!(
persisted["execution_state"]["execution"]["request"]["plugin_execution"],
serde_json::to_value(runtime_api::PluginExecutionSnapshot {
plan_id: saved.plan_id.clone(),
fingerprint: saved.fingerprint.clone(),
plan: saved.plan.clone(),
})
.unwrap()
);
let cache_files = fs::read_dir(f.runtime_root.with_extension("plugin-cache"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("artifact"))
.collect::<Vec<_>>();
assert_eq!(cache_files.len(), 1);
assert_eq!(
fs::read(&cache_files[0]).unwrap(),
package,
"Remote delivery must resolve the exact saved pin"
);
assert_eq!(
peer.connection_count(),
0,
"controller preparation must not invoke the guest Tool"
);
assert!(requests.lock().unwrap().is_empty());
let mut connection = connect_workspace_worker_protocol(&f.api, &worker, None)
.await
.unwrap();
let greeting = tokio::time::timeout(WATCHDOG, async {
loop {
if let protocol::Event::Snapshot {
greeting, state, ..
} = connection.events.recv().await.unwrap()
{
assert_eq!(state.state, protocol::WorkerState::Idle);
break greeting;
}
}
})
.await
.expect("Remote Controller did not publish its snapshot");
assert!(greeting.tools.contains(&public_name));
assert!(!greeting.tools.iter().any(|name| name == TOOL));
connection
.methods
.send(protocol::Method::Submit {
submission_request_id: "remote-controller-https-submit".into(),
input: vec![protocol::Segment::Text {
content: "Check the service status.".into(),
}],
})
.await
.unwrap();
tokio::time::timeout(WATCHDOG, async {
let mut accepted = false;
let mut finished = false;
loop {
match connection.events.recv().await.unwrap() {
protocol::Event::SubmissionAccepted {
submission_request_id,
..
} => {
assert_eq!(submission_request_id, "remote-controller-https-submit");
accepted = true;
}
protocol::Event::SubmissionRejected { message, .. } => {
panic!("Submit rejected: {message}")
}
protocol::Event::RunEnd { result } => {
assert!(accepted);
assert_eq!(result, protocol::RunResult::Finished);
finished = true;
}
protocol::Event::WorkerState { snapshot }
if finished
&& snapshot.state == protocol::WorkerState::Idle
&& snapshot.last_finished_submission_request_id.as_deref()
== Some("remote-controller-https-submit") =>
{
break;
}
_ => {}
}
}
})
.await
.expect("Remote Controller HTTPS submission did not finish");
{
let observed = requests.lock().unwrap();
assert_eq!(observed.len(), 2);
assert!(
observed[0]
.tools
.iter()
.any(|definition| definition.name == public_name)
);
assert_https_history(&observed[1].items, &public_name);
}
assert!(
String::from_utf8(
tokio::time::timeout(WATCHDOG, peer.observed())
.await
.unwrap()
)
.unwrap()
.starts_with("GET /status HTTP/1.1\r\n")
);
assert_eq!(peer.connection_count(), 1);
drop(connection);
let api = f.api.clone();
let stopped = worker.clone();
tokio::time::timeout(
WATCHDOG,
tokio::task::spawn_blocking(move || {
api.runtime.stop_worker(
&stopped,
WorkerLifecycleRequest {
reason: None,
ticket_assignment: None,
},
)
}),
)
.await
.expect("Remote Controller stop timed out")
.unwrap()
.unwrap();
// Reopen the Runtime's normal filesystem Session store, not a model-only
// observation or an injected Session entry, after Controller shutdown.
let reopened = session_store::WorkerSessionStore::new(aggregate.join("session")).unwrap();
let session = reopened.session_id().unwrap().unwrap();
let segments = reopened.list_segments(session).unwrap();
assert_eq!(segments.len(), 1);
let history = session_store::restore(&reopened, session, segments[0]).unwrap();
assert_https_history(&history.history, &public_name);
let (invocations, receipts): (i64, i64) = f
.api
.config_store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT count(*) FROM plugin_invocations WHERE workspace_id=?1",
[TEST_WORKSPACE_ID],
|row| row.get(0),
)?,
conn.query_row(
"SELECT count(*) FROM plugin_effect_receipts WHERE workspace_id=?1",
[TEST_WORKSPACE_ID],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!((invocations, receipts), (1, 1));
let (consumer, state, status, body): (String, String, i64, Vec<u8>) = f.api.config_store.with_conn(|conn| {
Ok(conn.query_row("SELECT i.consumer_json,r.state,r.response_status,r.response_body FROM plugin_invocations i \
JOIN plugin_effect_receipts r USING(workspace_id,invocation_id) \
WHERE i.workspace_id=?1 AND i.plan_id=?2 AND i.worker_id=?3 AND r.effect_key='status-v1'",
rusqlite::params![TEST_WORKSPACE_ID, saved.plan_id, worker.worker_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?)
}).unwrap();
let identity = serde_json::to_vec(&(session.to_string(), "https-call")).unwrap();
let consumer_id = format!(
"tool-{}",
sha2::Sha256::digest(identity)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
assert_eq!(
serde_json::from_str::<server_api::PluginConsumer>(&consumer).unwrap(),
server_api::PluginConsumer::ToolCall {
tool_call_id: consumer_id,
tool_name: TOOL.into()
}
);
assert_eq!(state, "completed");
assert_eq!(status, 503);
assert_eq!(body, b"peer data");
shutdown_workspace_feature_storage(&f.api).await.unwrap();
}
}
@@ -60,21 +60,36 @@ impl WorkspaceApi {
plugin_execution: None, plugin_execution: None,
}); });
} }
let selector = match &request.profile { let selector_name = match &request.profile {
worker_runtime::catalog::ProfileSelector::Builtin(s) if !s.starts_with("builtin:") => {
format!("builtin:{s}")
}
worker_runtime::catalog::ProfileSelector::Builtin(s) worker_runtime::catalog::ProfileSelector::Builtin(s)
| worker_runtime::catalog::ProfileSelector::Named(s) => s, | worker_runtime::catalog::ProfileSelector::Named(s) => s.clone(),
}; };
let manifest = match &request.resolved_config_bundle { let selector = selector_name.as_str();
Some(bundle) => crate::profile_settings::resolve_profile_manifest_from_config_bundle( let archive = match request.resolved_config_bundle.as_ref() {
bundle, selector, Some(bundle) if bundle.profile_source_archive.is_some() => bundle
)?, .profile_source_archive
None => crate::profile_settings::builtin_profile_source_archive(&request.profile) .clone()
.map_err(Error::Config)? .expect("checked archive"),
Some(bundle) if bundle.profile_source_archive_handle.is_some() => {
let handle = bundle
.profile_source_archive_handle
.as_ref()
.expect("checked handle");
self.resource_broker
.profile_source_archive(&handle.digest)
.ok_or_else(|| Error::Config("Plugin Profile archive unavailable".into()))?
}
_ => crate::profile_settings::builtin_profile_source_archive(&request.profile)
.map_err(Error::Config)?,
};
let manifest = archive
.verify() .verify()
.map_err(|_| Error::Config("Plugin Profile archive invalid".into()))? .map_err(|_| Error::Config("Plugin Profile archive invalid".into()))?
.resolve_profile(selector, Path::new("/"), "plugin-launch") .resolve_profile(selector, Path::new("/"), "plugin-launch")
.map_err(|_| Error::Config("Plugin Profile unavailable".into()))?, .map_err(|_| Error::Config("Plugin Profile unavailable".into()))?;
};
if manifest.plugins.selections.is_empty() { if manifest.plugins.selections.is_empty() {
return self.config_store.with_immediate_transaction(|tx| { return self.config_store.with_immediate_transaction(|tx| {
tx.execute("INSERT INTO worker_plugin_launches(workspace_id,runtime_id,worker_id,plan_id) SELECT workspace_id,runtime_id,worker_id,NULL FROM worker_create_reservations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND create_fingerprint=?4 AND state='reserved' ON CONFLICT(workspace_id,worker_id) DO NOTHING", tx.execute("INSERT INTO worker_plugin_launches(workspace_id,runtime_id,worker_id,plan_id) SELECT workspace_id,runtime_id,worker_id,NULL FROM worker_create_reservations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND create_fingerprint=?4 AND state='reserved' ON CONFLICT(workspace_id,worker_id) DO NOTHING",
@@ -159,11 +174,18 @@ impl PluginRuntimeProviders for EmbeddedPluginRuntimeProviders {
EMBEDDED_RUNTIME_ID, EMBEDDED_RUNTIME_ID,
) )
.with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, audience); .with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, audience);
#[cfg(test)]
let transport = api.plugin_https_transport.clone();
let owner = plugin_host::EmbeddedPluginHostOwner::authenticated( let owner = plugin_host::EmbeddedPluginHostOwner::authenticated(
api, api,
workspace.into(), workspace.into(),
client.plugin_host_authorizer(worker.worker_id), client.plugin_host_authorizer(worker.worker_id),
); );
#[cfg(test)]
let owner = match transport {
Some(transport) => owner.with_transport(transport),
None => owner,
};
Ok(PluginRuntimeServices { Ok(PluginRuntimeServices {
artifacts: Arc::new(artifacts), artifacts: Arc::new(artifacts),
owner: Arc::new(owner), owner: Arc::new(owner),
@@ -7,7 +7,7 @@
// complete create-controller/restore vertical or a Runtime lifecycle test. // complete create-controller/restore vertical or a Runtime lifecycle test.
// Run: cargo test -p yoi-workspace-server --lib plugin_runtime_tests // Run: cargo test -p yoi-workspace-server --lib plugin_runtime_tests
// Prerequisite: openssl, as required by the shared Peer fixture. // Prerequisite: openssl, as required by the shared Peer fixture.
mod plugin_runtime_tests { pub(super) mod plugin_runtime_tests {
use super::*; use super::*;
use agen::llm_client::event::{Event as ModelEvent, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event as ModelEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request as ModelRequest}; use agen::llm_client::{ClientError, LlmClient, Request as ModelRequest};
@@ -125,9 +125,9 @@ mod plugin_runtime_tests {
} }
#[derive(Clone)] #[derive(Clone)]
struct Model { pub(in crate::server::tests) struct Model {
tool_name: String, pub(in crate::server::tests) tool_name: String,
requests: Arc<Mutex<Vec<ModelRequest>>>, pub(in crate::server::tests) requests: Arc<Mutex<Vec<ModelRequest>>>,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -149,7 +149,14 @@ mod plugin_runtime_tests {
let events = match turn { let events = match turn {
0 => vec![ 0 => vec![
ModelEvent::tool_use_start(0, "https-call", &self.tool_name), ModelEvent::tool_use_start(0, "https-call", &self.tool_name),
ModelEvent::tool_input_delta(0, "{}"), ModelEvent::tool_input_delta(
0,
if self.tool_name.ends_with("__example_echo") {
r#"{"text":"probe"}"#
} else {
"{}"
},
),
ModelEvent::tool_use_stop(0), ModelEvent::tool_use_stop(0),
], ],
1 => vec![ 1 => vec![
@@ -170,7 +177,7 @@ mod plugin_runtime_tests {
} }
} }
fn assert_https_history(items: &[agen::Item], public_name: &str) { pub(in crate::server::tests) fn assert_https_history(items: &[agen::Item], public_name: &str) {
let calls = items let calls = items
.iter() .iter()
.filter_map(|item| match item { .filter_map(|item| match item {
@@ -215,7 +222,11 @@ mod plugin_runtime_tests {
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
let (call, summary, content, disposition, is_error, attachments) = results[0]; let (call, summary, content, disposition, is_error, attachments) = results[0];
assert_eq!(call, calls[0].0); assert_eq!(call, calls[0].0);
assert_eq!(*disposition, agen::ToolResultDisposition::Success); assert_eq!(
*disposition,
agen::ToolResultDisposition::Success,
"{summary}: {content:?}"
);
assert!(!*is_error); assert!(!*is_error);
assert!(attachments.is_empty()); assert!(attachments.is_empty());
assert_eq!(summary, "HTTP response observed"); assert_eq!(summary, "HTTP response observed");
@@ -25,7 +25,7 @@ fn package_bytes(version: &str) -> Vec<u8> {
.bytes .bytes
} }
async fn request( pub(super) async fn request(
app: Router, app: Router,
token: Option<&str>, token: Option<&str>,
method: &str, method: &str,
@@ -415,29 +415,101 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
#[tokio::test] #[tokio::test]
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() { async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
let dir=tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api=test_api(dir.path()).await; let api = test_api(dir.path()).await;
let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await; let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-config-owner").await;
let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader"); let reader = seed_test_api_token(api.store.as_ref(), "plugin-config-reader");
let app=build_router(api.clone()); let app = build_router(api.clone());
let prefix=format!("/api/w/{TEST_WORKSPACE_ID}"); let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await; let upload = request(
app.clone(),
Some(&owner),
"POST",
&format!("{prefix}/plugin-uploads"),
package_bytes("0.1.0"),
true,
StatusCode::OK,
)
.await;
let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await; let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await;
let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await; let state = get(
let a=&installed["installation"]["artifacts"][0]; app.clone(),
let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]); Some(&owner),
let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]}); &format!("{prefix}/config/source-tree"),
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await; StatusCode::OK,
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await; )
let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await; .await;
let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await; let a = &installed["installation"]["artifacts"][0];
assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]); let source = format!(
let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await; "{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",
assert_eq!(saved,preview); installed["installation"]["installation_id"],
assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]); installed["operation_id"],
json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await; a["package_digest"],
a["artifact_digest"]
);
let change = serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
json(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/commit"),
change.clone(),
StatusCode::FORBIDDEN,
)
.await;
json(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/preview"),
change.clone(),
StatusCode::FORBIDDEN,
)
.await;
let preview = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/preview"),
change.clone(),
StatusCode::OK,
)
.await;
let unchanged = get(
app.clone(),
Some(&owner),
&format!("{prefix}/config/source-tree"),
StatusCode::OK,
)
.await;
assert_eq!(unchanged["snapshot"]["digest"], state["snapshot"]["digest"]);
let saved = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
change.clone(),
StatusCode::CREATED,
)
.await;
assert_eq!(saved, preview);
assert_ne!(saved["snapshot"]["digest"], state["snapshot"]["digest"]);
json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
change,
StatusCode::CONFLICT,
)
.await;
json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await; json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await;
let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); let current = api
let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count(); .config_store
assert_eq!(candidates,1); .load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let candidates = current
.contract
.schema_bundle
.contributions
.iter()
.filter(|c| c.provider_id == "builtin:plugins")
.count();
assert_eq!(candidates, 1);
} }
@@ -1129,6 +1129,7 @@ mod subject_assignment_migration_tests {
_ => panic!("unsupported fixture origin"), _ => panic!("unsupported fixture origin"),
}; };
tail.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into())); tail.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
tail.push((110, "Worker Plugin launch selection associations".into()));
tail tail
} }
@@ -18,6 +18,7 @@ use crate::{Error, Result};
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"plugin_effect_receipts", "plugin_effect_receipts",
"plugin_invocations", "plugin_invocations",
"worker_plugin_launches",
"plugin_execution_plans", "plugin_execution_plans",
"plugin_references", "plugin_references",
"plugin_operations", "plugin_operations",
@@ -513,6 +514,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
for (table, trigger) in [ for (table, trigger) in [
("plugin_effect_receipts", "plugin_effect_retained"), ("plugin_effect_receipts", "plugin_effect_retained"),
("plugin_invocations", "plugin_invocation_retained"), ("plugin_invocations", "plugin_invocation_retained"),
("worker_plugin_launches", "worker_plugin_launch_retained"),
("plugin_execution_plans", "plugin_plan_replay_retained"), ("plugin_execution_plans", "plugin_plan_replay_retained"),
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"), ("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
("notification_job_targets", "notification_job_targets_no_delete"), ("notification_job_targets", "notification_job_targets_no_delete"),
+484 -1
View File
@@ -7587,6 +7587,131 @@
} }
] ]
}, },
"PluginDiagnosticAdmission": {
"description": "A successful observation is not retained admission authority.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "admissible",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "refused",
"type": "string"
},
"reason_code": {
"$ref": "#/components/schemas/PluginHostErrorCode"
}
},
"required": [
"kind",
"reason_code"
],
"type": "object"
}
]
},
"PluginDiagnosticConnection": {
"additionalProperties": false,
"properties": {
"effective_limits": {
"$ref": "#/components/schemas/PluginDiagnosticConnectionLimits"
},
"slot": {
"type": "string"
}
},
"required": [
"slot",
"effective_limits"
],
"type": "object"
},
"PluginDiagnosticConnectionLimits": {
"description": "Limits are absent when the current binding cannot be admitted; never fall\nback to a launch-time policy or a permissive default.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "available",
"type": "string"
},
"max_header_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"max_request_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"max_response_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"timeout_ms": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"kind",
"timeout_ms",
"max_request_bytes",
"max_response_bytes",
"max_header_bytes"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "unavailable",
"type": "string"
},
"reason_code": {
"$ref": "#/components/schemas/PluginHostErrorCode"
}
},
"required": [
"kind",
"reason_code"
],
"type": "object"
}
]
},
"PluginDiagnosticPinState": {
"enum": [
"pending",
"active",
"restorable",
"released"
],
"type": "string"
},
"PluginDiagnosticSurface": {
"enum": [
"tool"
],
"type": "string"
},
"PluginEffectObservation": { "PluginEffectObservation": {
"enum": [ "enum": [
"not_dispatched", "not_dispatched",
@@ -8472,6 +8597,105 @@
], ],
"type": "object" "type": "object"
}, },
"PluginRuntimeLimits": {
"additionalProperties": false,
"description": "Fixed Tool Runtime budgets, not usage counters or caller-selectable limits.",
"properties": {
"config_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"fuel": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"host_operations": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"http_body_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"http_header_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"http_header_pairs": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"instances": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"log_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"memories": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"memory_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"payload_bytes": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"resources": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"table_elements": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"tables": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"wall_time_ms": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"payload_bytes",
"config_bytes",
"memory_bytes",
"table_elements",
"instances",
"memories",
"tables",
"resources",
"fuel",
"wall_time_ms",
"host_operations",
"http_body_bytes",
"http_header_bytes",
"http_header_pairs",
"log_bytes"
],
"type": "object"
},
"PluginSchemaContribution": { "PluginSchemaContribution": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -8567,6 +8791,158 @@
], ],
"type": "object" "type": "object"
}, },
"PluginWorkerDiagnostic": {
"additionalProperties": false,
"properties": {
"admission": {
"$ref": "#/components/schemas/PluginDiagnosticAdmission"
},
"artifact_digest": {
"type": "string"
},
"artifact_size": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"connections": {
"items": {
"$ref": "#/components/schemas/PluginDiagnosticConnection"
},
"type": "array"
},
"installation_id": {
"type": "string"
},
"package_digest": {
"type": "string"
},
"package_id": {
"type": "string"
},
"package_version": {
"type": "string"
},
"selected_tools": {
"items": {
"type": "string"
},
"type": "array"
},
"surfaces": {
"items": {
"$ref": "#/components/schemas/PluginDiagnosticSurface"
},
"type": "array"
}
},
"required": [
"installation_id",
"package_id",
"package_version",
"package_digest",
"artifact_digest",
"artifact_size",
"surfaces",
"selected_tools",
"admission",
"connections"
],
"type": "object"
},
"PluginWorkerDiagnosticSelection": {
"oneOf": [
{
"additionalProperties": false,
"description": "No sealed launch selection exists; do not infer current Profile selection.",
"properties": {
"kind": {
"const": "unrecorded",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"description": "The owner sealed an explicitly empty selection (a NULL plan).",
"properties": {
"kind": {
"const": "empty",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"fingerprint": {
"type": "string"
},
"kind": {
"const": "plan",
"type": "string"
},
"pin_state": {
"$ref": "#/components/schemas/PluginDiagnosticPinState"
},
"plan_id": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/components/schemas/PluginWorkerDiagnostic"
},
"type": "array"
}
},
"required": [
"kind",
"plan_id",
"fingerprint",
"pin_state",
"plugins"
],
"type": "object"
}
]
},
"PluginWorkerDiagnostics": {
"additionalProperties": false,
"description": "Point-in-time owner observation only, never a permission or execution lease.\nDeliberately excludes configuration, credentials and artifact locations.",
"properties": {
"runtime_id": {
"type": "string"
},
"runtime_limits": {
"$ref": "#/components/schemas/PluginRuntimeLimits"
},
"selection": {
"$ref": "#/components/schemas/PluginWorkerDiagnosticSelection"
},
"worker_id": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
},
"required": [
"workspace_id",
"runtime_id",
"worker_id",
"runtime_limits",
"selection"
],
"type": "object"
},
"ProfileSettingsResponse": { "ProfileSettingsResponse": {
"additionalProperties": false, "additionalProperties": false,
"description": "Read-only Profile catalog projected from one active Workspace config tree.", "description": "Read-only Profile catalog projected from one active Workspace config tree.",
@@ -19847,7 +20223,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80" "x-yoi-source-digest": "sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -29977,6 +30353,113 @@
] ]
} }
}, },
"/api/w/{workspace_id}/plugins/worker-diagnostics/{runtime_id}/{worker_id}": {
"get": {
"operationId": "plugin_worker_diagnostics",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "runtime_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "worker_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginWorkerDiagnostics"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/plugins/{installation_id}": { "/api/w/{workspace_id}/plugins/{installation_id}": {
"get": { "get": {
"operationId": "plugin_show", "operationId": "plugin_show",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80) // Input contract: 0.1.0 (sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -38,3 +38,23 @@ export type PluginOperationResponse = { operation_id: string, kind: PluginOperat
export type PluginArtifactDeliveryRequest = { plan_id: string, plan_fingerprint: string, installation_id: string, artifact_digest: string, }; export type PluginArtifactDeliveryRequest = { plan_id: string, plan_fingerprint: string, installation_id: string, artifact_digest: string, };
export type PluginArtifactDeliveryMetadata = { workspace_id: string, request: PluginArtifactDeliveryRequest, artifact: PluginArtifact, }; export type PluginArtifactDeliveryMetadata = { workspace_id: string, request: PluginArtifactDeliveryRequest, artifact: PluginArtifact, };
export type PluginHostErrorCode = "invalid_input" | "denied" | "not_found" | "conflict" | "unsupported" | "quota_exceeded" | "deadline_exceeded" | "cancelled" | "unavailable" | "indeterminate" | "receipt_expired";
export type PluginWorkerDiagnostics = { workspace_id: string, runtime_id: string, worker_id: string, runtime_limits: PluginRuntimeLimits, selection: PluginWorkerDiagnosticSelection, };
export type PluginWorkerDiagnosticSelection = { "kind": "unrecorded" } | { "kind": "empty" } | { "kind": "plan", plan_id: string, fingerprint: string, pin_state: PluginDiagnosticPinState, plugins: Array<PluginWorkerDiagnostic>, };
export type PluginDiagnosticPinState = "pending" | "active" | "restorable" | "released";
export type PluginDiagnosticSurface = "tool";
export type PluginWorkerDiagnostic = { installation_id: string, package_id: string, package_version: string, package_digest: string, artifact_digest: string, artifact_size: number, surfaces: Array<PluginDiagnosticSurface>, selected_tools: Array<string>, admission: PluginDiagnosticAdmission, connections: Array<PluginDiagnosticConnection>, };
export type PluginDiagnosticAdmission = { "kind": "admissible" } | { "kind": "refused", reason_code: PluginHostErrorCode, };
export type PluginDiagnosticConnection = { slot: string, effective_limits: PluginDiagnosticConnectionLimits, };
export type PluginDiagnosticConnectionLimits = { "kind": "available", timeout_ms: number, max_request_bytes: number, max_response_bytes: number, max_header_bytes: number, } | { "kind": "unavailable", reason_code: PluginHostErrorCode, };
export type PluginRuntimeLimits = { payload_bytes: number, config_bytes: number, memory_bytes: number, table_elements: number, instances: number, memories: number, tables: number, resources: number, fuel: number, wall_time_ms: number, host_operations: number, http_body_bytes: number, http_header_bytes: number, http_header_pairs: number, log_bytes: number, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80) // Input contract: 0.1.0 (sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80) // Input contract: 0.1.0 (sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80) // Input contract: 0.1.0 (sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:63f6a6713e8a5d5e9aaf9e75482ec9271ffedff394ca1f32d19210929723ea80) // Input contract: 0.1.0 (sha256:3e616b1d2ec6b52f7504cf0f81f30c976cd6a18e510084ea50f4f9e340089581)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check