From 983ffe601745dc6ab7425af720559358fff00ae2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 16 Jul 2026 01:18:13 +0900 Subject: [PATCH] feat: route ticket tools through workspace backend --- crates/ticket/src/lib.rs | 263 ++++++++++-- crates/ticket/src/tool.rs | 208 +++++++-- crates/worker-runtime/src/catalog.rs | 8 + crates/worker-runtime/src/http_server.rs | 2 + crates/worker-runtime/src/runtime.rs | 1 + crates/worker-runtime/src/worker_backend.rs | 66 ++- crates/worker/src/controller.rs | 42 +- crates/worker/src/feature/builtin/ticket.rs | 444 ++++++++++++++++++-- crates/worker/src/worker.rs | 14 +- crates/workspace-server/src/hosts.rs | 25 +- crates/workspace-server/src/server.rs | 39 +- 11 files changed, 1002 insertions(+), 110 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 93a858cf..36dcd375 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -82,7 +82,7 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TicketStatus { Open, Closed, @@ -111,7 +111,7 @@ impl fmt::Display for TicketStatus { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum ExtensibleTicketStatus { Open, Closed, @@ -155,7 +155,8 @@ impl From for ExtensibleTicketStatus { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum TicketWorkflowState { Planning, Ready, @@ -221,7 +222,7 @@ impl fmt::Display for TicketWorkflowState { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MarkdownText(pub String); impl MarkdownText { @@ -246,7 +247,7 @@ impl From for MarkdownText { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum TicketIdOrSlug { Id(String), Slug(String), @@ -273,7 +274,8 @@ impl From for TicketIdOrSlug { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum TicketEventKind { Create, Comment, @@ -340,7 +342,8 @@ impl From<&str> for TicketEventKind { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum TicketReviewResult { Approve, RequestChanges, @@ -375,13 +378,13 @@ impl From<&str> for TicketReviewResult { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketReference { pub kind: String, pub target: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NewTicketEvent { pub kind: TicketEventKind, pub author: Option, @@ -400,7 +403,7 @@ impl NewTicketEvent { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketStateChange { pub from: String, pub to: String, @@ -428,7 +431,7 @@ impl TicketStateChange { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketIntakeSummary { pub author: Option, pub body: MarkdownText, @@ -445,7 +448,7 @@ impl TicketIntakeSummary { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketReview { pub result: TicketReviewResult, pub author: Option, @@ -470,7 +473,7 @@ impl TicketReview { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NewTicket { pub title: String, pub slug: Option, @@ -507,7 +510,7 @@ impl NewTicket { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketFilter { pub state: Option, } @@ -522,7 +525,7 @@ impl TicketFilter { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketRef { pub id: String, pub slug: String, @@ -568,7 +571,7 @@ impl fmt::Display for TicketRelationKind { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NewTicketRelation { pub kind: TicketRelationKind, pub target: String, @@ -596,7 +599,7 @@ struct TicketRelationArtifact { relations: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DerivedTicketRelation { pub source_ticket: String, pub inverse_kind: String, @@ -606,7 +609,7 @@ pub struct DerivedTicketRelation { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketRelationBlocker { pub blocking_ticket: String, pub reason_kind: String, @@ -615,14 +618,14 @@ pub struct TicketRelationBlocker { pub blocking_state: TicketWorkflowState, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketRelationNotice { pub related_ticket: String, pub kind: TicketRelationKind, pub message: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketRelationView { pub outgoing: Vec, pub incoming: Vec, @@ -702,7 +705,7 @@ pub struct AcceptedOrchestrationPlan { pub role_plan: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NewOrchestrationPlanRecord { pub kind: OrchestrationPlanKind, pub related_ticket: Option, @@ -727,7 +730,7 @@ pub struct OrchestrationPlanRecord { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketMeta { pub id: String, pub slug: String, @@ -748,7 +751,7 @@ pub struct TicketMeta { pub raw: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketSummary { pub id: String, pub slug: String, @@ -765,31 +768,31 @@ pub struct TicketSummary { pub updated_at: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketInvalidRecord { pub label: String, pub reason: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketPartialList { pub tickets: Vec, pub invalid_records: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketPartial { pub ticket: Ticket, pub invalid_records: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketDocument { pub body: MarkdownText, pub raw_frontmatter: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketEvent { pub kind: TicketEventKind, pub author: Option, @@ -805,13 +808,13 @@ pub struct TicketEvent { pub attributes: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketArtifactRef { /// Path relative to the ticket's `artifacts/` directory. pub relative_path: PathBuf, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Ticket { pub meta: TicketMeta, pub document: TicketDocument, @@ -821,20 +824,20 @@ pub struct Ticket { pub resolution: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum TicketDoctorSeverity { Error, Warning, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketDoctorDiagnostic { pub severity: TicketDoctorSeverity, pub message: String, pub path: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketDoctorReport { pub diagnostics: Vec, } @@ -869,6 +872,7 @@ impl TicketDoctorReport { } pub trait TicketBackend { + fn default_intake_ready_state_change_body(&self, from: &str) -> String; fn list(&self, filter: TicketFilter) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; fn create(&self, input: NewTicket) -> Result; @@ -915,6 +919,195 @@ pub trait TicketBackend { fn doctor(&self) -> Result; } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +pub enum TicketBackendOperation { + DefaultIntakeReadyStateChangeBody { + from: String, + }, + List { + filter: TicketFilter, + }, + Show { + id: TicketIdOrSlug, + }, + Create { + input: NewTicket, + }, + AddEvent { + id: TicketIdOrSlug, + event: NewTicketEvent, + }, + AddStateChanged { + id: TicketIdOrSlug, + change: TicketStateChange, + }, + AddIntakeSummary { + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + }, + SetStateField { + id: TicketIdOrSlug, + field: String, + change: TicketStateChange, + }, + SetWorkflowState { + id: TicketIdOrSlug, + change: TicketStateChange, + }, + MarkIntakeReady { + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + }, + QueueReady { + id: TicketIdOrSlug, + queued_by: String, + }, + Review { + id: TicketIdOrSlug, + review: TicketReview, + }, + Close { + id: TicketIdOrSlug, + resolution: MarkdownText, + }, + AddTicketRelation { + id: TicketIdOrSlug, + relation: NewTicketRelation, + }, + QueryTicketRelations { + ticket: Option, + kind: Option, + }, + RelationView { + id: TicketIdOrSlug, + }, + AddOrchestrationPlanRecord { + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + }, + QueryOrchestrationPlanRecords { + ticket: Option, + kind: Option, + }, + Doctor, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "result", content = "value", rename_all = "snake_case")] +pub enum TicketBackendOperationResult { + Text(String), + Unit, + Tickets(Vec), + Ticket(Ticket), + TicketRef(TicketRef), + Relation(TicketRelation), + Relations(Vec), + RelationView(TicketRelationView), + OrchestrationPlanRecord(OrchestrationPlanRecord), + OrchestrationPlanRecords(Vec), + DoctorReport(TicketDoctorReport), +} + +pub fn execute_ticket_backend_operation( + backend: &B, + operation: TicketBackendOperation, +) -> Result +where + B: TicketBackend + ?Sized, +{ + Ok(match operation { + TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => { + TicketBackendOperationResult::Text( + backend.default_intake_ready_state_change_body(&from), + ) + } + TicketBackendOperation::List { filter } => { + TicketBackendOperationResult::Tickets(backend.list(filter)?) + } + TicketBackendOperation::Show { id } => { + TicketBackendOperationResult::Ticket(backend.show(id)?) + } + TicketBackendOperation::Create { input } => { + TicketBackendOperationResult::TicketRef(backend.create(input)?) + } + TicketBackendOperation::AddEvent { id, event } => { + backend.add_event(id, event)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::AddStateChanged { id, change } => { + backend.add_state_changed(id, change)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::AddIntakeSummary { id, summary } => { + backend.add_intake_summary(id, summary)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::SetStateField { id, field, change } => { + backend.set_state_field(id, &field, change)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::SetWorkflowState { id, change } => { + backend.set_workflow_state(id, change)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::MarkIntakeReady { + id, + summary, + change, + } => { + backend.mark_intake_ready(id, summary, change)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::QueueReady { id, queued_by } => { + backend.queue_ready(id, &queued_by)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::Review { id, review } => { + backend.review(id, review)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::Close { id, resolution } => { + backend.close(id, resolution)?; + TicketBackendOperationResult::Unit + } + TicketBackendOperation::AddTicketRelation { id, relation } => { + TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?) + } + TicketBackendOperation::QueryTicketRelations { ticket, kind } => { + TicketBackendOperationResult::Relations(backend.query_ticket_relations(ticket, kind)?) + } + TicketBackendOperation::RelationView { id } => { + TicketBackendOperationResult::RelationView(backend.relation_view(id)?) + } + TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => { + TicketBackendOperationResult::OrchestrationPlanRecord( + backend.add_orchestration_plan_record(id, record)?, + ) + } + TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => { + TicketBackendOperationResult::OrchestrationPlanRecords( + backend.query_orchestration_plan_records(ticket, kind)?, + ) + } + TicketBackendOperation::Doctor => { + TicketBackendOperationResult::DoctorReport(backend.doctor()?) + } + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum TicketBackendHttpResponse { + Ok { + result: TicketBackendOperationResult, + }, + Error { + message: String, + }, +} + #[derive(Debug, Clone)] pub struct LocalTicketBackend { root: PathBuf, @@ -1392,6 +1585,10 @@ impl LocalTicketBackend { } impl TicketBackend for LocalTicketBackend { + fn default_intake_ready_state_change_body(&self, from: &str) -> String { + self.default_intake_ready_state_change_body(from) + } + fn list(&self, filter: TicketFilter) -> Result> { let mut tickets = Vec::new(); for dir in self.iter_ticket_dirs(filter)? { diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 8ec9e4b2..2d6d1b7c 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -13,10 +13,11 @@ use serde_json::{Value, json}; use crate::{ AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, - NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, Ticket, TicketBackend, - TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, - TicketIdOrSlug, TicketIntakeSummary, TicketRelationKind, TicketReview, TicketReviewResult, - TicketStateChange, TicketSummary, TicketWorkflowState, + NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, + Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, + TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, + TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, + TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -166,6 +167,160 @@ pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> Str description } +/// Backend object used by the LLM-facing Ticket tools. +/// +/// Tool execution is intentionally parameterized by this wrapper rather than by +/// `LocalTicketBackend` so Worker hosts can supply an API-backed implementation +/// without changing the model-visible tool surface. +#[derive(Clone)] +pub struct TicketToolBackend { + backend: Arc, + record_language: Option, +} + +impl TicketToolBackend { + pub fn new(backend: B) -> Self + where + B: TicketBackend + Send + Sync + 'static, + { + Self { + backend: Arc::new(backend), + record_language: None, + } + } + + pub fn with_record_language(mut self, language: Option<&str>) -> Self { + self.record_language = language + .map(str::trim) + .filter(|language| !language.is_empty()) + .map(str::to_string); + self + } + + pub fn record_language(&self) -> Option<&str> { + self.record_language.as_deref() + } +} + +impl From for TicketToolBackend { + fn from(backend: LocalTicketBackend) -> Self { + let record_language = backend.record_language().map(str::to_string); + Self::new(backend).with_record_language(record_language.as_deref()) + } +} + +impl TicketBackend for TicketToolBackend { + fn default_intake_ready_state_change_body(&self, from: &str) -> String { + self.backend.default_intake_ready_state_change_body(from) + } + + fn list(&self, filter: crate::TicketFilter) -> TicketResult> { + self.backend.list(filter) + } + + fn show(&self, id: TicketIdOrSlug) -> TicketResult { + self.backend.show(id) + } + + fn create(&self, input: NewTicket) -> TicketResult { + self.backend.create(input) + } + + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> { + self.backend.add_event(id, event) + } + + fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> TicketResult<()> { + self.backend.add_state_changed(id, change) + } + + fn add_intake_summary( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + ) -> TicketResult<()> { + self.backend.add_intake_summary(id, summary) + } + + fn set_state_field( + &self, + id: TicketIdOrSlug, + field: &str, + change: TicketStateChange, + ) -> TicketResult<()> { + self.backend.set_state_field(id, field, change) + } + + fn set_workflow_state( + &self, + id: TicketIdOrSlug, + change: TicketStateChange, + ) -> TicketResult<()> { + self.backend.set_workflow_state(id, change) + } + + fn mark_intake_ready( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + ) -> TicketResult<()> { + self.backend.mark_intake_ready(id, summary, change) + } + + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { + self.backend.queue_ready(id, queued_by) + } + + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> { + self.backend.review(id, review) + } + + fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { + self.backend.close(id, resolution) + } + + fn add_ticket_relation( + &self, + id: TicketIdOrSlug, + relation: NewTicketRelation, + ) -> TicketResult { + self.backend.add_ticket_relation(id, relation) + } + + fn query_ticket_relations( + &self, + ticket: Option, + kind: Option, + ) -> TicketResult> { + self.backend.query_ticket_relations(ticket, kind) + } + + fn relation_view(&self, id: TicketIdOrSlug) -> TicketResult { + self.backend.relation_view(id) + } + + fn add_orchestration_plan_record( + &self, + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + ) -> TicketResult { + self.backend.add_orchestration_plan_record(id, record) + } + + fn query_orchestration_plan_records( + &self, + ticket: Option, + kind: Option, + ) -> TicketResult> { + self.backend.query_orchestration_plan_records(ticket, kind) + } + + fn doctor(&self) -> TicketResult { + self.backend.doctor() + } +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketCreateParams { /// Ticket title. Must not be empty. @@ -565,67 +720,67 @@ struct TicketDoctorOutput { #[derive(Clone)] struct TicketCreateTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketListTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketShowTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketCommentTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketReviewTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketIntakeReadyTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketWorkflowStateTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketCloseTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketRelationRecordTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketRelationQueryTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketOrchestrationPlanRecordTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketOrchestrationPlanQueryTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[derive(Clone)] struct TicketDoctorTool { - backend: LocalTicketBackend, + backend: TicketToolBackend, } #[async_trait] @@ -1308,9 +1463,9 @@ fn json_output(summary: String, value: impl Serialize) -> ToolOutput { } } -fn tool_definition(name: &'static str, backend: LocalTicketBackend) -> ToolDefinition +fn tool_definition(name: &'static str, backend: TicketToolBackend) -> ToolDefinition where - T: Tool + From + 'static, + T: Tool + From + 'static, { let description = ticket_tool_description(name, backend.record_language()); Arc::new(move || { @@ -1355,8 +1510,8 @@ fn input_schema(name: &str) -> Value { macro_rules! impl_from_backend { ($tool:ident) => { - impl From for $tool { - fn from(backend: LocalTicketBackend) -> Self { + impl From for $tool { + fn from(backend: TicketToolBackend) -> Self { Self { backend } } } @@ -1377,8 +1532,9 @@ impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketDoctorTool); -/// Build all MVP Ticket tool definitions over one local backend root. -pub fn ticket_tools(backend: LocalTicketBackend) -> Vec { +/// Build all MVP Ticket tool definitions over the supplied backend. +pub fn ticket_tools(backend: impl Into) -> Vec { + let backend = backend.into(); vec![ tool_definition::("TicketCreate", backend.clone()), tool_definition::("TicketList", backend.clone()), @@ -1416,7 +1572,7 @@ mod tests { tool } - fn tool_by_name(backend: LocalTicketBackend, name: &str) -> Arc { + fn tool_by_name(backend: impl Into, name: &str) -> Arc { ticket_tools(backend) .into_iter() .find_map(|definition| { @@ -1426,7 +1582,7 @@ mod tests { .expect("tool exists") } - fn tool_description_by_name(backend: LocalTicketBackend, name: &str) -> String { + fn tool_description_by_name(backend: impl Into, name: &str) -> String { ticket_tools(backend) .into_iter() .find_map(|definition| { @@ -2303,7 +2459,7 @@ mod tests { let temp = TempDir::new().unwrap(); let create = tool(tool_definition::( "TicketCreate", - backend(&temp), + backend(&temp).into(), )); let _ = create; } diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index b05c2388..cb8912c2 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -167,6 +167,12 @@ pub struct WorkingDirectoryStatus { pub summary: WorkingDirectorySummary, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceApiRef { + pub workspace_id: String, + pub base_url: String, +} + /// Canonical Runtime Worker creation request. /// /// Browser/product launch semantics are resolved by a backend before this @@ -188,6 +194,8 @@ pub struct CreateWorkerRequest { pub working_directory_request: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_api: Option, } /// Worker lifecycle status for the in-memory embedded runtime. diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index a108faba..6d8a5e23 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -982,6 +982,7 @@ mod tests { initial_input: None, working_directory_request: None, working_directory: None, + workspace_api: None, } } @@ -1293,6 +1294,7 @@ mod ws_tests { initial_input: None, working_directory_request: None, working_directory: None, + workspace_api: None, } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index e5c057a7..9bd05f32 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1842,6 +1842,7 @@ mod tests { initial_input: None, working_directory_request: None, working_directory: None, + workspace_api: None, } } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index e473eae7..4772ee03 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -39,7 +39,7 @@ use tokio::sync::broadcast; use worker::{ PromptLoader, Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle, - WorkerWorkspaceContext, WorkspaceId, + WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, }; const DEFAULT_BACKEND_ID: &str = "worker-crate"; @@ -240,10 +240,29 @@ impl ProfileRuntimeWorkerFactory { #[derive(Debug, Clone)] enum RuntimeWorkspaceBackendRef { None, - LocalFilesystem { root: PathBuf }, + LocalFilesystem { + root: PathBuf, + }, + Http { + workspace_id: String, + base_url: String, + }, } impl RuntimeWorkspaceBackendRef { + fn from_worker_request( + request: &CreateWorkerRequest, + binding: Option<&WorkingDirectoryBinding>, + ) -> Self { + if let Some(api) = request.workspace_api.as_ref() { + return Self::Http { + workspace_id: api.workspace_id.clone(), + base_url: api.base_url.clone(), + }; + } + Self::from_working_directory(binding) + } + fn from_working_directory(binding: Option<&WorkingDirectoryBinding>) -> Self { match binding { Some(binding) => Self::LocalFilesystem { @@ -257,6 +276,13 @@ impl RuntimeWorkspaceBackendRef { match self { Self::None => WorkerWorkspaceContext::no_workspace(), Self::LocalFilesystem { root } => local_workspace_context(root), + Self::Http { + workspace_id, + base_url, + } => WorkerWorkspaceContext::with_client( + WorkspaceId::new(workspace_id.clone()).ok(), + WorkspaceClient::http(workspace_id.clone(), base_url.clone()), + ), } } } @@ -350,8 +376,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) }) .unwrap_or(WorkerFilesystemAuthority::None); - let workspace_backend_ref = - RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref()); + let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request( + &request.request, + request.working_directory.as_ref(), + ); let workspace_context = workspace_backend_ref.worker_context(); let selector = profile.as_deref().unwrap_or("builtin:default"); let archive = self @@ -422,8 +450,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) }) .unwrap_or(WorkerFilesystemAuthority::None); - let workspace_backend_ref = - RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref()); + let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request( + &request.request, + request.working_directory.as_ref(), + ); let workspace_context = workspace_backend_ref.worker_context(); let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; @@ -1187,6 +1217,7 @@ mod tests { store_dir: PathBuf, worker_metadata_dir: PathBuf, observed_cwds: Arc>>, + observed_workspace_clients: Arc>>, } #[async_trait] @@ -1232,10 +1263,15 @@ mod tests { .as_ref() .map(|binding| binding.root().to_path_buf()) .unwrap_or_else(|| self.cwd.clone()); - let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_working_directory( + let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request( + &request.request, request.working_directory.as_ref(), ); let workspace_context = workspace_backend_ref.worker_context(); + self.observed_workspace_clients + .lock() + .unwrap() + .push(workspace_context.client().clone()); let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?; let worker = Worker::new( manifest, @@ -1354,6 +1390,7 @@ mod tests { initial_input: None, working_directory_request: None, working_directory: None, + workspace_api: None, } } @@ -1454,6 +1491,7 @@ mod tests { let cwd = tempfile::tempdir().unwrap(); let store = tempfile::tempdir().unwrap(); let observed_cwds = Arc::new(Mutex::new(Vec::new())); + let observed_workspace_clients = Arc::new(Mutex::new(Vec::new())); let factory = MockFactory { client: client.clone(), runtime_base: runtime_base.path().to_path_buf(), @@ -1461,13 +1499,19 @@ mod tests { store_dir: store.path().join("sessions"), worker_metadata_dir: store.path().join("workers"), observed_cwds: observed_cwds.clone(), + observed_workspace_clients: observed_workspace_clients.clone(), }; let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap(); let runtime = EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) .unwrap(); runtime.store_config_bundle(test_bundle()).unwrap(); - let detail = runtime.create_worker(create_request("chat")).unwrap(); + let mut request = create_request("chat"); + request.workspace_api = Some(crate::catalog::WorkspaceApiRef { + workspace_id: "ws-test".to_string(), + base_url: "http://127.0.0.1:3999".to_string(), + }); + let detail = runtime.create_worker(request).unwrap(); runtime .send_input(&detail.worker_ref, WorkerInput::user("say hello")) @@ -1495,6 +1539,10 @@ mod tests { assert_eq!(client.captured.lock().unwrap().len(), 1); assert!(observed_cwds.lock().unwrap().is_empty()); + assert_eq!( + observed_workspace_clients.lock().unwrap().as_slice(), + &[WorkspaceClient::http("ws-test", "http://127.0.0.1:3999")] + ); let names = captured_tool_names(&client, 0); for forbidden in core_filesystem_tool_names() { assert!( @@ -1519,6 +1567,7 @@ mod tests { let repo = create_clean_repo(); let store = tempfile::tempdir().unwrap(); let observed_cwds = Arc::new(Mutex::new(Vec::new())); + let observed_workspace_clients = Arc::new(Mutex::new(Vec::new())); let factory = MockFactory { client: client.clone(), runtime_base: runtime_base.path().to_path_buf(), @@ -1526,6 +1575,7 @@ mod tests { store_dir: store.path().join("sessions"), worker_metadata_dir: store.path().join("workers"), observed_cwds: observed_cwds.clone(), + observed_workspace_clients: observed_workspace_clients.clone(), }; let backend = WorkerRuntimeExecutionBackend::new(factory) .unwrap() diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 891e7b3e..5452c00e 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -33,7 +33,7 @@ use crate::spawn::tool::spawn_worker_tool; use crate::ticket_event_notify::{ TicketEventCompanionNotifyHook, companion_worker_name_for_workspace, }; -use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult}; +use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult, WorkspaceClient}; use protocol::{ AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, TurnResult, WorkerStatus, @@ -715,21 +715,35 @@ where crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle } }; - // Ticket tools are typed operations over the currently checked-out work - // tree. They require explicit local filesystem authority and must not - // use workspace identity as a cwd fallback. - let ticket_cwd = local_filesystem - .as_ref() - .map(|local| &local.cwd) - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "ticket tools require local Worker filesystem authority", - ) - })?; + // Ticket tools are typed operations over the current workspace Ticket backend. + // Runtime-hosted Workers prefer the workspace API URI carried by the + // Worker context; legacy/local Workers fall back to the checked-out worktree. + let ticket_backend = match worker.workspace_client() { + WorkspaceClient::Http { + workspace_id, + base_url, + } => crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceHttp { + workspace_id: workspace_id.clone(), + base_url: base_url.clone(), + }, + _ => { + let ticket_cwd = local_filesystem + .as_ref() + .map(|local| &local.cwd) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "ticket tools require local Worker filesystem authority", + ) + })?; + crate::feature::builtin::ticket::TicketFeatureBackend::Local { + root: ticket_cwd.clone(), + } + } + }; feature_registry.add_module( crate::feature::builtin::ticket::ticket_tools_feature_with_options( - ticket_cwd, + ticket_backend, feature_config.ticket.enabled.then_some(ticket_access), feature_config.ticket_orchestration.enabled, ), diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 20c33029..a6aab4fc 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -7,12 +7,18 @@ use std::path::{Path, PathBuf}; use ticket::{ - LocalTicketBackend, + LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, + NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, + Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation, + TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug, + TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, + TicketReview, TicketStateChange, TicketSummary, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, tool::{ TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, - TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tool_description, ticket_tools, + TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, + ticket_tools, }, }; @@ -50,9 +56,40 @@ impl TicketFeatureAccess { } } +#[derive(Clone, Debug)] +pub enum TicketFeatureBackend { + Local { + root: PathBuf, + }, + WorkspaceHttp { + workspace_id: String, + base_url: String, + }, +} + +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_root: PathBuf, + backend: TicketFeatureBackend, record_language: Option, config_error: Option, access: TicketFeatureAccess, @@ -73,9 +110,23 @@ impl TicketFeature { backend_root: impl Into, access: Option, include_orchestration_tools: bool, + ) -> Self { + Self::with_backend( + TicketFeatureBackend::Local { + root: backend_root.into(), + }, + access, + include_orchestration_tools, + ) + } + + pub fn with_backend( + backend: TicketFeatureBackend, + access: Option, + include_orchestration_tools: bool, ) -> Self { Self { - backend_root: backend_root.into(), + backend, record_language: None, config_error: None, access: access.unwrap_or(TicketFeatureAccess::Lifecycle), @@ -113,7 +164,9 @@ impl TicketFeature { Err(error) => { let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle); Self { - backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + backend: TicketFeatureBackend::Local { + root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + }, record_language: None, config_error: Some(error.to_string()), access: access_value, @@ -124,8 +177,11 @@ impl TicketFeature { } } - pub fn backend_root(&self) -> &Path { - &self.backend_root + pub fn backend_root(&self) -> Option<&Path> { + match &self.backend { + TicketFeatureBackend::Local { root } => Some(root), + TicketFeatureBackend::WorkspaceHttp { .. } => None, + } } pub fn access(&self) -> TicketFeatureAccess { @@ -150,8 +206,10 @@ impl TicketFeature { } fn usable_backend_root(&self) -> Result { - let root = self - .backend_root + 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() { @@ -159,6 +217,41 @@ impl TicketFeature { } 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::WorkspaceHttp { + workspace_id, + base_url, + } => Some( + TicketToolBackend::new(WorkspaceHttpTicketBackend::new( + workspace_id.clone(), + base_url.clone(), + )) + .with_record_language(self.record_language.as_deref()), + ), + } + } } impl FeatureModule for TicketFeature { @@ -184,20 +277,9 @@ impl FeatureModule for TicketFeature { ))); return Ok(()); } - 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.display() - ))); - return Ok(()); - } + let Some(backend) = self.tool_backend(context) else { + return Ok(()); }; - let backend = LocalTicketBackend::new(usable_root) - .with_record_language(self.record_language.as_deref()); let allowed_tool_names = self.enabled_tool_names(); let mut tools = context.tools(); for definition in ticket_tools(backend) { @@ -215,6 +297,275 @@ impl FeatureModule for TicketFeature { } } +#[derive(Clone, Debug)] +struct WorkspaceHttpTicketBackend { + workspace_id: String, + base_url: String, + client: reqwest::blocking::Client, +} + +impl WorkspaceHttpTicketBackend { + fn new(workspace_id: String, base_url: String) -> Self { + Self { + workspace_id, + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + } + } + + fn endpoint(&self) -> String { + format!( + "{}/api/w/{}/tickets/backend", + self.base_url, self.workspace_id + ) + } + + fn invoke( + &self, + operation: TicketBackendOperation, + ) -> TicketResult { + let body = serde_json::to_string(&operation).map_err(|error| { + TicketError::Conflict(format!("serialize ticket operation: {error}")) + })?; + let response = self + .client + .post(self.endpoint()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .map_err(|error| { + TicketError::Conflict(format!("ticket backend request failed: {error}")) + })?; + let status = response.status(); + let text = response.text().map_err(|error| { + TicketError::Conflict(format!("ticket backend response failed: {error}")) + })?; + if !status.is_success() { + return Err(TicketError::Conflict(format!( + "ticket backend returned HTTP {status}: {text}" + ))); + } + match serde_json::from_str::(&text).map_err(|error| { + TicketError::Conflict(format!("decode ticket backend response: {error}")) + })? { + TicketBackendHttpResponse::Ok { result } => Ok(result), + TicketBackendHttpResponse::Error { message } => Err(TicketError::Conflict(message)), + } + } +} + +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: TicketFilter) -> 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 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_intake_ready( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + ) -> TicketResult<()> { + match self.invoke(TicketBackendOperation::MarkIntakeReady { + id, + summary, + change, + })? { + TicketBackendOperationResult::Unit => Ok(()), + other => Err(TicketError::Conflict(format!( + "unexpected ticket backend response: {other:?}" + ))), + } + } + + 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 review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> { + match self.invoke(TicketBackendOperation::Review { id, review })? { + 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 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) } @@ -227,11 +578,11 @@ pub fn ticket_tools_feature_with_access( } pub fn ticket_tools_feature_with_options( - workspace: impl AsRef, + backend: impl Into, access: Option, include_orchestration_tools: bool, ) -> TicketFeature { - TicketFeature::for_workspace_with_options(workspace, access, include_orchestration_tools) + TicketFeature::with_backend(backend.into(), access, include_orchestration_tools) } #[cfg(test)] @@ -239,6 +590,9 @@ 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; use ticket::tool::{ TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES, @@ -510,7 +864,10 @@ profile = "project:coder" make_ticket_root(&temp.path().join("tickets")); let feature = ticket_tools_feature(temp.path()); - assert_eq!(feature.backend_root(), temp.path().join("tickets")); + assert_eq!( + feature.backend_root(), + Some(temp.path().join("tickets").as_path()) + ); let mut pending_tools = Vec::new(); let mut hooks = HookRegistryBuilder::default(); @@ -611,4 +968,41 @@ provider = "github" assert!(!root.join("pending").exists()); assert!(!root.join("closed").exists()); } + + #[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/backend HTTP/1.1")); + assert!(request.contains("\"operation\":\"create\"")); + assert!(request.contains("\"title\":\"HTTP ticket\"")); + let response_body = serde_json::to_string(&TicketBackendHttpResponse::Ok { + result: TicketBackendOperationResult::TicketRef(TicketRef { + id: "01TEST".to_string(), + 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("workspace-a".to_string(), 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"); + } } diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index f2b9d69b..35148b8b 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -135,6 +135,11 @@ pub enum WorkspaceIdError { /// adapter code. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkspaceClient { + /// Runtime/host supplied an HTTP workspace API endpoint. + Http { + workspace_id: String, + base_url: String, + }, /// Runtime/host supplied a workspace API handle. The string is an opaque /// diagnostic/backend kind, not an endpoint, path, or secret-bearing value. Available { kind: String }, @@ -147,6 +152,13 @@ impl WorkspaceClient { Self::Available { kind: kind.into() } } + pub fn http(workspace_id: impl Into, base_url: impl Into) -> Self { + Self::Http { + workspace_id: workspace_id.into(), + base_url: base_url.into(), + } + } + pub fn unavailable(reason: impl Into) -> Self { Self::Unavailable { reason: reason.into(), @@ -158,7 +170,7 @@ impl WorkspaceClient { } pub fn is_available(&self) -> bool { - matches!(self, Self::Available { .. }) + matches!(self, Self::Available { .. } | Self::Http { .. }) } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 629de1c8..41f53c03 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -17,7 +17,7 @@ use worker_runtime::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, - WorkingDirectoryStatus, WorkingDirectorySummary, + WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef, }; use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; #[cfg(test)] @@ -1182,6 +1182,8 @@ impl RuntimeRegistry { pub struct EmbeddedWorkerRuntime { runtime_id: String, host_id: String, + workspace_id: String, + backend_base_url: Option, runtime: worker_runtime::Runtime, execution_enabled: bool, resource_broker: BackendResourceBroker, @@ -1234,10 +1236,18 @@ impl EmbeddedWorkerRuntime { self } + pub fn with_backend_base_url(mut self, backend_base_url: impl Into) -> Self { + self.backend_base_url = Some(backend_base_url.into().trim_end_matches('/').to_string()); + self + } + pub fn from_runtime(workspace_id: impl AsRef, runtime: worker_runtime::Runtime) -> Self { + let workspace_id = workspace_id.as_ref().to_string(); Self { runtime_id: EMBEDDED_RUNTIME_ID.to_string(), - host_id: host_id_for_embedded_workspace(workspace_id.as_ref()), + host_id: host_id_for_embedded_workspace(&workspace_id), + workspace_id, + backend_base_url: None, runtime, execution_enabled: false, resource_broker: BackendResourceBroker::default(), @@ -1556,6 +1566,13 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { initial_input: request.initial_input.clone(), working_directory_request: request.resolved_working_directory_request.clone(), working_directory: request.resolved_working_directory.clone(), + workspace_api: self + .backend_base_url + .as_ref() + .map(|base_url| WorkspaceApiRef { + workspace_id: self.workspace_id.clone(), + base_url: base_url.clone(), + }), }; match self.runtime.create_worker(create_request) { Ok(detail) => { @@ -2392,6 +2409,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { initial_input: request.initial_input.clone(), working_directory_request: request.resolved_working_directory_request.clone(), working_directory: request.resolved_working_directory.clone(), + workspace_api: Some(WorkspaceApiRef { + workspace_id: self.workspace_id.clone(), + base_url: self.backend_base_url.clone(), + }), }; match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) { Ok(response) => WorkerSpawnResult { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a859941a..964212f0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,3 +1,4 @@ +use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -13,7 +14,10 @@ use chrono::{SecondsFormat, Utc}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; +use ticket::{ + LocalTicketBackend, TicketBackendHttpResponse, TicketBackendOperation, + execute_ticket_backend_operation, +}; use tokio::net::TcpListener; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; @@ -238,6 +242,14 @@ impl WorkspaceApi { execution_backend, ) .map(|runtime| runtime.with_resource_broker(resource_broker.clone())) + .map(|runtime| { + runtime.with_backend_base_url( + config + .backend_base_url + .clone() + .unwrap_or_else(|| "http://127.0.0.1:8787".to_string()), + ) + }) .map_err(|err| { crate::Error::Store(format!("invalid embedded Worker backend: {err}")) })?, @@ -313,6 +325,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route("/api/tickets", get(list_tickets)) .route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets)) + .route( + "/api/w/{workspace_id}/tickets/backend", + post(scoped_ticket_backend_operation), + ) .route("/api/tickets/{id}", get(get_ticket)) .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) .route("/api/objectives", get(list_objectives)) @@ -1226,6 +1242,26 @@ async fn scoped_get_ticket( get_ticket(State(api), AxumPath(path.id)).await } +async fn scoped_ticket_backend_operation( + State(api): State, + AxumPath(path): AxumPath, + Json(operation): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let backend = LocalTicketBackend::new( + api.config + .workspace_root + .join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + ); + let response = match execute_ticket_backend_operation(&backend, operation) { + Ok(result) => TicketBackendHttpResponse::Ok { result }, + Err(error) => TicketBackendHttpResponse::Error { + message: error.to_string(), + }, + }; + Ok(Json(response)) +} + async fn scoped_list_objectives( State(api): State, AxumPath(path): AxumPath, @@ -6265,6 +6301,7 @@ mod tests { initial_input: None, working_directory_request: None, working_directory: None, + workspace_api: None, } }