merge: integrate orchestration branch
# Conflicts: # .yoi/tickets/00001KV10SN02/item.md # .yoi/tickets/00001KV10SN02/thread.md
This commit is contained in:
+10
-259
@@ -1,8 +1,8 @@
|
||||
//! Feature contribution registry for Pod-hosted builtin/plugin modules.
|
||||
//!
|
||||
//! This module defines the Pod-side feature boundary used to collect
|
||||
//! descriptor metadata, host authority requests, tool contributions, safe hook
|
||||
//! contributions, background task declarations, and service declarations before
|
||||
//! descriptor metadata, tool contributions, safe hook contributions, background
|
||||
//! task declarations, and service declarations before
|
||||
//! installing them into the existing Worker/HookRegistry host surfaces.
|
||||
//!
|
||||
//! The first implementation slice is intentionally host-mediated and
|
||||
@@ -69,26 +69,6 @@ pub enum FeatureRuntimeKind {
|
||||
ExternalPlugin,
|
||||
}
|
||||
|
||||
/// Host authority requested by a feature for host-mediated operations that can
|
||||
/// cross sandbox or model-context boundaries.
|
||||
///
|
||||
/// Contribution declarations such as tools, hooks, background tasks, and
|
||||
/// services are descriptor/package-approved host-visible contributions, not
|
||||
/// host authorities. Host authority grants are additive and do not replace
|
||||
/// manifest/tool permission checks.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostAuthority {
|
||||
Filesystem,
|
||||
Network,
|
||||
SecretRef { id: String },
|
||||
ModelNotification,
|
||||
PodManagement,
|
||||
StateStore { name: String },
|
||||
TicketBackend { root: String },
|
||||
ServiceAccess { service: ServiceId },
|
||||
}
|
||||
|
||||
/// A safe hook contribution point exposed to feature modules.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -99,82 +79,6 @@ pub enum FeatureHookPoint {
|
||||
TurnEnd,
|
||||
}
|
||||
|
||||
/// Host authority request declared by a feature descriptor.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostAuthorityRequest {
|
||||
pub authority: HostAuthority,
|
||||
pub required: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl HostAuthorityRequest {
|
||||
pub fn required(authority: HostAuthority, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
authority,
|
||||
required: true,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn optional(authority: HostAuthority, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
authority,
|
||||
required: false,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host authority grants resolved by the host for one feature installation.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostAuthorityGrantSet {
|
||||
granted: HashSet<HostAuthority>,
|
||||
denied: Vec<HostAuthorityDenial>,
|
||||
}
|
||||
|
||||
impl HostAuthorityGrantSet {
|
||||
pub fn grant_all(requests: &[HostAuthorityRequest]) -> Self {
|
||||
Self {
|
||||
granted: requests
|
||||
.iter()
|
||||
.map(|request| request.authority.clone())
|
||||
.collect(),
|
||||
denied: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn contains(&self, authority: &HostAuthority) -> bool {
|
||||
self.granted.contains(authority)
|
||||
}
|
||||
|
||||
pub fn denied(&self) -> &[HostAuthorityDenial] {
|
||||
&self.denied
|
||||
}
|
||||
|
||||
pub fn grant(&mut self, authority: HostAuthority) {
|
||||
self.granted.insert(authority);
|
||||
}
|
||||
|
||||
pub fn deny(&mut self, authority: HostAuthority, reason: impl Into<String>) {
|
||||
self.granted.remove(&authority);
|
||||
self.denied.push(HostAuthorityDenial {
|
||||
authority,
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-side denial of a requested feature host authority.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostAuthorityDenial {
|
||||
pub authority: HostAuthority,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Serializable declaration of a tool contribution. The executable factory is
|
||||
/// carried by [`ToolContribution`] during installation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -192,12 +96,10 @@ impl ToolDeclaration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Executable tool contribution wrapper. Host-authority requirements are optional
|
||||
/// per-tool gates for privileged host APIs, not permission to contribute a tool.
|
||||
/// Executable tool contribution wrapper.
|
||||
pub struct ToolContribution {
|
||||
name: String,
|
||||
definition: ToolDefinition,
|
||||
required_host_authorities: Vec<HostAuthority>,
|
||||
}
|
||||
|
||||
impl ToolContribution {
|
||||
@@ -205,18 +107,9 @@ impl ToolContribution {
|
||||
Self {
|
||||
name: name.into(),
|
||||
definition,
|
||||
required_host_authorities: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_required_host_authorities(
|
||||
mut self,
|
||||
required_host_authorities: Vec<HostAuthority>,
|
||||
) -> Self {
|
||||
self.required_host_authorities = required_host_authorities;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
@@ -410,7 +303,6 @@ pub struct FeatureDescriptor {
|
||||
pub display_name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub requested_host_authorities: Vec<HostAuthorityRequest>,
|
||||
pub tools: Vec<ToolDeclaration>,
|
||||
pub hooks: Vec<HookDeclaration>,
|
||||
pub background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||
@@ -426,7 +318,6 @@ impl FeatureDescriptor {
|
||||
display_name: display_name.into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
description: String::new(),
|
||||
requested_host_authorities: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
background_tasks: Vec::new(),
|
||||
@@ -440,11 +331,6 @@ impl FeatureDescriptor {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_host_authority(mut self, request: HostAuthorityRequest) -> Self {
|
||||
self.requested_host_authorities.push(request);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tool(mut self, tool: ToolDeclaration) -> Self {
|
||||
self.tools.push(tool);
|
||||
self
|
||||
@@ -543,7 +429,6 @@ pub struct FeatureInstallReport {
|
||||
pub feature_id: FeatureId,
|
||||
pub runtime: FeatureRuntimeKind,
|
||||
pub installed: bool,
|
||||
pub host_authority_grants: HostAuthorityGrantSet,
|
||||
pub installed_tools: Vec<String>,
|
||||
pub installed_hooks: Vec<HookDeclaration>,
|
||||
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||
@@ -554,12 +439,11 @@ pub struct FeatureInstallReport {
|
||||
}
|
||||
|
||||
impl FeatureInstallReport {
|
||||
fn new(descriptor: &FeatureDescriptor, host_authority_grants: HostAuthorityGrantSet) -> Self {
|
||||
fn new(descriptor: &FeatureDescriptor) -> Self {
|
||||
Self {
|
||||
feature_id: descriptor.id.clone(),
|
||||
runtime: descriptor.runtime.clone(),
|
||||
installed: false,
|
||||
host_authority_grants,
|
||||
installed_tools: Vec::new(),
|
||||
installed_hooks: Vec::new(),
|
||||
declared_background_tasks: Vec::new(),
|
||||
@@ -653,38 +537,14 @@ fn reject_undeclared_contribution(
|
||||
error
|
||||
}
|
||||
|
||||
fn require_host_authority(
|
||||
host_authority_grants: &HostAuthorityGrantSet,
|
||||
report: &mut FeatureInstallReport,
|
||||
kind: FeatureContributionKind,
|
||||
name: impl Into<String>,
|
||||
authority: &HostAuthority,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
if host_authority_grants.contains(authority) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let reason = format!("required host authority was not granted: {authority:?}");
|
||||
report.mark_skipped(kind, name, reason.clone());
|
||||
Err(FeatureInstallError::HostAuthorityDenied(reason))
|
||||
}
|
||||
|
||||
/// Model-visible durable notification sink skeleton. The first slice exposes
|
||||
/// the boundary without implementing a new event channel.
|
||||
pub struct FeatureNotificationSink<'a> {
|
||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
impl FeatureNotificationSink<'_> {
|
||||
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
||||
require_host_authority(
|
||||
self.host_authority_grants,
|
||||
self.report,
|
||||
FeatureContributionKind::Notification,
|
||||
"notify_model",
|
||||
&HostAuthority::ModelNotification,
|
||||
)?;
|
||||
let message = message.into();
|
||||
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
||||
"model notification requested during feature installation but no durable Notify host is attached: {message}"
|
||||
@@ -744,7 +604,6 @@ impl FeatureDiagnosticSink<'_> {
|
||||
pub struct ToolContributionRegistrar<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
declarations: &'a FeatureContributionDeclarations,
|
||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
@@ -776,16 +635,6 @@ impl ToolContributionRegistrar<'_> {
|
||||
));
|
||||
}
|
||||
|
||||
for authority in &contribution.required_host_authorities {
|
||||
require_host_authority(
|
||||
self.host_authority_grants,
|
||||
self.report,
|
||||
FeatureContributionKind::Tool,
|
||||
model_visible_name.clone(),
|
||||
authority,
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(first) = self.installed_tool_names.get(&model_visible_name) {
|
||||
let error = FeatureInstallError::DuplicateToolName {
|
||||
tool: model_visible_name.clone(),
|
||||
@@ -951,7 +800,6 @@ impl FeatureServiceRegistrar<'_> {
|
||||
pub struct FeatureInstallContext<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
declarations: &'a FeatureContributionDeclarations,
|
||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||
hook_builder: &'a mut HookRegistryBuilder,
|
||||
@@ -964,15 +812,10 @@ impl FeatureInstallContext<'_> {
|
||||
self.feature_id
|
||||
}
|
||||
|
||||
pub fn host_authority_grants(&self) -> &HostAuthorityGrantSet {
|
||||
self.host_authority_grants
|
||||
}
|
||||
|
||||
pub fn tools(&mut self) -> ToolContributionRegistrar<'_> {
|
||||
ToolContributionRegistrar {
|
||||
feature_id: self.feature_id,
|
||||
declarations: self.declarations,
|
||||
host_authority_grants: self.host_authority_grants,
|
||||
pending_tools: self.pending_tools,
|
||||
installed_tool_names: self.installed_tool_names,
|
||||
report: self.report,
|
||||
@@ -1007,7 +850,6 @@ impl FeatureInstallContext<'_> {
|
||||
|
||||
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
||||
FeatureNotificationSink {
|
||||
host_authority_grants: self.host_authority_grants,
|
||||
report: self.report,
|
||||
}
|
||||
}
|
||||
@@ -1107,10 +949,8 @@ impl FeatureRegistryBuilder {
|
||||
let mut seen_features = HashSet::new();
|
||||
|
||||
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
||||
let host_authority_grants =
|
||||
HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities);
|
||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||
let mut report = FeatureInstallReport::new(&descriptor, host_authority_grants.clone());
|
||||
let mut report = FeatureInstallReport::new(&descriptor);
|
||||
|
||||
if !seen_features.insert(descriptor.id.clone()) {
|
||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||
@@ -1126,13 +966,6 @@ impl FeatureRegistryBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
for authority in host_authority_grants.denied() {
|
||||
report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
||||
"host authority denied: {:?}: {}",
|
||||
authority.authority, authority.reason
|
||||
)));
|
||||
}
|
||||
|
||||
let mut required_service_failed = false;
|
||||
for requirement in descriptor.requires_services.iter().cloned() {
|
||||
if service_registry.provides(&requirement.id) {
|
||||
@@ -1192,7 +1025,6 @@ impl FeatureRegistryBuilder {
|
||||
let mut context = FeatureInstallContext {
|
||||
feature_id: &descriptor.id,
|
||||
declarations: &declarations,
|
||||
host_authority_grants: &host_authority_grants,
|
||||
pending_tools,
|
||||
installed_tool_names: &mut installed_tool_names,
|
||||
hook_builder,
|
||||
@@ -1256,8 +1088,6 @@ pub enum FeatureInstallError {
|
||||
first_feature: String,
|
||||
duplicate_feature: String,
|
||||
},
|
||||
#[error("feature host authority denied: {0}")]
|
||||
HostAuthorityDenied(String),
|
||||
#[error("feature install failed: {0}")]
|
||||
Install(String),
|
||||
}
|
||||
@@ -1335,7 +1165,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_contributions_and_empty_host_authority_grants_are_recorded() {
|
||||
fn descriptor_contributions_are_recorded() {
|
||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
|
||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||
@@ -1358,7 +1188,6 @@ mod tests {
|
||||
assert!(feature_report.installed);
|
||||
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
||||
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
||||
assert!(feature_report.host_authority_grants.denied().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1421,79 +1250,6 @@ mod tests {
|
||||
assert_eq!(report.reports[0].skipped[0].name, "Actual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_host_authority_requirements_use_host_authority_grants_not_contribution_declarations() {
|
||||
struct HostAuthorityToolFeature {
|
||||
descriptor: FeatureDescriptor,
|
||||
required_host_authorities: Vec<HostAuthority>,
|
||||
}
|
||||
|
||||
impl FeatureModule for HostAuthorityToolFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(
|
||||
ToolContribution::new("NetworkTool", dummy_tool("NetworkTool"))
|
||||
.with_required_host_authorities(self.required_host_authorities.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let missing_grant = FeatureDescriptor::builtin("missing-host-authority", "Missing")
|
||||
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
|
||||
let missing_report = FeatureRegistryBuilder::new()
|
||||
.with_module(HostAuthorityToolFeature {
|
||||
descriptor: missing_grant,
|
||||
required_host_authorities: vec![HostAuthority::Network],
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(pending_tools.is_empty());
|
||||
assert!(!missing_report.reports[0].installed);
|
||||
assert!(
|
||||
missing_report.reports[0]
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("required host authority was not granted")
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
missing_report.reports[0].skipped[0].kind,
|
||||
FeatureContributionKind::Tool
|
||||
);
|
||||
|
||||
let granted = FeatureDescriptor::builtin("granted-host-authority", "Granted")
|
||||
.with_host_authority(HostAuthorityRequest::required(
|
||||
HostAuthority::Network,
|
||||
"uses a host network API",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
|
||||
let granted_report = FeatureRegistryBuilder::new()
|
||||
.with_module(HostAuthorityToolFeature {
|
||||
descriptor: granted,
|
||||
required_host_authorities: vec![HostAuthority::Network],
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(granted_report.reports[0].installed);
|
||||
assert!(
|
||||
granted_report.reports[0]
|
||||
.host_authority_grants
|
||||
.contains(&HostAuthority::Network)
|
||||
);
|
||||
assert_eq!(pending_tools.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_tool_definition_is_materialized_once_for_report_and_worker() {
|
||||
struct StatefulToolFeature {
|
||||
@@ -1790,7 +1546,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_task_declaration_is_not_host_authority_gated() {
|
||||
fn background_task_declaration_is_descriptor_contribution() {
|
||||
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||
"declared-task",
|
||||
@@ -1811,7 +1567,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_provider_declaration_is_not_host_authority_gated() {
|
||||
fn service_provider_declaration_is_descriptor_contribution() {
|
||||
let service = ServiceId::builtin("declared-service");
|
||||
let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service(
|
||||
ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"),
|
||||
@@ -1829,7 +1585,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks_and_no_host_authorities() {
|
||||
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
||||
let descriptor = builtin::task_tools_feature().descriptor();
|
||||
let tool_names: Vec<_> = descriptor
|
||||
.tools
|
||||
@@ -1845,7 +1601,6 @@ mod tests {
|
||||
|
||||
assert_eq!(descriptor.id.as_str(), "builtin:task-tools");
|
||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||
assert!(descriptor.requested_host_authorities.is_empty());
|
||||
assert_eq!(
|
||||
hook_points,
|
||||
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
||||
@@ -1860,7 +1615,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_internal_task_feature_installs_declared_tools_without_host_authorities() {
|
||||
fn builtin_internal_task_feature_installs_declared_tools() {
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut builder = FeatureRegistryBuilder::new();
|
||||
@@ -1882,10 +1637,6 @@ mod tests {
|
||||
|
||||
assert_eq!(report.reports.len(), 1);
|
||||
assert!(report.reports[0].installed);
|
||||
assert_eq!(
|
||||
report.reports[0].host_authority_grants,
|
||||
HostAuthorityGrantSet::empty()
|
||||
);
|
||||
assert!(report.reports[0].skipped.is_empty());
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
assert_eq!(report.reports[0].installed_hooks.len(), 2);
|
||||
|
||||
@@ -35,9 +35,8 @@ const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||
///
|
||||
/// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and
|
||||
/// `TaskList` through descriptor-approved tool registration, plus built-in hooks
|
||||
/// that maintain Task-reminder state. It does not request sandbox/external-plugin
|
||||
/// host authorities; normal ToolRegistry and PreToolCall permission policy still
|
||||
/// applies at call time.
|
||||
/// that maintain Task-reminder state. Normal ToolRegistry and PreToolCall
|
||||
/// permission policy still applies at call time.
|
||||
pub fn task_tools_feature() -> TaskFeature {
|
||||
TaskFeature::new()
|
||||
}
|
||||
|
||||
@@ -18,14 +18,13 @@ use ticket::{
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration,
|
||||
FeatureModule, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||
const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TicketFeatureAccess {
|
||||
@@ -150,12 +149,6 @@ impl TicketFeature {
|
||||
names
|
||||
}
|
||||
|
||||
fn authority(&self) -> HostAuthority {
|
||||
HostAuthority::TicketBackend {
|
||||
root: self.backend_root.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||
let root = self
|
||||
.backend_root
|
||||
@@ -171,11 +164,7 @@ impl TicketFeature {
|
||||
impl FeatureModule for TicketFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION)
|
||||
.with_host_authority(HostAuthorityRequest::required(
|
||||
self.authority(),
|
||||
AUTHORITY_REASON,
|
||||
));
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
let enabled_tool_names = self.enabled_tool_names();
|
||||
for name in &enabled_tool_names {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
@@ -207,7 +196,6 @@ impl FeatureModule for TicketFeature {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let authority = self.authority();
|
||||
let backend = LocalTicketBackend::new(usable_root)
|
||||
.with_record_language(self.record_language.as_deref());
|
||||
let allowed_tool_names = self.enabled_tool_names();
|
||||
@@ -221,10 +209,7 @@ impl FeatureModule for TicketFeature {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tools.register(
|
||||
ToolContribution::new(name, definition)
|
||||
.with_required_host_authorities(vec![authority.clone()]),
|
||||
)?;
|
||||
tools.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -284,7 +269,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_ticket_tools_and_backend_authority() {
|
||||
fn descriptor_declares_ticket_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature(temp.path());
|
||||
let descriptor = feature.descriptor();
|
||||
@@ -299,11 +284,6 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_TOOL_NAMES
|
||||
);
|
||||
assert_eq!(descriptor.requested_host_authorities.len(), 1);
|
||||
assert!(matches!(
|
||||
descriptor.requested_host_authorities[0].authority,
|
||||
HostAuthority::TicketBackend { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -321,7 +301,6 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
assert_eq!(descriptor.requested_host_authorities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,6 +35,9 @@ pub fn ticket_config_scaffold() -> String {
|
||||
out.push_str(
|
||||
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
|
||||
);
|
||||
out.push_str(
|
||||
"\n# Optional Panel Orchestrator worktree branch. When unset, Panel uses orchestration/<workspace-orchestrator-pod-name>.\n# [orchestration]\n# branch = \"orchestration/<workspace-orchestrator-pod-name>\"\n",
|
||||
);
|
||||
for role in TicketRole::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
||||
@@ -67,15 +70,110 @@ pub enum TicketConfigError {
|
||||
pub struct TicketConfig {
|
||||
pub backend: TicketBackendConfig,
|
||||
pub ticket: TicketRecordConfig,
|
||||
pub orchestration: TicketOrchestrationConfig,
|
||||
pub roles: TicketRoleProfiles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TicketOrchestrationConfig {
|
||||
pub branch: Option<GitBranchName>,
|
||||
}
|
||||
|
||||
impl TicketOrchestrationConfig {
|
||||
pub fn branch_name(&self) -> Option<&str> {
|
||||
self.branch.as_ref().map(GitBranchName::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub struct GitBranchName(String);
|
||||
|
||||
impl GitBranchName {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim();
|
||||
if trimmed != value {
|
||||
return Err("git branch name must not have leading or trailing whitespace".to_string());
|
||||
}
|
||||
validate_git_branch_name_value(trimmed)?;
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GitBranchName {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GitBranchName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_git_branch_name_value(value: &str) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("git branch name must not be empty".to_string());
|
||||
}
|
||||
if value == "@" {
|
||||
return Err("git branch name must not be `@`".to_string());
|
||||
}
|
||||
if value.starts_with('-') {
|
||||
return Err("git branch name must not start with `-`".to_string());
|
||||
}
|
||||
if value.starts_with("refs/") {
|
||||
return Err("git branch name must be a short branch name, not a full ref".to_string());
|
||||
}
|
||||
if value.starts_with('/') || value.ends_with('/') || value.contains("//") {
|
||||
return Err("git branch name must not contain empty path components".to_string());
|
||||
}
|
||||
if value.contains("..") {
|
||||
return Err("git branch name must not contain `..`".to_string());
|
||||
}
|
||||
if value.contains("@{") {
|
||||
return Err("git branch name must not contain `@{`".to_string());
|
||||
}
|
||||
if value.ends_with('.') {
|
||||
return Err("git branch name must not end with `.`".to_string());
|
||||
}
|
||||
|
||||
for component in value.split('/') {
|
||||
if component.starts_with('.') {
|
||||
return Err("git branch name components must not start with `.`".to_string());
|
||||
}
|
||||
if component.ends_with(".lock") {
|
||||
return Err("git branch name components must not end with `.lock`".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for ch in value.chars() {
|
||||
if ch.is_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') {
|
||||
return Err(format!(
|
||||
"git branch name contains unsupported character `{}`",
|
||||
ch.escape_default()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TicketConfig {
|
||||
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
Self {
|
||||
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
||||
ticket: TicketRecordConfig::default(),
|
||||
orchestration: TicketOrchestrationConfig::default(),
|
||||
roles: TicketRoleProfiles::default(),
|
||||
}
|
||||
}
|
||||
@@ -528,9 +626,26 @@ struct RawTicketConfig {
|
||||
#[serde(default)]
|
||||
ticket: RawTicketRecordConfig,
|
||||
#[serde(default)]
|
||||
orchestration: RawTicketOrchestrationConfig,
|
||||
#[serde(default)]
|
||||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawTicketOrchestrationConfig {
|
||||
#[serde(default)]
|
||||
branch: Option<GitBranchName>,
|
||||
}
|
||||
|
||||
impl RawTicketOrchestrationConfig {
|
||||
fn resolve(self) -> TicketOrchestrationConfig {
|
||||
TicketOrchestrationConfig {
|
||||
branch: self.branch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawTicketRecordConfig {
|
||||
@@ -576,6 +691,7 @@ impl RawTicketConfig {
|
||||
}
|
||||
})?,
|
||||
ticket: self.ticket.resolve(),
|
||||
orchestration: self.orchestration.resolve(),
|
||||
roles,
|
||||
})
|
||||
}
|
||||
@@ -680,6 +796,7 @@ mod tests {
|
||||
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
|
||||
);
|
||||
assert_eq!(config.ticket_record_language(), None);
|
||||
assert_eq!(config.orchestration.branch_name(), None);
|
||||
for role in TicketRole::ALL {
|
||||
let role_config = config.role(role);
|
||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||
@@ -701,6 +818,9 @@ root = "custom-tickets"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[orchestration]
|
||||
branch = "orchestration/custom-panel"
|
||||
|
||||
[roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
@@ -730,6 +850,10 @@ workflow = "multi-agent-workflow"
|
||||
);
|
||||
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
|
||||
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
||||
assert_eq!(
|
||||
config.orchestration.branch_name(),
|
||||
Some("orchestration/custom-panel")
|
||||
);
|
||||
assert_eq!(
|
||||
config.profile_for(TicketRole::Intake).as_str(),
|
||||
"project:intake"
|
||||
@@ -756,6 +880,9 @@ workflow = "multi-agent-workflow"
|
||||
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
||||
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
||||
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
|
||||
assert!(scaffold.contains(
|
||||
"# [orchestration]\n# branch = \"orchestration/<workspace-orchestrator-pod-name>\""
|
||||
));
|
||||
for role in TicketRole::ALL {
|
||||
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
||||
assert!(scaffold.contains(&format!(
|
||||
@@ -773,6 +900,7 @@ workflow = "multi-agent-workflow"
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
|
||||
assert_eq!(config.orchestration.branch_name(), None);
|
||||
for role in TicketRole::ALL {
|
||||
let role_config = config.role_launch_config(role).unwrap();
|
||||
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
||||
@@ -851,6 +979,32 @@ profile = "builtin:default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_branch_config_is_validated_as_git_branch_name() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[orchestration]
|
||||
branch = "orchestration/panel:bad"
|
||||
"#,
|
||||
);
|
||||
|
||||
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||
assert!(error.to_string().contains("git branch name"));
|
||||
assert!(error.to_string().contains("unsupported character"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_branch_rejects_full_refs_and_dash_prefixes() {
|
||||
assert!(GitBranchName::new("refs/heads/orchestration/panel").is_err());
|
||||
assert!(GitBranchName::new("-orchestration-panel").is_err());
|
||||
assert_eq!(
|
||||
GitBranchName::new("orchestration/panel").unwrap().as_str(),
|
||||
"orchestration/panel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_table_without_profile_is_not_role_launch_ready() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
+868
-23
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ use crossterm::event::{
|
||||
};
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::{Command, execute};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use protocol::{Method, PodStatus};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
@@ -75,6 +77,15 @@ pub(crate) async fn run_pod_name(
|
||||
socket_override: Option<PathBuf>,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let result = run_e2e_rewind_fixture(&mut terminal, pod_name).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
||||
@@ -248,6 +259,16 @@ pub(crate) async fn run_spawn(
|
||||
profile: Option<String>,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string());
|
||||
let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return result;
|
||||
}
|
||||
|
||||
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
@@ -388,6 +409,181 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
async fn run_e2e_rewind_fixture(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
pod_name: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(pod_name.clone(), &workspace_root);
|
||||
app.connected = true;
|
||||
app.handle_pod_event(Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
status: PodStatus::Idle,
|
||||
greeting: Greeting {
|
||||
pod_name: pod_name.clone(),
|
||||
cwd: workspace_root.display().to_string(),
|
||||
provider: "e2e-fixture".to_string(),
|
||||
model: "canned".to_string(),
|
||||
scope_summary: "isolated e2e rewind fixture".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
let target_id = RewindTargetId {
|
||||
segment_id: uuid::Uuid::from_u128(1),
|
||||
user_input_entry_index: 1,
|
||||
};
|
||||
let mut rewind_submit_count = 0usize;
|
||||
let mut pending_apply: Option<std::time::Instant> = None;
|
||||
let apply_delay = Duration::from_millis(400);
|
||||
#[cfg(feature = "e2e-test")]
|
||||
crate::e2e_observer::emit(
|
||||
"single_pod",
|
||||
"rewind_fixture_ready",
|
||||
serde_json::json!({ "pod": pod_name.clone() }),
|
||||
);
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||
|
||||
loop {
|
||||
let wait = pending_apply.map(|submitted_at| {
|
||||
apply_delay
|
||||
.checked_sub(submitted_at.elapsed())
|
||||
.unwrap_or(Duration::ZERO)
|
||||
});
|
||||
let input = match wait {
|
||||
Some(Duration::ZERO) => E2eRewindInput::Tick,
|
||||
Some(timeout) => match tokio::time::timeout(timeout, term_rx.recv()).await {
|
||||
Ok(Some(Ok(event))) => E2eRewindInput::Terminal(event),
|
||||
Ok(Some(Err(err))) => return Err(Box::new(err)),
|
||||
Ok(None) => E2eRewindInput::TerminalClosed,
|
||||
Err(_) => E2eRewindInput::Tick,
|
||||
},
|
||||
None => match term_rx.recv().await {
|
||||
Some(Ok(event)) => E2eRewindInput::Terminal(event),
|
||||
Some(Err(err)) => return Err(Box::new(err)),
|
||||
None => E2eRewindInput::TerminalClosed,
|
||||
},
|
||||
};
|
||||
|
||||
let mut needs_draw = false;
|
||||
match input {
|
||||
E2eRewindInput::Terminal(TermEvent::Key(key)) => {
|
||||
let duplicate_enter_pending = matches!(key.code, KeyCode::Enter)
|
||||
&& app
|
||||
.rewind_picker
|
||||
.as_ref()
|
||||
.map(|picker| picker.applying)
|
||||
.unwrap_or(false);
|
||||
if let Some(method) = handle_key(&mut app, key) {
|
||||
match method {
|
||||
Method::ListRewindTargets => {
|
||||
app.handle_pod_event(Event::RewindTargets {
|
||||
head_entries: 3,
|
||||
targets: vec![RewindTarget {
|
||||
id: target_id.clone(),
|
||||
expected_head_entries: 3,
|
||||
truncate_entries: 1,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "revise the plan".to_string(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}],
|
||||
});
|
||||
crate::e2e_observer::emit(
|
||||
"single_pod",
|
||||
"rewind_picker_opened",
|
||||
serde_json::json!({
|
||||
"targets": 1,
|
||||
"selected_preview": "revise the plan",
|
||||
}),
|
||||
);
|
||||
}
|
||||
Method::RewindTo {
|
||||
target,
|
||||
expected_head_entries,
|
||||
} => {
|
||||
rewind_submit_count += 1;
|
||||
pending_apply = Some(std::time::Instant::now());
|
||||
crate::e2e_observer::emit(
|
||||
"single_pod",
|
||||
"rewind_submit_sent",
|
||||
serde_json::json!({
|
||||
"segment_id": target.segment_id.to_string(),
|
||||
"user_input_entry_index": target.user_input_entry_index,
|
||||
"expected_head_entries": expected_head_entries,
|
||||
"submit_count": rewind_submit_count,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if duplicate_enter_pending {
|
||||
crate::e2e_observer::emit(
|
||||
"single_pod",
|
||||
"rewind_duplicate_enter_suppressed",
|
||||
serde_json::json!({ "submit_count": rewind_submit_count }),
|
||||
);
|
||||
}
|
||||
needs_draw = true;
|
||||
}
|
||||
E2eRewindInput::Terminal(TermEvent::Mouse(_))
|
||||
| E2eRewindInput::Terminal(TermEvent::Resize(_, _))
|
||||
| E2eRewindInput::Tick => {
|
||||
needs_draw = true;
|
||||
}
|
||||
E2eRewindInput::TerminalClosed => break,
|
||||
E2eRewindInput::Terminal(_) => {}
|
||||
}
|
||||
|
||||
if let Some(submitted_at) = pending_apply {
|
||||
if submitted_at.elapsed() >= apply_delay {
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: Vec::new(),
|
||||
input: vec![Segment::text("revise the plan")],
|
||||
summary: RewindSummary {
|
||||
truncated_to_entries: 1,
|
||||
discarded_entries: 2,
|
||||
tool_side_effect_warning: false,
|
||||
},
|
||||
});
|
||||
pending_apply = None;
|
||||
let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
|
||||
crate::e2e_observer::emit(
|
||||
"single_pod",
|
||||
"rewind_applied",
|
||||
serde_json::json!({
|
||||
"composer_text": composer_text,
|
||||
"submit_count": rewind_submit_count,
|
||||
}),
|
||||
);
|
||||
needs_draw = true;
|
||||
}
|
||||
}
|
||||
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
if needs_draw {
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
enum E2eRewindInput {
|
||||
Terminal(TermEvent),
|
||||
TerminalClosed,
|
||||
Tick,
|
||||
}
|
||||
|
||||
enum LoopInput<P> {
|
||||
Terminal(TerminalEventResult),
|
||||
Pod(Option<P>),
|
||||
|
||||
Reference in New Issue
Block a user