merge: sync orchestration before queue 00001KVDH2E06
This commit is contained in:
+204
-11
@@ -66,18 +66,111 @@ impl PluginExactVersion {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginGrantConfig {
|
||||
pub tools: Vec<String>,
|
||||
pub secrets: Vec<String>,
|
||||
pub filesystem: Vec<String>,
|
||||
pub network: bool,
|
||||
/// Source-qualified package id this grant is pinned to, for example `project:example`.
|
||||
pub id: Option<String>,
|
||||
/// Exact package version this grant is pinned to.
|
||||
pub version: Option<PluginExactVersion>,
|
||||
/// Deterministic package digest this grant is pinned to.
|
||||
pub digest: Option<String>,
|
||||
/// Explicit capabilities granted for the pinned package identity/version/digest.
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
}
|
||||
|
||||
impl PluginGrantConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tools.is_empty()
|
||||
&& self.secrets.is_empty()
|
||||
&& self.filesystem.is_empty()
|
||||
&& !self.network
|
||||
self.permissions.is_empty()
|
||||
}
|
||||
|
||||
pub fn binding_error(
|
||||
&self,
|
||||
identity: &SourceQualifiedPluginId,
|
||||
digest: &str,
|
||||
version: &str,
|
||||
) -> Option<&'static str> {
|
||||
if self.permissions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(grant_id) = &self.id else {
|
||||
return Some("plugin grant is missing a source-qualified package id binding");
|
||||
};
|
||||
match SourceQualifiedPluginId::parse(grant_id) {
|
||||
Ok(grant_identity) if &grant_identity == identity => {}
|
||||
Ok(_) => return Some("plugin grant package id binding does not match enabled package"),
|
||||
Err(_) => {
|
||||
return Some(
|
||||
"plugin grant package id binding is not a valid source-qualified plugin id",
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(grant_digest) = &self.digest else {
|
||||
return Some("plugin grant is missing a deterministic digest binding");
|
||||
};
|
||||
if !digest_matches(grant_digest, digest) {
|
||||
return Some("plugin grant digest binding does not match enabled package digest");
|
||||
}
|
||||
let Some(grant_version) = &self.version else {
|
||||
return Some("plugin grant is missing an exact package version binding");
|
||||
};
|
||||
if !grant_version.matches(version) {
|
||||
return Some("plugin grant version binding does not match enabled package version");
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginPermission {
|
||||
Surface { surface: PluginSurface },
|
||||
Tool { name: String },
|
||||
ToolNamespace { namespace: String },
|
||||
ExternalWrite,
|
||||
HostApi { api: PluginHostApi },
|
||||
}
|
||||
|
||||
impl PluginPermission {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Surface { surface } => format!("surfaces.{surface}"),
|
||||
Self::Tool { name } => format!("tool.{name}"),
|
||||
Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"),
|
||||
Self::ExternalWrite => "external_write".to_string(),
|
||||
Self::HostApi { api } => format!("host_api.{api}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface(surface: PluginSurface) -> Self {
|
||||
Self::Surface { surface }
|
||||
}
|
||||
|
||||
pub fn tool(name: impl Into<String>) -> Self {
|
||||
Self::Tool { name: name.into() }
|
||||
}
|
||||
|
||||
pub fn tool_namespace(namespace: impl Into<String>) -> Self {
|
||||
Self::ToolNamespace {
|
||||
namespace: namespace.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_api(api: PluginHostApi) -> Self {
|
||||
Self::HostApi { api }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginHostApi {
|
||||
Https,
|
||||
Fs,
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginHostApi {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Https => f.write_str("https"),
|
||||
Self::Fs => f.write_str("fs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +283,10 @@ pub struct PluginPackageManifest {
|
||||
pub hooks: Vec<PluginHookManifest>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<PluginToolManifest>,
|
||||
/// Permission requests declared by the package. These are requests only;
|
||||
/// enablement grants must match them before runtime surfaces are exposed.
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
}
|
||||
|
||||
impl PluginPackageManifest {
|
||||
@@ -229,6 +326,11 @@ pub struct PluginToolManifest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
/// Whether this Tool declares side effects outside the model-visible result.
|
||||
/// The flag does not grant authority; it requires a matching external_write
|
||||
/// request and grant before registration or execution.
|
||||
#[serde(default)]
|
||||
pub external_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -561,12 +663,16 @@ pub fn resolve_enabled_plugins(
|
||||
}
|
||||
}
|
||||
|
||||
if !enablement.grants.is_empty() {
|
||||
if let Some(message) =
|
||||
enablement
|
||||
.grants
|
||||
.binding_error(&identity, &package.digest, &package.manifest.version)
|
||||
{
|
||||
resolution.diagnostics.push(
|
||||
PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Grant,
|
||||
PluginDiagnosticPhase::Resolution,
|
||||
"plugin authority grants are not implemented and fail closed",
|
||||
message,
|
||||
)
|
||||
.with_source(identity.source)
|
||||
.with_identity(&identity)
|
||||
@@ -1937,6 +2043,93 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_permission_grant_binding_resolves_only_exact_package_identity() {
|
||||
let (report, _) = fixture_with_enabled_plugin(false);
|
||||
let digest = report.packages[0].digest.clone();
|
||||
let exact_grants = PluginGrantConfig {
|
||||
id: Some("project:example".to_string()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
};
|
||||
let resolution = resolve_enabled_plugins(
|
||||
&PluginConfig {
|
||||
enabled: vec![PluginEnablementConfig {
|
||||
id: "project:example".to_string(),
|
||||
grants: exact_grants,
|
||||
..PluginEnablementConfig::default()
|
||||
}],
|
||||
..PluginConfig::default()
|
||||
},
|
||||
&report,
|
||||
);
|
||||
assert!(
|
||||
resolution.diagnostics.is_empty(),
|
||||
"{:#?}",
|
||||
resolution.diagnostics
|
||||
);
|
||||
assert_eq!(resolution.resolved.len(), 1);
|
||||
|
||||
for grants in [
|
||||
PluginGrantConfig {
|
||||
id: Some("project:other".to_string()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
},
|
||||
PluginGrantConfig {
|
||||
id: Some("project:example".to_string()),
|
||||
version: Some(PluginExactVersion("0.1.1".to_string())),
|
||||
digest: Some(digest.clone()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
},
|
||||
PluginGrantConfig {
|
||||
id: Some("project:example".to_string()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some("sha256:unrelated".to_string()),
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
|
||||
},
|
||||
] {
|
||||
let resolution = resolve_enabled_plugins(
|
||||
&PluginConfig {
|
||||
enabled: vec![PluginEnablementConfig {
|
||||
id: "project:example".to_string(),
|
||||
grants,
|
||||
..PluginEnablementConfig::default()
|
||||
}],
|
||||
..PluginConfig::default()
|
||||
},
|
||||
&report,
|
||||
);
|
||||
assert!(resolution.resolved.is_empty());
|
||||
assert!(
|
||||
resolution
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diag| diag.kind == PluginDiagnosticKind::Grant),
|
||||
"{:#?}",
|
||||
resolution.diagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_permission_kind_fails_closed_at_manifest_parse_boundary() {
|
||||
let error = toml::from_str::<PluginPackageManifest>(
|
||||
r#"schema_version = 1
|
||||
id = "example"
|
||||
name = "Example"
|
||||
version = "0.1.0"
|
||||
|
||||
[[permissions]]
|
||||
kind = "ambient_shell"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("ambient_shell"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_and_grant_failures_do_not_resolve() {
|
||||
let (report, _) = fixture_with_enabled_plugin(false);
|
||||
@@ -1951,7 +2144,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||
PluginEnablementConfig {
|
||||
id: "project:example".to_string(),
|
||||
grants: PluginGrantConfig {
|
||||
filesystem: vec![".".to_string()],
|
||||
permissions: vec![PluginPermission::surface(PluginSurface::Tool)],
|
||||
..PluginGrantConfig::default()
|
||||
},
|
||||
..PluginEnablementConfig::default()
|
||||
|
||||
@@ -15,8 +15,8 @@ use llm_worker::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
||||
};
|
||||
use manifest::plugin::{
|
||||
PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord,
|
||||
read_resolved_plugin_runtime_module,
|
||||
PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface,
|
||||
PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -106,6 +106,8 @@ impl FeatureModule for PluginToolFeature {
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
validate_declared_tool_names(&self.record)?;
|
||||
let origin = self.origin();
|
||||
let mut registered = 0usize;
|
||||
let mut denied = Vec::new();
|
||||
for tool in &self.record.manifest.tools {
|
||||
validate_tool_name(&tool.name).map_err(|reason| {
|
||||
FeatureInstallError::Install(format!(
|
||||
@@ -119,6 +121,17 @@ impl FeatureModule for PluginToolFeature {
|
||||
self.record.identity, tool.name
|
||||
))
|
||||
})?;
|
||||
if let Err(error) = authorize_plugin_tool(&self.record, tool) {
|
||||
let message = format!(
|
||||
"plugin `{}` tool `{}` registration denied: {}",
|
||||
self.record.identity,
|
||||
tool.name,
|
||||
error.bounded_message()
|
||||
);
|
||||
context.diagnostics().warning(message.clone());
|
||||
denied.push(message);
|
||||
continue;
|
||||
}
|
||||
context.tools().register(ToolContribution::new(
|
||||
tool.name.clone(),
|
||||
plugin_wasm_tool_definition(
|
||||
@@ -129,11 +142,128 @@ impl FeatureModule for PluginToolFeature {
|
||||
origin.clone(),
|
||||
),
|
||||
))?;
|
||||
registered += 1;
|
||||
}
|
||||
if registered == 0 && !denied.is_empty() {
|
||||
let summary = if denied.len() == 1 {
|
||||
denied.remove(0)
|
||||
} else {
|
||||
format!(
|
||||
"{} plugin tool registrations denied; first denial: {}",
|
||||
denied.len(),
|
||||
denied[0]
|
||||
)
|
||||
};
|
||||
return Err(FeatureInstallError::Install(bounded_message(summary)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PluginPermissionError(String);
|
||||
|
||||
impl PluginPermissionError {
|
||||
fn bounded_message(&self) -> String {
|
||||
bounded_message(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize_plugin_tool(
|
||||
record: &ResolvedPluginRecord,
|
||||
tool: &PluginToolManifest,
|
||||
) -> Result<(), PluginPermissionError> {
|
||||
validate_grant_binding(record)?;
|
||||
require_permission(
|
||||
&record.manifest.permissions,
|
||||
&PluginPermission::surface(PluginSurface::Tool),
|
||||
"requested surfaces.tool permission is missing",
|
||||
)?;
|
||||
require_permission(
|
||||
&record.grants.permissions,
|
||||
&PluginPermission::surface(PluginSurface::Tool),
|
||||
"granted surfaces.tool permission is missing",
|
||||
)?;
|
||||
if !permission_allows_tool(&record.manifest.permissions, &tool.name) {
|
||||
return Err(PluginPermissionError(format!(
|
||||
"requested tool permission for `{}` is missing",
|
||||
tool.name
|
||||
)));
|
||||
}
|
||||
if !permission_allows_tool(&record.grants.permissions, &tool.name) {
|
||||
return Err(PluginPermissionError(format!(
|
||||
"granted tool permission for `{}` is missing",
|
||||
tool.name
|
||||
)));
|
||||
}
|
||||
if tool.external_write {
|
||||
require_permission(
|
||||
&record.manifest.permissions,
|
||||
&PluginPermission::ExternalWrite,
|
||||
"requested external_write permission is missing",
|
||||
)?;
|
||||
require_permission(
|
||||
&record.grants.permissions,
|
||||
&PluginPermission::ExternalWrite,
|
||||
"granted external_write permission is missing",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authorize_plugin_host_api(
|
||||
record: &ResolvedPluginRecord,
|
||||
api: PluginHostApi,
|
||||
) -> Result<(), PluginPermissionError> {
|
||||
validate_grant_binding(record)?;
|
||||
let permission = PluginPermission::host_api(api);
|
||||
require_permission(
|
||||
&record.manifest.permissions,
|
||||
&permission,
|
||||
&format!("requested host_api.{api} permission is missing"),
|
||||
)?;
|
||||
require_permission(
|
||||
&record.grants.permissions,
|
||||
&permission,
|
||||
&format!("granted host_api.{api} permission is missing"),
|
||||
)?;
|
||||
Err(PluginPermissionError(format!(
|
||||
"host_api.{api} is not implemented"
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> {
|
||||
if let Some(message) =
|
||||
record
|
||||
.grants
|
||||
.binding_error(&record.identity, &record.digest, &record.manifest.version)
|
||||
{
|
||||
return Err(PluginPermissionError(message.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_permission(
|
||||
permissions: &[PluginPermission],
|
||||
expected: &PluginPermission,
|
||||
missing_message: &str,
|
||||
) -> Result<(), PluginPermissionError> {
|
||||
if permissions.iter().any(|permission| permission == expected) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(PluginPermissionError(missing_message.to_string()))
|
||||
}
|
||||
|
||||
fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool {
|
||||
permissions.iter().any(|permission| match permission {
|
||||
PluginPermission::Tool { name } => name == tool_name,
|
||||
PluginPermission::ToolNamespace { namespace } => {
|
||||
!namespace.is_empty() && tool_name.starts_with(namespace)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool";
|
||||
const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call";
|
||||
const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024;
|
||||
@@ -259,6 +389,20 @@ fn run_plugin_wasm_tool(
|
||||
tool_name: String,
|
||||
input: Vec<u8>,
|
||||
) -> Result<ToolOutput, PluginWasmError> {
|
||||
let tool = record
|
||||
.manifest
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == tool_name)
|
||||
.ok_or_else(|| {
|
||||
PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string())
|
||||
})?;
|
||||
authorize_plugin_tool(&record, tool).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin permission denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
let limits = PluginDiscoveryLimits::default();
|
||||
let module_bytes = read_resolved_plugin_runtime_module(&record, &limits)
|
||||
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
|
||||
@@ -276,7 +420,7 @@ fn run_plugin_wasm_tool(
|
||||
let engine = wasmi::Engine::new(&config);
|
||||
let module = wasmi::Module::new(&engine, &module_bytes[..])
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
validate_wasm_imports(&module)?;
|
||||
validate_wasm_imports(&record, &module)?;
|
||||
|
||||
let store_limits = wasmi::StoreLimitsBuilder::new()
|
||||
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
|
||||
@@ -319,8 +463,27 @@ fn run_plugin_wasm_tool(
|
||||
decode_plugin_wasm_output(&store.data().output)
|
||||
}
|
||||
|
||||
fn validate_wasm_imports(module: &wasmi::Module) -> Result<(), PluginWasmError> {
|
||||
fn validate_wasm_imports(
|
||||
record: &ResolvedPluginRecord,
|
||||
module: &wasmi::Module,
|
||||
) -> Result<(), PluginWasmError> {
|
||||
for import in module.imports() {
|
||||
if import.module() == "yoi:https" {
|
||||
authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin host API dispatch denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if import.module() == "yoi:fs" {
|
||||
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin host API dispatch denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if import.module() != PLUGIN_WASM_HOST_MODULE {
|
||||
return Err(PluginWasmError::Module(format!(
|
||||
"unsupported import module `{}`; only `{}` is available",
|
||||
@@ -752,8 +915,9 @@ fn is_supported_schema_keyword(key: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::plugin::{
|
||||
PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest,
|
||||
PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup,
|
||||
PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig,
|
||||
PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId,
|
||||
resolve_plugin_config_for_startup,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
@@ -765,6 +929,7 @@ mod tests {
|
||||
name: name.into(),
|
||||
description: format!("{name} tool"),
|
||||
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
|
||||
external_write: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +942,7 @@ mod tests {
|
||||
tools: Vec<manifest::plugin::PluginToolManifest>,
|
||||
) -> ResolvedPluginRecord {
|
||||
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
|
||||
let permissions = tool_permissions(&tools);
|
||||
ResolvedPluginRecord {
|
||||
identity: parsed_identity.clone(),
|
||||
source: parsed_identity.source,
|
||||
@@ -794,13 +960,29 @@ mod tests {
|
||||
runtime: None,
|
||||
hooks: Vec::new(),
|
||||
tools,
|
||||
permissions: permissions.clone(),
|
||||
},
|
||||
enabled_surfaces: vec![PluginSurface::Tool],
|
||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||
grants: PluginGrantConfig {
|
||||
id: Some(parsed_identity.to_string()),
|
||||
version: Some(PluginExactVersion("0.1.0".to_string())),
|
||||
digest: Some("sha256:abc".to_string()),
|
||||
permissions,
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec<PluginPermission> {
|
||||
let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
|
||||
permissions.extend(
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| PluginPermission::tool(tool.name.clone())),
|
||||
);
|
||||
permissions
|
||||
}
|
||||
|
||||
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
|
||||
report
|
||||
.reports
|
||||
@@ -818,6 +1000,20 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn install_plugin_record(
|
||||
record: ResolvedPluginRecord,
|
||||
) -> (
|
||||
super::super::FeatureRegistryInstallReport,
|
||||
Vec<ToolDefinition>,
|
||||
) {
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||
let report = super::super::FeatureRegistryBuilder::default()
|
||||
.with_module(PluginToolFeature::new(record))
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
(report, pending)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_root_schema() {
|
||||
let schema = json!({"type":"string"});
|
||||
@@ -942,6 +1138,146 @@ mod tests {
|
||||
assert_eq!(origin.surface, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_grant_denies_plugin_tool_registration_and_runtime_execution() {
|
||||
let mut record = record(vec![tool("PluginSearch")]);
|
||||
record.grants = PluginGrantConfig::default();
|
||||
|
||||
let (report, pending) = install_plugin_record(record.clone());
|
||||
assert!(pending.is_empty());
|
||||
assert!(has_diagnostic(&report, "registration denied"));
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"granted surfaces.tool permission is missing"
|
||||
));
|
||||
|
||||
let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec())
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(error.contains("plugin permission denied"), "{error}");
|
||||
assert!(
|
||||
error.contains("granted surfaces.tool permission is missing"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.len() < 700, "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specific_tool_grant_registers_only_intended_plugin_tool() {
|
||||
let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]);
|
||||
record.grants.permissions = vec![
|
||||
PluginPermission::surface(PluginSurface::Tool),
|
||||
PluginPermission::tool("PluginAllowed"),
|
||||
];
|
||||
|
||||
let (report, pending) = install_plugin_record(record);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let (meta, _) = pending[0]();
|
||||
assert_eq!(meta.name, "PluginAllowed");
|
||||
assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]);
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"granted tool permission for `PluginDenied` is missing"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_binding_mismatches_do_not_authorize_plugin_tool() {
|
||||
let mut unrelated = record(vec![tool("PluginSearch")]);
|
||||
unrelated.grants.id = Some("project:other".to_string());
|
||||
let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0])
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(
|
||||
error.contains("package id binding does not match"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let mut bad_digest = record(vec![tool("PluginSearch")]);
|
||||
bad_digest.grants.digest = Some("sha256:not-the-package".to_string());
|
||||
let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0])
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(error.contains("digest binding does not match"), "{error}");
|
||||
|
||||
let mut bad_version = record(vec![tool("PluginSearch")]);
|
||||
bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string()));
|
||||
let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0])
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(error.contains("version binding does not match"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_surface_tool_and_external_write_permissions_are_required() {
|
||||
let mut missing_surface = record(vec![tool("PluginSearch")]);
|
||||
missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")];
|
||||
let (report, pending) = install_plugin_record(missing_surface);
|
||||
assert!(pending.is_empty());
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"requested surfaces.tool permission is missing"
|
||||
));
|
||||
|
||||
let mut missing_tool = record(vec![tool("PluginSearch")]);
|
||||
missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
|
||||
let (report, pending) = install_plugin_record(missing_tool);
|
||||
assert!(pending.is_empty());
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"requested tool permission for `PluginSearch` is missing"
|
||||
));
|
||||
|
||||
let mut external_tool = tool("PluginWrite");
|
||||
external_tool.external_write = true;
|
||||
let mut missing_external_request = record(vec![external_tool]);
|
||||
let (report, pending) = install_plugin_record(missing_external_request.clone());
|
||||
assert!(pending.is_empty());
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"requested external_write permission is missing"
|
||||
));
|
||||
|
||||
missing_external_request
|
||||
.manifest
|
||||
.permissions
|
||||
.push(PluginPermission::ExternalWrite);
|
||||
let (report, pending) = install_plugin_record(missing_external_request);
|
||||
assert!(pending.is_empty());
|
||||
assert!(has_diagnostic(
|
||||
&report,
|
||||
"granted external_write permission is missing"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() {
|
||||
let (_dir, mut record) = resolved_record_with_wasm(https_import_module());
|
||||
let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec())
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(
|
||||
error.contains("requested host_api.https permission is missing"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
record
|
||||
.manifest
|
||||
.permissions
|
||||
.push(PluginPermission::host_api(PluginHostApi::Https));
|
||||
record
|
||||
.grants
|
||||
.permissions
|
||||
.push(PluginPermission::host_api(PluginHostApi::Https));
|
||||
let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec())
|
||||
.unwrap_err()
|
||||
.bounded_message();
|
||||
assert!(
|
||||
error.contains("host_api.https is not implemented"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_without_enabled_tool_surface_registers_no_schema() {
|
||||
let mut config = PluginConfig::default();
|
||||
@@ -1176,7 +1512,14 @@ mod tests {
|
||||
resolved.diagnostics
|
||||
);
|
||||
assert_eq!(resolved.resolved.len(), 1);
|
||||
(dir, resolved.resolved[0].clone())
|
||||
let mut record = resolved.resolved[0].clone();
|
||||
record.grants = PluginGrantConfig {
|
||||
id: Some(record.identity.to_string()),
|
||||
version: Some(PluginExactVersion(record.version.clone())),
|
||||
digest: Some(record.digest.clone()),
|
||||
permissions: tool_permissions(&record.manifest.tools),
|
||||
};
|
||||
(dir, record)
|
||||
}
|
||||
|
||||
fn write_plugin_package(path: &Path, wasm: &[u8]) {
|
||||
@@ -1192,6 +1535,14 @@ kind = "wasm"
|
||||
entry = "plugin.wasm"
|
||||
abi = "yoi-plugin-wasm-1"
|
||||
|
||||
[[permissions]]
|
||||
kind = "surface"
|
||||
surface = "tool"
|
||||
|
||||
[[permissions]]
|
||||
kind = "tool"
|
||||
name = "PluginEcho"
|
||||
|
||||
[[tools]]
|
||||
name = "PluginEcho"
|
||||
description = "Echo plugin tool"
|
||||
@@ -1296,6 +1647,17 @@ input_schema = { type = "object", additionalProperties = true }
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn https_import_module() -> Vec<u8> {
|
||||
wat::parse_str(
|
||||
r#"(module
|
||||
(import "yoi:https" "request" (func $request))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "yoi_tool_call"))
|
||||
)"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn wat_bytes(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
|
||||
@@ -5354,6 +5354,7 @@ permission = "read"
|
||||
runtime: None,
|
||||
hooks: vec![],
|
||||
tools: vec![],
|
||||
permissions: vec![],
|
||||
},
|
||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||
|
||||
+320
-20
@@ -44,15 +44,19 @@ use crate::pod_list::{
|
||||
use crate::role_session_registry::{
|
||||
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult,
|
||||
};
|
||||
#[cfg(not(feature = "e2e-test"))]
|
||||
use crate::workspace_panel::build_workspace_panel;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use crate::workspace_panel::build_workspace_panel_with_e2e_timings;
|
||||
use crate::workspace_panel::{
|
||||
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
||||
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
|
||||
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
|
||||
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle,
|
||||
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
|
||||
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
||||
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
|
||||
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
|
||||
workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
||||
};
|
||||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
@@ -925,14 +929,14 @@ impl PanelRowHitBox {
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eRowKey {
|
||||
kind: &'static str,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eRect {
|
||||
x: u16,
|
||||
y: u16,
|
||||
@@ -941,22 +945,92 @@ struct PanelE2eRect {
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eRenderedRow {
|
||||
key: PanelE2eRowKey,
|
||||
title: String,
|
||||
status: Option<String>,
|
||||
action: Option<&'static str>,
|
||||
disabled_reason: Option<String>,
|
||||
local_state: Option<String>,
|
||||
overlay_state: Option<String>,
|
||||
overlay_detail: Option<String>,
|
||||
rect: PanelE2eRect,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eRowsRendered {
|
||||
selected: Option<PanelE2eRowKey>,
|
||||
header: PanelE2eDashboardHeader,
|
||||
rows: Vec<PanelE2eRenderedRow>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eDashboardHeader {
|
||||
ticket_configured: bool,
|
||||
companion: Option<PanelE2eCompanionState>,
|
||||
orchestrator: Option<PanelE2eOrchestratorState>,
|
||||
diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eCompanionState {
|
||||
pod_name: String,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eOrchestratorState {
|
||||
pod_name: String,
|
||||
status: &'static str,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PanelE2eDashboardContentReady {
|
||||
snapshot: PanelE2eDashboardSnapshot,
|
||||
categories: PanelE2eDashboardCategories,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PanelE2eDashboardSnapshot {
|
||||
header: PanelE2eDashboardHeader,
|
||||
rows: Vec<PanelE2eRenderedRow>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PanelE2eDashboardCategories {
|
||||
ticket_rows: usize,
|
||||
ready_ticket_rows: usize,
|
||||
planning_ticket_rows: usize,
|
||||
pod_rows: usize,
|
||||
actionable_rows: usize,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PanelE2eSourceTiming {
|
||||
source: &'static str,
|
||||
elapsed_ms: u128,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PanelE2eDashboardSourceBreakdown {
|
||||
total_elapsed_ms: u128,
|
||||
sources: Vec<PanelE2eSourceTiming>,
|
||||
ticket_rows: usize,
|
||||
pod_rows: usize,
|
||||
diagnostics: usize,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
||||
match key {
|
||||
@@ -992,6 +1066,76 @@ fn panel_e2e_rect(rect: Rect) -> PanelE2eRect {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashboardCategories {
|
||||
PanelE2eDashboardCategories {
|
||||
ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(),
|
||||
ready_ticket_rows: rows
|
||||
.iter()
|
||||
.filter(|row| row.key.kind == "ticket" && row.local_state.as_deref() == Some("ready"))
|
||||
.count(),
|
||||
planning_ticket_rows: rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
row.key.kind == "ticket" && row.local_state.as_deref() == Some("planning")
|
||||
})
|
||||
.count(),
|
||||
pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(),
|
||||
actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn panel_e2e_dashboard_header(panel: &WorkspacePanelViewModel) -> PanelE2eDashboardHeader {
|
||||
PanelE2eDashboardHeader {
|
||||
ticket_configured: panel.header.ticket_configured,
|
||||
companion: panel
|
||||
.header
|
||||
.companion
|
||||
.as_ref()
|
||||
.map(|state| PanelE2eCompanionState {
|
||||
pod_name: state.pod_name.clone(),
|
||||
status: state.status.label(),
|
||||
}),
|
||||
orchestrator: panel
|
||||
.header
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.map(|state| PanelE2eOrchestratorState {
|
||||
pod_name: state.pod_name.clone(),
|
||||
status: state.status.label(),
|
||||
detail: state.detail.clone(),
|
||||
}),
|
||||
diagnostics: panel.header.diagnostics.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn panel_e2e_dashboard_content_is_ready(
|
||||
snapshot: &PanelE2eDashboardSnapshot,
|
||||
categories: &PanelE2eDashboardCategories,
|
||||
) -> bool {
|
||||
snapshot.header.ticket_configured
|
||||
&& snapshot.header.companion.is_some()
|
||||
&& snapshot.header.orchestrator.is_some()
|
||||
&& categories.ready_ticket_rows > 0
|
||||
&& categories.planning_ticket_rows > 0
|
||||
&& categories.pod_rows > 0
|
||||
&& snapshot.rows.iter().any(|row| {
|
||||
row.key.kind == "ticket"
|
||||
&& row.local_state.as_deref() == Some("ready")
|
||||
&& row.overlay_state.is_some()
|
||||
&& row.action.is_some()
|
||||
&& row.disabled_reason.is_some()
|
||||
})
|
||||
&& snapshot.rows.iter().any(|row| {
|
||||
row.key.kind == "ticket"
|
||||
&& row.local_state.as_deref() == Some("planning")
|
||||
&& row.action.is_some()
|
||||
&& row.disabled_reason.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct MultiPodApp {
|
||||
pub(crate) list: PodList,
|
||||
pub(crate) panel: WorkspacePanelViewModel,
|
||||
@@ -1010,6 +1154,8 @@ pub(crate) struct MultiPodApp {
|
||||
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
|
||||
orchestrator_work_set: OrchestratorWorkSet,
|
||||
orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>,
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emitted_dashboard_content_ready: bool,
|
||||
}
|
||||
|
||||
impl MultiPodApp {
|
||||
@@ -1046,6 +1192,8 @@ impl MultiPodApp {
|
||||
last_orchestrator_lifecycle_failure: None,
|
||||
orchestrator_work_set: OrchestratorWorkSet::default(),
|
||||
orchestrator_queue_attention: None,
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emitted_dashboard_content_ready: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,25 +1503,52 @@ impl MultiPodApp {
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn emit_rows_rendered(&self) {
|
||||
let rows = self
|
||||
fn emit_rows_rendered(&mut self) {
|
||||
let rows: Vec<_> = self
|
||||
.row_hit_boxes
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let panel_row = self.panel.row(&hit.key);
|
||||
let (title, status, action) = match panel_row {
|
||||
Some(row) => (
|
||||
row.title.clone(),
|
||||
Some(row.status.clone()),
|
||||
row.next_action.map(NextUserAction::label),
|
||||
),
|
||||
let (
|
||||
title,
|
||||
status,
|
||||
action,
|
||||
disabled_reason,
|
||||
local_state,
|
||||
overlay_state,
|
||||
overlay_detail,
|
||||
) = match panel_row {
|
||||
Some(row) => {
|
||||
let ticket = row.ticket.as_ref();
|
||||
(
|
||||
row.title.clone(),
|
||||
Some(row.status.clone()),
|
||||
row.next_action.map(NextUserAction::label),
|
||||
row.disabled_reason.clone(),
|
||||
ticket.map(|ticket| ticket.workflow_state.as_str().to_string()),
|
||||
ticket
|
||||
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
|
||||
.map(|overlay| overlay.workflow_state.as_str().to_string()),
|
||||
ticket
|
||||
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
|
||||
.map(|overlay| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
overlay.source,
|
||||
overlay.workflow_state.as_str()
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
None => match &hit.key {
|
||||
PanelRowKey::Pod(name) => (name.clone(), None, None),
|
||||
PanelRowKey::Pod(name) => {
|
||||
(name.clone(), None, None, None, None, None, None)
|
||||
}
|
||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
|
||||
(id.clone(), None, None)
|
||||
(id.clone(), None, None, None, None, None, None)
|
||||
}
|
||||
PanelRowKey::TicketIntakePod { pod_name, .. } => {
|
||||
(pod_name.clone(), None, None)
|
||||
(pod_name.clone(), None, None, None, None, None, None)
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1382,18 +1557,40 @@ impl MultiPodApp {
|
||||
title,
|
||||
status,
|
||||
action,
|
||||
disabled_reason,
|
||||
local_state,
|
||||
overlay_state,
|
||||
overlay_detail,
|
||||
rect: panel_e2e_rect(hit.rect),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let selected = self.selected_row.as_ref().map(panel_e2e_row_key);
|
||||
let header = panel_e2e_dashboard_header(&self.panel);
|
||||
crate::e2e_observer::emit(
|
||||
"panel",
|
||||
"rows_rendered",
|
||||
PanelE2eRowsRendered {
|
||||
selected: self.selected_row.as_ref().map(panel_e2e_row_key),
|
||||
rows,
|
||||
selected: selected.clone(),
|
||||
header: header.clone(),
|
||||
rows: rows.clone(),
|
||||
},
|
||||
);
|
||||
if !self.emitted_dashboard_content_ready {
|
||||
let categories = panel_e2e_dashboard_categories(&rows);
|
||||
let snapshot = PanelE2eDashboardSnapshot { header, rows };
|
||||
if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) {
|
||||
crate::e2e_observer::emit(
|
||||
"panel",
|
||||
"dashboard_content_ready",
|
||||
PanelE2eDashboardContentReady {
|
||||
snapshot,
|
||||
categories,
|
||||
},
|
||||
);
|
||||
self.emitted_dashboard_content_ready = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_selection_visible(&mut self) {
|
||||
@@ -2286,12 +2483,35 @@ async fn load_multi_pod_snapshot(
|
||||
lifecycle_mode: OrchestratorLifecycleMode,
|
||||
) -> Result<MultiPodSnapshot, MultiPodError> {
|
||||
let workspace_root = current_workspace_root();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let load_started = Instant::now();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let mut source_timings = Vec::new();
|
||||
let companion_pod_name = workspace_companion_pod_name(&workspace_root);
|
||||
let list_selected_name = selected_name
|
||||
.clone()
|
||||
.or_else(|| Some(companion_pod_name.clone()));
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "pod_metadata_status_probe.initial",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "companion.presence",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let companion = match lifecycle_mode.clone() {
|
||||
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
||||
ensure_workspace_companion(
|
||||
@@ -2306,17 +2526,48 @@ async fn load_multi_pod_snapshot(
|
||||
observe_workspace_companion(companion_pod_name, companion_presence)
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "companion.lifecycle",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
if companion.reload_pods {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "pod_metadata_status_probe.after_companion_reload",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let config = ticket_config_availability(&workspace_root);
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "ticket_config_probe",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
|
||||
let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root);
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let orchestrator_presence = match &config {
|
||||
TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None,
|
||||
TicketConfigAvailability::Usable => {
|
||||
Some(load_exact_pod_presence(&orchestrator_pod_name).await?)
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "orchestrator.presence",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
let orchestrator = match lifecycle_mode {
|
||||
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
||||
ensure_workspace_orchestrator(
|
||||
@@ -2332,14 +2583,63 @@ async fn load_multi_pod_snapshot(
|
||||
observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence)
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "orchestrator.lifecycle",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
if orchestrator.reload_pods {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "pod_metadata_status_probe.after_orchestrator_reload",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let source_started = Instant::now();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
let (mut panel, panel_source_timings) =
|
||||
build_workspace_panel_with_e2e_timings(&workspace_root, &list);
|
||||
#[cfg(not(feature = "e2e-test"))]
|
||||
let mut panel = build_workspace_panel(&workspace_root, &list);
|
||||
panel.header.companion = companion.state;
|
||||
panel.header.diagnostics.extend(companion.diagnostics);
|
||||
panel.header.orchestrator = orchestrator.state;
|
||||
panel.header.diagnostics.extend(orchestrator.diagnostics);
|
||||
#[cfg(feature = "e2e-test")]
|
||||
{
|
||||
source_timings.push(PanelE2eSourceTiming {
|
||||
source: "workspace_panel.build.total",
|
||||
elapsed_ms: source_started.elapsed().as_millis(),
|
||||
});
|
||||
source_timings.extend(panel_source_timings.into_iter().map(|timing| {
|
||||
PanelE2eSourceTiming {
|
||||
source: timing.source,
|
||||
elapsed_ms: timing.elapsed_ms,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
crate::e2e_observer::emit(
|
||||
"panel",
|
||||
"dashboard_source_breakdown",
|
||||
PanelE2eDashboardSourceBreakdown {
|
||||
total_elapsed_ms: load_started.elapsed().as_millis(),
|
||||
sources: source_timings,
|
||||
ticket_rows: panel
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.is_ticket_action())
|
||||
.count(),
|
||||
pod_rows: list.entries.len(),
|
||||
diagnostics: panel.header.diagnostics.len(),
|
||||
},
|
||||
);
|
||||
Ok(MultiPodSnapshot { list, panel })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use std::time::Instant;
|
||||
|
||||
use protocol::PodStatus;
|
||||
use ticket::config::{
|
||||
@@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "e2e-test", allow(dead_code))]
|
||||
pub(crate) fn build_workspace_panel(
|
||||
workspace_root: &Path,
|
||||
pods: &PodList,
|
||||
@@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel(
|
||||
build_workspace_panel_with_registry(workspace_root, pods, ®istry)
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspacePanelE2eSourceTiming {
|
||||
pub(crate) source: &'static str,
|
||||
pub(crate) elapsed_ms: u128,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
pub(crate) fn build_workspace_panel_with_e2e_timings(
|
||||
workspace_root: &Path,
|
||||
pods: &PodList,
|
||||
) -> (WorkspacePanelViewModel, Vec<WorkspacePanelE2eSourceTiming>) {
|
||||
let mut timings = Vec::new();
|
||||
let started = Instant::now();
|
||||
let registry = match PanelRegistryStore::default_for_workspace(workspace_root)
|
||||
.and_then(|store| store.snapshot())
|
||||
{
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "local_claim_scan",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
let mut model = WorkspacePanelViewModel::empty(workspace_root);
|
||||
model
|
||||
.header
|
||||
.diagnostics
|
||||
.push(bounded_panel_diagnostic(format!(
|
||||
"Panel local role registry unavailable: {error}"
|
||||
)));
|
||||
return (
|
||||
build_workspace_panel_with_registry_model(
|
||||
model,
|
||||
workspace_root,
|
||||
pods,
|
||||
&PanelRegistrySnapshot::empty(),
|
||||
),
|
||||
timings,
|
||||
);
|
||||
}
|
||||
};
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "local_claim_scan",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
|
||||
let mut model = WorkspacePanelViewModel::empty(workspace_root);
|
||||
let started = Instant::now();
|
||||
let availability = ticket_config_availability(workspace_root);
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "ticket_config_probe",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
match availability {
|
||||
TicketConfigAvailability::Absent => {}
|
||||
TicketConfigAvailability::Usable => {
|
||||
model.header.ticket_configured = true;
|
||||
model.composer = WorkspacePanelComposer::ticket_enabled();
|
||||
let started = Instant::now();
|
||||
match TicketConfig::load_workspace(workspace_root) {
|
||||
Ok(config) => {
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "ticket_config_parse",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
model.header.ticket_root = config.backend_root().to_path_buf();
|
||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let started = Instant::now();
|
||||
let orchestration_overlay =
|
||||
load_orchestration_ticket_overlay(workspace_root, &config);
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "orchestration_overlay_validation_read_git",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
match build_ticket_rows(
|
||||
&backend,
|
||||
pods,
|
||||
®istry,
|
||||
&orchestration_overlay.states,
|
||||
) {
|
||||
Ok(ticket_rows) => {
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "ticket_scan_parse",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
model.rows.extend(ticket_rows.rows);
|
||||
model.header.diagnostics.extend(ticket_rows.diagnostics);
|
||||
model
|
||||
.header
|
||||
.diagnostics
|
||||
.extend(orchestration_overlay.diagnostics);
|
||||
}
|
||||
Err(error) => {
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "ticket_scan_parse",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
model
|
||||
.header
|
||||
.diagnostics
|
||||
.push(bounded_panel_diagnostic(format!(
|
||||
"Ticket rows unavailable: {error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "ticket_config_parse",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
model
|
||||
.header
|
||||
.diagnostics
|
||||
.push(bounded_panel_diagnostic(format!(
|
||||
"Ticket config is unusable: {error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
TicketConfigAvailability::Unusable(message) => {
|
||||
model.header.ticket_configured = true;
|
||||
model
|
||||
.header
|
||||
.diagnostics
|
||||
.push(bounded_panel_diagnostic(format!(
|
||||
"Ticket config is unusable: {message}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
model.rows.extend(pod_rows(pods));
|
||||
timings.push(WorkspacePanelE2eSourceTiming {
|
||||
source: "pod_row_materialization",
|
||||
elapsed_ms: started.elapsed().as_millis(),
|
||||
});
|
||||
(model, timings)
|
||||
}
|
||||
|
||||
fn build_workspace_panel_with_registry(
|
||||
workspace_root: &Path,
|
||||
pods: &PodList,
|
||||
|
||||
Reference in New Issue
Block a user