//! 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. use std::{ path::{Path, PathBuf}, 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}, 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, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; #[derive(Clone, Copy)] enum WorkspaceTicketReadKind { Query, Show, } impl WorkspaceTicketReadKind { fn name(self) -> &'static str { match self { Self::Query => "QueryTicket", Self::Show => "ShowTicket", } } fn description(self) -> &'static str { match self { Self::Query => { "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." } Self::Show => { "Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence." } } } fn schema(self) -> Value { match self { Self::Query => serde_json::to_value(schemars::schema_for!(WorkspaceQueryTicketInput)) .expect("QueryTicket schema serializes"), Self::Show => serde_json::to_value(schemars::schema_for!(WorkspaceShowTicketInput)) .expect("ShowTicket schema serializes"), } } } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketStateFilter { Planning, Ready, Queued, Inprogress, Done, Closed, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketEvidenceFilter { MergeRequest, Commit, ApprovedReview, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketReviewFilter { None, Pending, Approved, RequestChanges, UnresolvedChanges, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketAttentionFilter { DoneNotClosed, UnresolvedReview, MissingCommit, Blocked, Unblocked, Ready, AwaitingReview, UnresolvedChanges, StaleAfterRescope, MissingEvidence, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketRelationFilter { DependsOn, Blocks, Related, Supersedes, DuplicateOf, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketSort { Relevance, UpdatedDesc, CreatedDesc, Priority, Title, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] struct WorkspaceQueryTicketInput { /// Full-text match over Ticket title, item body, and bounded thread excerpts. query: Option, /// Exact workflow states. Empty means every state. #[serde(default)] states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. #[serde(default)] event_kinds: Vec, /// Required evidence kinds: merge_request, commit, or approved_review. #[serde(default)] evidence: Vec, /// Current authoritative Merge Request review status: none, pending, approved, /// request_changes, or unresolved_changes. review_status: Option, /// Attention filters include done_not_closed, unresolved_review, missing_commit, /// blocked, unblocked, ready, awaiting_review, unresolved_changes, /// stale_after_rescope, and missing_evidence. #[serde(default)] attention: Vec, related_ticket_id: Option, relation_kind: Option, linked_objective_id: Option, updated_after: Option, updated_before: Option, /// relevance (default when query is present), updated_desc, created_desc, /// priority, or title. sort: Option, /// Page size; bounded by the Backend to 1..=100. limit: Option, /// Opaque cursor returned by a prior QueryTicket page. cursor: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] struct WorkspaceShowTicketInput { id: String, /// Most-recent thread entries to return, bounded by the Backend to 1..=50. event_limit: Option, /// Opaque event cursor returned by a prior ShowTicket page. event_cursor: Option, } #[derive(Clone)] struct WorkspaceTicketReadTool { client: Arc, kind: WorkspaceTicketReadKind, } #[async_trait] impl Tool for WorkspaceTicketReadTool { async fn execute( &self, input: &str, _context: ToolExecutionContext, ) -> Result { let workspace_id = self.client.workspace_id().ok_or_else(|| { ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into()) })?; let (path, body) = match self.kind { WorkspaceTicketReadKind::Query => { let input: WorkspaceQueryTicketInput = serde_json::from_str(&input) .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; ( format!("/api/w/{workspace_id}/tickets/query"), serde_json::to_value(input) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, ) } WorkspaceTicketReadKind::Show => { let input: WorkspaceShowTicketInput = serde_json::from_str(&input) .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; if input.id.trim().is_empty() { return Err(ToolError::InvalidArgument( "ShowTicket.id must not be empty".into(), )); } let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim()); let body = json!({ "event_limit": input.event_limit, "event_cursor": input.event_cursor, }); (path, body) } }; let response = self .client .execute(WorkspaceRequest::json( WorkspaceRequestMethod::Post, path, serde_json::to_string(&body) .map_err(|error| ToolError::Internal(error.to_string()))?, )) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( "Workspace Ticket API returned HTTP {}: {}", response.status, response.body ))); } Ok(ToolOutput { summary: self.kind.name().to_string(), content: Some(response.body), attachments: Vec::new(), }) } } fn workspace_ticket_read_definition( client: Arc, kind: WorkspaceTicketReadKind, ) -> ToolDefinition { Arc::new(move || { let meta = ToolMeta::new(kind.name()) .description(kind.description()) .input_schema(kind.schema()); let tool: Arc = Arc::new(WorkspaceTicketReadTool { client: client.clone(), kind, }); (meta, tool) }) } 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 TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow"; const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets"; pub const TICKET_SERVICE_ID: &str = "ticket.authority"; const TICKET_SERVICE_VERSION: &str = "1"; pub trait TicketService: Send + Sync { fn workflow_state(&self, ticket_id: &str) -> Result; } struct BackendTicketService { backend: TicketToolBackend, } impl TicketService for BackendTicketService { fn workflow_state(&self, ticket_id: &str) -> Result { self.backend .show(ticket_id.into()) .map(|ticket| ticket.meta.workflow_state) } } fn ticket_workflow_instruction() -> FeatureInstructionDeclaration { FeatureInstructionDeclaration::new( FeatureInstructionId::builtin(TICKET_WORKFLOW_INSTRUCTION_ID), TICKET_WORKFLOW_PROMPT_REF, "Typed Ticket workflow guidance", ) .expect("static Ticket workflow instruction declaration is valid") } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TicketFeatureAccess { pub authoring: bool, pub thread: bool, pub intake: bool, pub workflow: bool, } impl TicketFeatureAccess { pub const fn read_only() -> Self { Self { authoring: false, thread: false, intake: false, workflow: false, } } pub const fn workspace_authoring() -> Self { Self { authoring: true, thread: true, intake: false, workflow: false, } } pub const fn intake() -> Self { Self { authoring: true, thread: true, intake: true, workflow: false, } } pub const fn workflow() -> Self { Self { authoring: false, thread: true, intake: false, workflow: true, } } pub const fn work_report() -> Self { Self { authoring: false, thread: true, intake: false, workflow: false, } } pub const fn review() -> Self { Self { authoring: false, thread: false, intake: false, workflow: false, } } pub fn tool_names(self) -> Vec<&'static str> { TICKET_TOOL_NAMES .iter() .copied() .filter(|name| self.allows_tool(name)) .collect() } fn allows_tool(self, name: &str) -> bool { READ_ONLY_TOOL_NAMES.contains(&name) || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) || (self.thread && THREAD_TOOL_NAMES.contains(&name)) || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) || (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name)) } } const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", "TicketMarkReady", "TicketQueue", "TicketClose", "TicketRelationRecord", "TicketRelationRemove", ]; const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; #[cfg(test)] const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", "QueryTicket", "ShowTicket", "TicketComment", "TicketMarkReady", "TicketQueue", "TicketClose", "TicketRelationRecord", "TicketRelationRemove", ]; #[cfg(test)] const WORKFLOW_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", ]; #[derive(Clone, Debug)] pub enum TicketFeatureBackend { Local { root: PathBuf }, WorkspaceClient(Arc), } impl From 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, config_error: Option, access: TicketFeatureAccess, } impl TicketFeature { pub fn new(backend_root: impl Into) -> Self { Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring()) } pub fn new_with_access(backend_root: impl Into, access: TicketFeatureAccess) -> Self { Self::with_backend( TicketFeatureBackend::Local { root: backend_root.into(), }, access, ) } pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self { Self { backend, record_language: None, config_error: None, access, } } pub fn for_workspace(workspace: impl AsRef) -> Self { Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring()) } pub fn for_workspace_with_access( workspace: impl AsRef, 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 } fn enabled_tool_names(&self) -> Vec<&'static str> { self.access.tool_names() } fn usable_backend_root(&self) -> Result { 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 tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option { 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(|| "".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()), ), } } } impl FeatureModule for TicketFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) .with_description(FEATURE_DESCRIPTION) .with_instruction(ticket_workflow_instruction()) .with_provided_service(ServiceDeclaration::new( ServiceId::builtin(TICKET_SERVICE_ID), TICKET_SERVICE_VERSION, "Current typed Ticket authority", )); let enabled_tool_names = self.enabled_tool_names(); for name in enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new( name, ticket_tool_description(name, self.record_language.as_deref()), )); } 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 = Arc::new(BackendTicketService { backend: backend.clone(), }); context.services().provide( ServiceDeclaration::new( ServiceId::builtin(TICKET_SERVICE_ID), TICKET_SERVICE_VERSION, "Current typed Ticket authority", ), ticket_service, )?; context .instructions() .register(FeatureInstructionContribution::new( 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(); let name = meta.name.clone(); if !allowed_tool_names .iter() .any(|allowed| *allowed == name.as_str()) { 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) } _ => definition, }; tools.register(ToolContribution::new(name, definition))?; } Ok(()) } } #[derive(Clone, Debug)] struct WorkspaceHttpTicketBackend { client: Arc, } impl WorkspaceHttpTicketBackend { fn new(client: Arc) -> Self { Self { client } } fn invoke( &self, operation: TicketBackendOperation, ) -> TicketResult { let client = self.client.clone(); let workspace_id = self.client.workspace_id().unwrap_or_default().to_string(); if tokio::runtime::Handle::try_current().is_ok() { return std::thread::spawn(move || { Self::invoke_client(client, workspace_id, operation) }) .join() .map_err(|_| { TicketError::Conflict("ticket REST request thread panicked".to_string()) })?; } Self::invoke_client(client, workspace_id, operation) } fn ticket_path(id: &TicketIdOrSlug) -> String { let value = match id { TicketIdOrSlug::Id(value) | TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Query(value) => value, }; let mut encoded = String::with_capacity(value.len()); for byte in value.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(byte as char); } else { use std::fmt::Write as _; let _ = write!(encoded, "%{byte:02X}"); } } encoded } fn request( client: Arc, method: WorkspaceRequestMethod, endpoint: String, body: Option, ) -> TicketResult { let request = match body { Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()), None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint), None => WorkspaceRequest { method, path: endpoint, body: None, }, }; let response = client.execute(request).map_err(|error| { TicketError::Conflict(format!("ticket REST request failed: {error}")) })?; if !response.is_success() { return Err(TicketError::Conflict(format!( "ticket REST API returned HTTP {}: {}", response.status, response.body ))); } serde_json::from_str(&response.body) .map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}"))) } fn request_unit( client: Arc, method: WorkspaceRequestMethod, endpoint: String, body: Option, ) -> TicketResult { let request = match body { Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()), None => WorkspaceRequest { method, path: endpoint, body: None, }, }; let response = client.execute(request).map_err(|error| { TicketError::Conflict(format!("ticket REST request failed: {error}")) })?; if !response.is_success() { return Err(TicketError::Conflict(format!( "ticket REST API returned HTTP {}: {}", response.status, response.body ))); } Ok(TicketBackendOperationResult::Unit) } fn invoke_client( client: Arc, workspace_id: String, operation: TicketBackendOperation, ) -> TicketResult { let base = format!("/api/w/{workspace_id}/tickets"); match operation { TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => { let value = Self::request::( client, WorkspaceRequestMethod::Post, format!("{base}/default-intake-ready-body"), Some(serde_json::json!({ "from": from })), )?; Ok(TicketBackendOperationResult::Text(value)) } TicketBackendOperation::List { filter } => { let state = match filter.state { ticket::TicketStateSelector::Active => "active".to_string(), ticket::TicketStateSelector::All => "all".to_string(), ticket::TicketStateSelector::States(states) => states .into_iter() .map(|state| state.as_str().to_string()) .collect::>() .join(","), }; let tickets = Self::request( client, WorkspaceRequestMethod::Get, format!("{base}/search?state={state}"), None, )?; Ok(TicketBackendOperationResult::Tickets(tickets)) } TicketBackendOperation::Show { id } => { let ticket = Self::request( client, WorkspaceRequestMethod::Get, format!("{base}/{}/record", Self::ticket_path(&id)), None, )?; Ok(TicketBackendOperationResult::Ticket(ticket)) } TicketBackendOperation::Create { input } => { let ticket = Self::request( client, WorkspaceRequestMethod::Post, base, Some(serde_json::to_value(input).map_err(|error| { TicketError::Conflict(format!("serialize Ticket create: {error}")) })?), )?; Ok(TicketBackendOperationResult::TicketRef(ticket)) } TicketBackendOperation::EditItem { id, edit } => { let ticket = Self::request( client, WorkspaceRequestMethod::Patch, format!("{base}/{}/item", Self::ticket_path(&id)), Some(serde_json::to_value(edit).map_err(|error| { TicketError::Conflict(format!("serialize Ticket edit: {error}")) })?), )?; Ok(TicketBackendOperationResult::Ticket(ticket)) } TicketBackendOperation::DependencyCheck { id } => { let check = Self::request( client, WorkspaceRequestMethod::Get, format!("{base}/{}/dependency-check", Self::ticket_path(&id)), None, )?; Ok(TicketBackendOperationResult::DependencyCheck(check)) } TicketBackendOperation::AddEvent { id, event } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/thread-events", Self::ticket_path(&id)), Some(serde_json::to_value(event).map_err(|error| { TicketError::Conflict(format!("serialize Ticket event: {error}")) })?), ), TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/state-changes", Self::ticket_path(&id)), Some(serde_json::to_value(change).map_err(|error| { TicketError::Conflict(format!("serialize Ticket state change: {error}")) })?), ), TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/intake-summaries", Self::ticket_path(&id)), Some(serde_json::to_value(summary).map_err(|error| { TicketError::Conflict(format!("serialize Ticket intake summary: {error}")) })?), ), TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!( "{base}/{}/state-fields/{}", Self::ticket_path(&id), Self::ticket_path(&TicketIdOrSlug::Query(field)) ), Some(serde_json::to_value(change).map_err(|error| { TicketError::Conflict(format!("serialize Ticket state field change: {error}")) })?), ), TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/workflow-state", Self::ticket_path(&id)), Some(serde_json::to_value(change).map_err(|error| { TicketError::Conflict(format!("serialize Ticket workflow change: {error}")) })?), ), TicketBackendOperation::MarkReady { id, request } => Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)), Some(serde_json::to_value(request).map_err(|error| { TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}")) })?), ) .map(TicketBackendOperationResult::Ticket), TicketBackendOperation::QueueReady { id, .. } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/workflow/queue", Self::ticket_path(&id)), None, ), TicketBackendOperation::Close { id, resolution } => Self::request_unit( client, WorkspaceRequestMethod::Post, format!("{base}/{}/workflow/close", Self::ticket_path(&id)), Some(serde_json::to_value(resolution).map_err(|error| { TicketError::Conflict(format!("serialize Ticket close: {error}")) })?), ), TicketBackendOperation::AddTicketRelation { id, relation } => { let relation = Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/{}/relations", Self::ticket_path(&id)), Some(serde_json::to_value(relation).map_err(|error| { TicketError::Conflict(format!("serialize Ticket relation: {error}")) })?), )?; Ok(TicketBackendOperationResult::Relation(relation)) } TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { let target = match target { TicketIdOrSlug::Id(value) | TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Query(value) => value, }; let relation = Self::request( client, WorkspaceRequestMethod::Delete, format!("{base}/{}/relations", Self::ticket_path(&id)), Some(serde_json::json!({ "kind": kind, "target": target })), )?; Ok(TicketBackendOperationResult::Relation(relation)) } TicketBackendOperation::QueryTicketRelations { ticket, kind } => { let relations = Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/relations/search"), Some(serde_json::json!({ "ticket": ticket, "kind": kind })), )?; Ok(TicketBackendOperationResult::Relations(relations)) } TicketBackendOperation::RelationView { id } => { let view = Self::request( client, WorkspaceRequestMethod::Get, format!("{base}/{}/relation-view", Self::ticket_path(&id)), None, )?; Ok(TicketBackendOperationResult::RelationView(view)) } TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => { let record = Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/{}/orchestration-plans", Self::ticket_path(&id)), Some(serde_json::to_value(record).map_err(|error| { TicketError::Conflict(format!( "serialize Ticket orchestration plan: {error}" )) })?), )?; Ok(TicketBackendOperationResult::OrchestrationPlanRecord( record, )) } TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => { let records = Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/orchestration-plans/search"), Some(serde_json::json!({ "ticket": ticket, "kind": kind })), )?; Ok(TicketBackendOperationResult::OrchestrationPlanRecords( records, )) } TicketBackendOperation::Doctor => { let report = Self::request( client, WorkspaceRequestMethod::Get, format!("{base}/doctor"), None, )?; Ok(TicketBackendOperationResult::DoctorReport(report)) } } } } macro_rules! expect_ticket_result { ($expr:expr, $variant:path) => { match $expr? { $variant(value) => Ok(value), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } }; } impl TicketBackend for WorkspaceHttpTicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String { match self.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from: from.to_string(), }) { Ok(TicketBackendOperationResult::Text(value)) => value, Ok(other) => format!("unexpected ticket backend response: {other:?}"), Err(error) => error.to_string(), } } fn list(&self, filter: TicketListQuery) -> TicketResult> { expect_ticket_result!( self.invoke(TicketBackendOperation::List { filter }), TicketBackendOperationResult::Tickets ) } fn show(&self, id: TicketIdOrSlug) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::Show { id }), TicketBackendOperationResult::Ticket ) } fn create(&self, input: NewTicket) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::Create { input }), TicketBackendOperationResult::TicketRef ) } fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::EditItem { id, edit }), TicketBackendOperationResult::Ticket ) } fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::DependencyCheck { id }), TicketBackendOperationResult::DependencyCheck ) } fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> { match self.invoke(TicketBackendOperation::AddEvent { id, event })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> TicketResult<()> { match self.invoke(TicketBackendOperation::AddStateChanged { id, change })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn add_intake_summary( &self, id: TicketIdOrSlug, summary: TicketIntakeSummary, ) -> TicketResult<()> { match self.invoke(TicketBackendOperation::AddIntakeSummary { id, summary })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn set_state_field( &self, id: TicketIdOrSlug, field: &str, change: TicketStateChange, ) -> TicketResult<()> { match self.invoke(TicketBackendOperation::SetStateField { id, field: field.to_string(), change, })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn set_workflow_state( &self, id: TicketIdOrSlug, change: TicketStateChange, ) -> TicketResult<()> { match self.invoke(TicketBackendOperation::SetWorkflowState { id, change })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn mark_ready( &self, id: TicketIdOrSlug, request: ticket::TicketMarkReady, ) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::MarkReady { id, request }), TicketBackendOperationResult::Ticket ) } fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { match self.invoke(TicketBackendOperation::QueueReady { id, queued_by: queued_by.to_string(), })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { match self.invoke(TicketBackendOperation::Close { id, resolution })? { TicketBackendOperationResult::Unit => Ok(()), other => Err(TicketError::Conflict(format!( "unexpected ticket backend response: {other:?}" ))), } } fn add_ticket_relation( &self, id: TicketIdOrSlug, relation: NewTicketRelation, ) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::AddTicketRelation { id, relation }), TicketBackendOperationResult::Relation ) } fn remove_ticket_relation( &self, id: TicketIdOrSlug, kind: TicketRelationKind, target: TicketIdOrSlug, ) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::RemoveTicketRelation { id, kind, target }), TicketBackendOperationResult::Relation ) } fn query_ticket_relations( &self, ticket: Option, kind: Option, ) -> TicketResult> { expect_ticket_result!( self.invoke(TicketBackendOperation::QueryTicketRelations { ticket, kind }), TicketBackendOperationResult::Relations ) } fn relation_view(&self, id: TicketIdOrSlug) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::RelationView { id }), TicketBackendOperationResult::RelationView ) } fn add_orchestration_plan_record( &self, id: TicketIdOrSlug, record: NewOrchestrationPlanRecord, ) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::AddOrchestrationPlanRecord { id, record }), TicketBackendOperationResult::OrchestrationPlanRecord ) } fn query_orchestration_plan_records( &self, ticket: Option, kind: Option, ) -> TicketResult> { expect_ticket_result!( self.invoke(TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind }), TicketBackendOperationResult::OrchestrationPlanRecords ) } fn doctor(&self) -> TicketResult { expect_ticket_result!( self.invoke(TicketBackendOperation::Doctor), TicketBackendOperationResult::DoctorReport ) } } pub fn ticket_tools_feature(workspace: impl AsRef) -> TicketFeature { TicketFeature::for_workspace(workspace) } pub fn ticket_tools_feature_with_access( workspace: impl AsRef, access: TicketFeatureAccess, ) -> TicketFeature { TicketFeature::for_workspace_with_access(workspace, access) } pub fn ticket_tools_feature_with_backend( backend: impl Into, access: TicketFeatureAccess, ) -> TicketFeature { TicketFeature::with_backend(backend.into(), access) } #[cfg(test)] mod tests { use super::*; use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind}; use crate::hook::HookRegistryBuilder; 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(); } 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: &[llm_engine::tool::ToolDefinition], name: &str, ) -> String { pending_tools .iter() .find_map(|definition| { let (meta, _) = definition(); (meta.name == name).then_some(meta.description) }) .expect("tool exists") } #[test] fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() { let client: Arc = Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), ); let (query, _) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); assert_eq!(query.name, "QueryTicket"); assert!(query.input_schema["properties"]["evidence"].is_object()); assert!(query.input_schema["properties"]["attention"].is_object()); assert!(query.input_schema["properties"]["cursor"].is_object()); let query_schema = serde_json::to_string(&query.input_schema).unwrap(); assert!(query_schema.contains("done_not_closed")); assert!(query_schema.contains("request_changes")); assert!(query_schema.contains("created_desc")); assert!( query_schema.len() < 8_000, "QueryTicket schema grew unexpectedly" ); let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)(); assert_eq!(show.name, "ShowTicket"); assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); assert_eq!(tool_names.len(), 10); assert!( tool_names.len() < 13, "authoring catalog must stay below the prior broad catalog" ); let workflow_names = TicketFeatureAccess::workflow().tool_names(); assert_eq!(workflow_names.len(), 10); assert!( workflow_names.len() < 12, "workflow catalog must stay below the prior broad catalog" ); assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2); assert!(tool_names.contains(&"QueryTicket")); assert!(tool_names.contains(&"ShowTicket")); assert!(!tool_names.contains(&"TicketList")); assert!(!tool_names.contains(&"TicketShow")); } #[test] fn descriptor_declares_ticket_tools() { let temp = TempDir::new().unwrap(); let feature = ticket_tools_feature(temp.path()); let descriptor = feature.descriptor(); assert_eq!(descriptor.id.to_string(), "builtin:ticket"); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); assert_eq!(descriptor.tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), WORKSPACE_AUTHORING_TOOL_NAMES ); } #[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 descriptor = feature.descriptor(); assert_eq!(feature.access(), TicketFeatureAccess::read_only()); assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), READ_ONLY_TOOL_NAMES ); } #[test] fn workflow_descriptor_declares_workflow_tools() { let temp = TempDir::new().unwrap(); let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow()); let descriptor = feature.descriptor(); assert_eq!(feature.access(), TicketFeatureAccess::workflow()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), WORKFLOW_TOOL_NAMES ); } #[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_descriptor = workspace_authoring.descriptor(); let workspace_tools = workspace_descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); assert!(workspace_tools.contains(&"TicketQueue")); assert!(!workspace_tools.contains(&"TicketWorkflowState")); let orchestration = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); let orchestration_tools = orchestration_descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(); assert!(orchestration_tools.contains(&"TicketWorkflowState")); assert!(orchestration_tools.contains(&"TicketDependencyCheck")); assert!(orchestration_tools.contains(&"TicketRelationRecord")); assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); 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_descriptor = work_report.descriptor(); let work_report_tools = work_report_descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(); 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_descriptor = review.descriptor(); let review_tools = review_descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(); assert!(!review_tools.contains(&"TicketWorkflowState")); } #[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(), )) .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 pending_names = pending_tools .iter() .map(|definition| definition().0.name) .collect::>(); assert_eq!(pending_names, READ_ONLY_TOOL_NAMES); for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES { assert!( !report.reports[0] .installed_tools .iter() .any(|tool| tool == name) ); assert!(!pending_names.iter().any(|tool| tool == name)); } } #[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(), )) .install_into_pending(&mut pending_tools, &mut hooks); assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); let installed = report.reports[0] .installed_tools .iter() .map(String::as_str) .collect::>(); assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES); assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); assert!(installed.iter().any(|tool| *tool == "TicketQueue")); assert!(installed.iter().any(|tool| *tool == "TicketMarkReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState")); assert!( !installed .iter() .any(|tool| *tool == "TicketOrchestrationPlanRecord") ); } #[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)); 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())) .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 ); assert!(report.reports[0].diagnostics.is_empty()); assert!(!root.join("open").exists()); assert!(!root.join("pending").exists()); assert!(!root.join("closed").exists()); } #[tokio::test(flavor = "multi_thread")] async fn workspace_http_backend_invoke_is_safe_inside_async_context() { let backend = WorkspaceHttpTicketBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace-a", "not-a-url"), )); let error = backend .invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from: "planning".to_string(), }) .unwrap_err(); assert!(error.to_string().contains("ticket REST request failed")); } #[test] fn workspace_http_backend_posts_ticket_event_subresource() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let size = stream.read(&mut buffer).unwrap(); let request = String::from_utf8_lossy(&buffer[..size]); assert!( request .starts_with("POST /api/w/workspace-a/tickets/01TEST/thread-events HTTP/1.1") ); assert!(!request.contains("\"operation\"")); assert!(request.contains("\"kind\":\"comment\"")); stream .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") .unwrap(); }); let client = Arc::new(crate::worker::TestWorkspaceHttpClient::new( "workspace-a", format!("http://{address}"), )); let backend = WorkspaceHttpTicketBackend::new(client); backend .add_event( TicketIdOrSlug::Id("01TEST".to_string()), NewTicketEvent::new(ticket::TicketEventKind::Comment, "REST comment"), ) .unwrap(); server.join().unwrap(); } #[test] fn workspace_http_backend_deletes_exact_ticket_relation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let len = stream.read(&mut buffer).unwrap(); let request = String::from_utf8_lossy(&buffer[..len]); assert!( request .starts_with("DELETE /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1") ); assert!(request.contains("\"kind\":\"depends_on\"")); assert!(request.contains("\"target\":\"01TARGET\"")); let response_body = serde_json::to_string(&TicketRelation { ticket_id: "01SOURCE".to_string(), kind: TicketRelationKind::DependsOn, target: "01TARGET".to_string(), note: Some("obsolete".to_string()), author: "tester".to_string(), at: "2026-08-06T00:00:00Z".to_string(), }) .unwrap(); write!( stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", response_body.len(), response_body ) .unwrap(); }); let backend = WorkspaceHttpTicketBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url), )); let removed = backend .remove_ticket_relation( TicketIdOrSlug::Id("01SOURCE".to_string()), TicketRelationKind::DependsOn, TicketIdOrSlug::Id("01TARGET".to_string()), ) .unwrap(); server.join().unwrap(); assert_eq!(removed.ticket_id, "01SOURCE"); assert_eq!(removed.target, "01TARGET"); } #[test] fn workspace_http_backend_executes_ticket_create_operation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let len = stream.read(&mut buffer).unwrap(); let request = String::from_utf8_lossy(&buffer[..len]); assert!(request.starts_with("POST /api/w/workspace-a/tickets HTTP/1.1")); assert!(!request.contains("\"operation\"")); assert!(request.contains("\"title\":\"HTTP ticket\"")); let response_body = serde_json::to_string(&TicketRef { id: "01TEST".to_string(), human_key: None, slug: "http-ticket".to_string(), status: ticket::TicketStatus::Open, }) .unwrap(); write!( stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", response_body.len(), response_body ) .unwrap(); }); let backend = WorkspaceHttpTicketBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url), )); let created = backend.create(NewTicket::new("HTTP ticket")).unwrap(); server.join().unwrap(); assert_eq!(created.id, "01TEST"); assert_eq!(created.slug, "http-ticket"); } }