refactor: remove repository-local yoi authority

This commit is contained in:
2026-09-22 22:52:41 +09:00
parent 043cb7bf22
commit 5fb3cb20cd
45 changed files with 487 additions and 9151 deletions
+3 -8
View File
@@ -1287,15 +1287,10 @@ where
"ticket tools require Backend Workspace API authority",
));
}
let ticket_backend = crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceClient(
feature_registry.add_module(crate::feature::builtin::ticket::ticket_tools_feature(
workspace_client,
);
feature_registry.add_module(
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
ticket_backend,
ticket_access,
),
);
ticket_access,
));
}
if feature_config.merge_request.any() {
let workspace_client = worker.workspace_client_handle();
+14
View File
@@ -832,6 +832,20 @@ permission = "write"
);
}
#[test]
fn clean_workspace_manifest_resolution_does_not_create_repository_local_yoi() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("runtime-workspace");
std::fs::create_dir(&workspace).unwrap();
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.worker.name, "runtime-workspace");
assert!(!workspace.join(".yoi").exists());
}
#[test]
fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() {
let tmp = TempDir::new().unwrap();
+1 -4
View File
@@ -21,10 +21,7 @@ pub mod worker_observation;
pub mod workspace_worker_discovery;
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
ticket_tools_feature_with_backend,
};
pub use ticket::{TicketFeature, TicketFeatureAccess, ticket_tools_feature};
pub use worker_observation::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
+103 -449
View File
@@ -1,34 +1,28 @@
//! Built-in Ticket feature adapter.
//!
//! The ticket crate owns Ticket domain logic and Tool implementations. This
//! module only resolves the local backend root, declares the built-in feature,
//! and contributes those tools through the normal feature registry path.
//! module binds an authority-scoped Workspace client, declares the built-in
//! feature, and contributes those tools through the normal registry path.
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use std::sync::Arc;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
TicketSummary, TicketWorkflowState,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend,
TicketBackendOperation, TicketBackendOperationResult, TicketDoctorReport, TicketError,
TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation,
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
ToolDefinition,
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ServiceDeclaration,
ServiceId, ToolContribution, ToolDeclaration, ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -278,8 +272,8 @@ fn workspace_ticket_read_definition(
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 FEATURE_DESCRIPTION: &str =
"Typed Ticket operations through the authoritative Workspace API.";
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
@@ -302,10 +296,6 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
})
}
struct BackendTicketService {
backend: TicketToolBackend,
}
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
}
@@ -323,12 +313,6 @@ fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketErr
})
}
impl TicketService for BackendTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
}
}
impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
@@ -478,98 +462,20 @@ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketOrchestrationPlanQuery",
];
#[derive(Clone, Debug)]
pub enum TicketFeatureBackend {
Local { root: PathBuf },
WorkspaceClient(Arc<dyn WorkspaceClient>),
}
impl From<PathBuf> for TicketFeatureBackend {
fn from(root: PathBuf) -> Self {
Self::Local { root }
}
}
impl From<&Path> for TicketFeatureBackend {
fn from(root: &Path) -> Self {
Self::Local {
root: root.to_path_buf(),
}
}
}
impl From<&PathBuf> for TicketFeatureBackend {
fn from(root: &PathBuf) -> Self {
Self::Local { root: root.clone() }
}
}
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend: TicketFeatureBackend,
record_language: Option<String>,
config_error: Option<String>,
workspace_client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
}
impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
}
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
Self::with_backend(
TicketFeatureBackend::Local {
root: backend_root.into(),
},
access,
)
}
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
pub fn new(workspace_client: Arc<dyn WorkspaceClient>, access: TicketFeatureAccess) -> Self {
Self {
backend,
record_language: None,
config_error: None,
workspace_client,
access,
}
}
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
}
pub fn for_workspace_with_access(
workspace: impl AsRef<Path>,
access: TicketFeatureAccess,
) -> Self {
let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) {
Ok(config) => {
let backend_root = config.backend_root().to_path_buf();
let record_language = config.ticket_record_language().map(str::to_string);
let mut feature = Self::new_with_access(backend_root, access);
feature.record_language = record_language;
feature
}
Err(error) => Self {
backend: TicketFeatureBackend::Local {
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
},
record_language: None,
config_error: Some(error.to_string()),
access,
},
}
}
pub fn backend_root(&self) -> Option<&Path> {
match &self.backend {
TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::WorkspaceClient(_) => None,
}
}
pub fn access(&self) -> TicketFeatureAccess {
self.access
}
@@ -578,46 +484,12 @@ impl TicketFeature {
self.access.tool_names()
}
fn usable_backend_root(&self) -> Result<PathBuf, String> {
let Some(root) = self.backend_root() else {
return Err("ticket backend is not local filesystem backed".to_string());
};
let root = root
.canonicalize()
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
if !root.is_dir() {
return Err("ticket backend root is not a directory".to_string());
}
Ok(root)
fn workspace_client(&self) -> Arc<dyn WorkspaceClient> {
self.workspace_client.clone()
}
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend {
TicketFeatureBackend::Local { root: _ } => {
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {reason}; root={} ",
self.backend_root()
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<non-local>".to_string())
)));
return None;
}
};
Some(
LocalTicketBackend::new(usable_root)
.with_record_language(self.record_language.as_deref())
.into(),
)
}
TicketFeatureBackend::WorkspaceClient(client) => Some(
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone()))
.with_record_language(self.record_language.as_deref()),
),
}
fn tool_backend(&self) -> TicketToolBackend {
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(self.workspace_client()))
}
}
@@ -635,32 +507,18 @@ impl FeatureModule for TicketFeature {
for name in enabled_tool_names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
name,
ticket_tool_description(name, self.record_language.as_deref()),
ticket_tool_description(name, None),
));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(error) = &self.config_error {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {error}"
)));
return Ok(());
}
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
let backend = self.tool_backend();
let workspace_client = self.workspace_client();
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
@@ -675,10 +533,6 @@ impl FeatureModule for TicketFeature {
ticket_workflow_instruction(),
))?;
let allowed_tool_names = self.enabled_tool_names();
let workspace_client = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
TicketFeatureBackend::Local { .. } => None,
};
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
@@ -689,13 +543,15 @@ impl FeatureModule for TicketFeature {
{
continue;
}
let definition = match (name.as_str(), workspace_client.as_ref()) {
("QueryTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
}
("ShowTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
}
let definition = match name.as_str() {
"QueryTicket" => workspace_ticket_read_definition(
workspace_client.clone(),
WorkspaceTicketReadKind::Query,
),
"ShowTicket" => workspace_ticket_read_definition(
workspace_client.clone(),
WorkspaceTicketReadKind::Show,
),
_ => definition,
};
tools.register(ToolContribution::new(name, definition))?;
@@ -1371,22 +1227,11 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
TicketFeature::for_workspace(workspace)
}
pub fn ticket_tools_feature_with_access(
workspace: impl AsRef<Path>,
pub fn ticket_tools_feature(
workspace_client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
) -> TicketFeature {
TicketFeature::for_workspace_with_access(workspace, access)
}
pub fn ticket_tools_feature_with_backend(
backend: impl Into<TicketFeatureBackend>,
access: TicketFeatureAccess,
) -> TicketFeature {
TicketFeature::with_backend(backend.into(), access)
TicketFeature::new(workspace_client, access)
}
#[cfg(test)]
@@ -1397,29 +1242,31 @@ mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use tempfile::TempDir;
fn make_ticket_root(root: &Path) {
std::fs::create_dir_all(root).unwrap();
#[test]
fn production_source_has_no_local_ticket_feature_backend() {
let production = include_str!("ticket.rs")
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("Ticket feature test module marker");
for forbidden in [
"TicketFeatureBackend",
"LocalTicketBackend",
"for_workspace",
".yoi/tickets",
] {
assert!(
!production.contains(forbidden),
"repository-local Ticket feature returned through {forbidden}"
);
}
}
fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap();
}
fn pending_tool_description(
pending_tools: &[agen::tool::ToolDefinition],
name: &str,
) -> String {
pending_tools
.iter()
.find_map(|definition| {
let (meta, _) = definition();
(meta.name == name).then_some(meta.description)
})
.expect("tool exists")
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
ticket_tools_feature(client, access)
}
#[test]
@@ -1482,8 +1329,7 @@ mod tests {
#[test]
fn descriptor_declares_ticket_tools() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature(temp.path());
let feature = workspace_feature(TicketFeatureAccess::workspace_authoring());
let descriptor = feature.descriptor();
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
@@ -1500,9 +1346,7 @@ mod tests {
#[test]
fn read_only_descriptor_declares_only_state_tools() {
let temp = TempDir::new().unwrap();
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
let feature = workspace_feature(TicketFeatureAccess::read_only());
let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
@@ -1518,9 +1362,7 @@ mod tests {
#[test]
fn workflow_descriptor_declares_workflow_tools() {
let temp = TempDir::new().unwrap();
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let feature = workspace_feature(TicketFeatureAccess::workflow());
let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
assert_eq!(
@@ -1535,12 +1377,7 @@ mod tests {
#[test]
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
let temp = TempDir::new().unwrap();
let workspace_authoring = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
);
let workspace_authoring = workspace_feature(TicketFeatureAccess::workspace_authoring());
let workspace_descriptor = workspace_authoring.descriptor();
let workspace_tools = workspace_descriptor
.tools
@@ -1552,8 +1389,7 @@ mod tests {
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
let orchestration =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let orchestration = workspace_feature(TicketFeatureAccess::workflow());
let orchestration_descriptor = orchestration.descriptor();
let orchestration_tools = orchestration_descriptor
.tools
@@ -1567,8 +1403,7 @@ mod tests {
assert!(!orchestration_tools.contains(&"TicketEditItem"));
assert!(!orchestration_tools.contains(&"TicketQueue"));
let work_report =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
let work_report = workspace_feature(TicketFeatureAccess::work_report());
let work_report_descriptor = work_report.descriptor();
let work_report_tools = work_report_descriptor
.tools
@@ -1578,7 +1413,7 @@ mod tests {
assert!(work_report_tools.contains(&"TicketComment"));
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
let review = workspace_feature(TicketFeatureAccess::review());
let review_descriptor = review.descriptor();
let review_tools = review_descriptor
.tools
@@ -1590,15 +1425,10 @@ mod tests {
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::read_only(),
))
.with_module(workspace_feature(TicketFeatureAccess::read_only()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
@@ -1619,54 +1449,12 @@ mod tests {
}
}
#[test]
fn read_only_companion_style_context_exposes_ticket_language_guidance() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket]
language = "Japanese"
"#,
);
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
let descriptor = feature.descriptor();
let descriptor_description = descriptor
.tools
.iter()
.find(|tool| tool.name == "ShowTicket")
.expect("ShowTicket declared")
.description
.clone();
assert!(descriptor_description.contains("Ticket record language: Japanese"));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
let description = pending_tool_description(&pending_tools, "ShowTicket");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("Preserve protocol literals"));
}
#[test]
fn workspace_authoring_installation_exposes_authoring_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
))
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
@@ -1690,169 +1478,28 @@ language = "Japanese"
}
#[test]
fn workspace_authoring_context_exposes_ticket_language_guidance() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket]
language = "Japanese"
"#,
);
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
fn workspace_only_ticket_feature_preserves_unrelated_stale_repository_trees() {
let ancestor = tempfile::tempdir().unwrap();
let repository = ancestor.path().join("repository");
std::fs::create_dir_all(repository.join(".yoi/tickets/broken")).unwrap();
std::fs::create_dir_all(ancestor.path().join(".yoi")).unwrap();
std::fs::write(
ancestor.path().join(".yoi/workspace.toml"),
"not valid toml",
)
.unwrap();
std::fs::write(
repository.join(".yoi/tickets/broken/item.md"),
"not a Ticket",
)
.unwrap();
// TicketFeature accepts only an injected Workspace client and access policy;
// there is deliberately no repository/cwd path to pass to this installation.
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
let description = pending_tool_description(&pending_tools, "TicketComment");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("durable Ticket record and Ticket tool body text"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("memory.language"));
}
#[test]
fn installs_ticket_tools_when_default_root_is_usable() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
assert!(report.reports[0].skipped.is_empty());
}
#[test]
fn installs_ticket_tools_with_configured_backend_root() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[ticket.backend]
provider = "builtin:yoi_local"
root = "tickets"
[ticket.roles.coder]
profile = "project:coder"
"#,
);
make_ticket_root(&temp.path().join("tickets"));
let feature = ticket_tools_feature(temp.path());
assert_eq!(
feature.backend_root(),
Some(temp.path().join("tickets").as_path())
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
assert!(report.reports[0].diagnostics.is_empty());
}
#[test]
fn malformed_ticket_config_fails_closed() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
write_ticket_config(
temp.path(),
r#"
[ticket.roles.operator]
profile = "inherit"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unsupported Ticket role `operator`"));
}
#[test]
fn unsupported_ticket_backend_provider_fails_closed() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
write_ticket_config(
temp.path(),
r#"
[ticket.backend]
provider = "github"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unsupported Ticket backend provider `github`"));
}
#[test]
fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
assert!(
report.reports[0].diagnostics[0]
.message
.contains("Ticket tools not registered")
);
}
#[test]
fn registers_ticket_tools_for_flat_backend_root() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
std::fs::create_dir_all(&root).unwrap();
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.with_module(workspace_feature(TicketFeatureAccess::workspace_authoring()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
@@ -1861,9 +1508,14 @@ provider = "github"
WORKSPACE_AUTHORING_TOOL_NAMES
);
assert!(report.reports[0].diagnostics.is_empty());
assert!(!root.join("open").exists());
assert!(!root.join("pending").exists());
assert!(!root.join("closed").exists());
assert_eq!(
std::fs::read_to_string(ancestor.path().join(".yoi/workspace.toml")).unwrap(),
"not valid toml"
);
assert_eq!(
std::fs::read_to_string(repository.join(".yoi/tickets/broken/item.md")).unwrap(),
"not a Ticket"
);
}
#[tokio::test(flavor = "multi_thread")]
@@ -2057,10 +1709,12 @@ provider = "github"
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
let temp = tempfile::tempdir().unwrap();
let sqlite =
ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test")
.unwrap();
let created = sqlite.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = sqlite.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;