feat: route ticket tools through workspace backend

This commit is contained in:
Keisuke Hirata 2026-07-16 01:18:13 +09:00
parent 66abd53e51
commit 983ffe6017
No known key found for this signature in database
11 changed files with 1002 additions and 110 deletions

View File

@ -82,7 +82,7 @@ fn io_err(path: impl Into<PathBuf>, 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 { pub enum TicketStatus {
Open, Open,
Closed, 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 { pub enum ExtensibleTicketStatus {
Open, Open,
Closed, Closed,
@ -155,7 +155,8 @@ impl From<TicketStatus> 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 { pub enum TicketWorkflowState {
Planning, Planning,
Ready, 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); pub struct MarkdownText(pub String);
impl MarkdownText { impl MarkdownText {
@ -246,7 +247,7 @@ impl From<String> for MarkdownText {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TicketIdOrSlug { pub enum TicketIdOrSlug {
Id(String), Id(String),
Slug(String), Slug(String),
@ -273,7 +274,8 @@ impl From<String> for TicketIdOrSlug {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketEventKind { pub enum TicketEventKind {
Create, Create,
Comment, 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 { pub enum TicketReviewResult {
Approve, Approve,
RequestChanges, 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 struct TicketReference {
pub kind: String, pub kind: String,
pub target: String, pub target: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewTicketEvent { pub struct NewTicketEvent {
pub kind: TicketEventKind, pub kind: TicketEventKind,
pub author: Option<String>, pub author: Option<String>,
@ -400,7 +403,7 @@ impl NewTicketEvent {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketStateChange { pub struct TicketStateChange {
pub from: String, pub from: String,
pub to: 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 struct TicketIntakeSummary {
pub author: Option<String>, pub author: Option<String>,
pub body: MarkdownText, 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 struct TicketReview {
pub result: TicketReviewResult, pub result: TicketReviewResult,
pub author: Option<String>, pub author: Option<String>,
@ -470,7 +473,7 @@ impl TicketReview {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewTicket { pub struct NewTicket {
pub title: String, pub title: String,
pub slug: Option<String>, pub slug: Option<String>,
@ -507,7 +510,7 @@ impl NewTicket {
} }
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketFilter { pub struct TicketFilter {
pub state: Option<TicketWorkflowState>, pub state: Option<TicketWorkflowState>,
} }
@ -522,7 +525,7 @@ impl TicketFilter {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRef { pub struct TicketRef {
pub id: String, pub id: String,
pub slug: 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 struct NewTicketRelation {
pub kind: TicketRelationKind, pub kind: TicketRelationKind,
pub target: String, pub target: String,
@ -596,7 +599,7 @@ struct TicketRelationArtifact {
relations: Vec<TicketRelation>, relations: Vec<TicketRelation>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DerivedTicketRelation { pub struct DerivedTicketRelation {
pub source_ticket: String, pub source_ticket: String,
pub inverse_kind: String, pub inverse_kind: String,
@ -606,7 +609,7 @@ pub struct DerivedTicketRelation {
pub at: String, pub at: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRelationBlocker { pub struct TicketRelationBlocker {
pub blocking_ticket: String, pub blocking_ticket: String,
pub reason_kind: String, pub reason_kind: String,
@ -615,14 +618,14 @@ pub struct TicketRelationBlocker {
pub blocking_state: TicketWorkflowState, pub blocking_state: TicketWorkflowState,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRelationNotice { pub struct TicketRelationNotice {
pub related_ticket: String, pub related_ticket: String,
pub kind: TicketRelationKind, pub kind: TicketRelationKind,
pub message: String, pub message: String,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRelationView { pub struct TicketRelationView {
pub outgoing: Vec<TicketRelation>, pub outgoing: Vec<TicketRelation>,
pub incoming: Vec<DerivedTicketRelation>, pub incoming: Vec<DerivedTicketRelation>,
@ -702,7 +705,7 @@ pub struct AcceptedOrchestrationPlan {
pub role_plan: Option<String>, pub role_plan: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewOrchestrationPlanRecord { pub struct NewOrchestrationPlanRecord {
pub kind: OrchestrationPlanKind, pub kind: OrchestrationPlanKind,
pub related_ticket: Option<String>, pub related_ticket: Option<String>,
@ -727,7 +730,7 @@ pub struct OrchestrationPlanRecord {
pub at: String, pub at: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketMeta { pub struct TicketMeta {
pub id: String, pub id: String,
pub slug: String, pub slug: String,
@ -748,7 +751,7 @@ pub struct TicketMeta {
pub raw: BTreeMap<String, String>, pub raw: BTreeMap<String, String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketSummary { pub struct TicketSummary {
pub id: String, pub id: String,
pub slug: String, pub slug: String,
@ -765,31 +768,31 @@ pub struct TicketSummary {
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketInvalidRecord { pub struct TicketInvalidRecord {
pub label: String, pub label: String,
pub reason: String, pub reason: String,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketPartialList { pub struct TicketPartialList {
pub tickets: Vec<TicketSummary>, pub tickets: Vec<TicketSummary>,
pub invalid_records: Vec<TicketInvalidRecord>, pub invalid_records: Vec<TicketInvalidRecord>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketPartial { pub struct TicketPartial {
pub ticket: Ticket, pub ticket: Ticket,
pub invalid_records: Vec<TicketInvalidRecord>, pub invalid_records: Vec<TicketInvalidRecord>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketDocument { pub struct TicketDocument {
pub body: MarkdownText, pub body: MarkdownText,
pub raw_frontmatter: BTreeMap<String, String>, pub raw_frontmatter: BTreeMap<String, String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketEvent { pub struct TicketEvent {
pub kind: TicketEventKind, pub kind: TicketEventKind,
pub author: Option<String>, pub author: Option<String>,
@ -805,13 +808,13 @@ pub struct TicketEvent {
pub attributes: BTreeMap<String, String>, pub attributes: BTreeMap<String, String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketArtifactRef { pub struct TicketArtifactRef {
/// Path relative to the ticket's `artifacts/` directory. /// Path relative to the ticket's `artifacts/` directory.
pub relative_path: PathBuf, pub relative_path: PathBuf,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ticket { pub struct Ticket {
pub meta: TicketMeta, pub meta: TicketMeta,
pub document: TicketDocument, pub document: TicketDocument,
@ -821,20 +824,20 @@ pub struct Ticket {
pub resolution: Option<MarkdownText>, pub resolution: Option<MarkdownText>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TicketDoctorSeverity { pub enum TicketDoctorSeverity {
Error, Error,
Warning, Warning,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketDoctorDiagnostic { pub struct TicketDoctorDiagnostic {
pub severity: TicketDoctorSeverity, pub severity: TicketDoctorSeverity,
pub message: String, pub message: String,
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketDoctorReport { pub struct TicketDoctorReport {
pub diagnostics: Vec<TicketDoctorDiagnostic>, pub diagnostics: Vec<TicketDoctorDiagnostic>,
} }
@ -869,6 +872,7 @@ impl TicketDoctorReport {
} }
pub trait TicketBackend { pub trait TicketBackend {
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>; fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>; fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>; fn create(&self, input: NewTicket) -> Result<TicketRef>;
@ -915,6 +919,195 @@ pub trait TicketBackend {
fn doctor(&self) -> Result<TicketDoctorReport>; fn doctor(&self) -> Result<TicketDoctorReport>;
} }
#[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<TicketIdOrSlug>,
kind: Option<TicketRelationKind>,
},
RelationView {
id: TicketIdOrSlug,
},
AddOrchestrationPlanRecord {
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
},
QueryOrchestrationPlanRecords {
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
},
Doctor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "result", content = "value", rename_all = "snake_case")]
pub enum TicketBackendOperationResult {
Text(String),
Unit,
Tickets(Vec<TicketSummary>),
Ticket(Ticket),
TicketRef(TicketRef),
Relation(TicketRelation),
Relations(Vec<TicketRelation>),
RelationView(TicketRelationView),
OrchestrationPlanRecord(OrchestrationPlanRecord),
OrchestrationPlanRecords(Vec<OrchestrationPlanRecord>),
DoctorReport(TicketDoctorReport),
}
pub fn execute_ticket_backend_operation<B>(
backend: &B,
operation: TicketBackendOperation,
) -> Result<TicketBackendOperationResult>
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)] #[derive(Debug, Clone)]
pub struct LocalTicketBackend { pub struct LocalTicketBackend {
root: PathBuf, root: PathBuf,
@ -1392,6 +1585,10 @@ impl LocalTicketBackend {
} }
impl TicketBackend for 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<Vec<TicketSummary>> { fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
let mut tickets = Vec::new(); let mut tickets = Vec::new();
for dir in self.iter_ticket_dirs(filter)? { for dir in self.iter_ticket_dirs(filter)? {

View File

@ -13,10 +13,11 @@ use serde_json::{Value, json};
use crate::{ use crate::{
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, Ticket, TicketBackend, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
TicketIdOrSlug, TicketIntakeSummary, TicketRelationKind, TicketReview, TicketReviewResult, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
TicketStateChange, TicketSummary, TicketWorkflowState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@ -166,6 +167,160 @@ pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> Str
description 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<dyn TicketBackend + Send + Sync>,
record_language: Option<String>,
}
impl TicketToolBackend {
pub fn new<B>(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<LocalTicketBackend> 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<Vec<TicketSummary>> {
self.backend.list(filter)
}
fn show(&self, id: TicketIdOrSlug) -> TicketResult<Ticket> {
self.backend.show(id)
}
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
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<TicketRelation> {
self.backend.add_ticket_relation(id, relation)
}
fn query_ticket_relations(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<TicketRelationKind>,
) -> TicketResult<Vec<TicketRelation>> {
self.backend.query_ticket_relations(ticket, kind)
}
fn relation_view(&self, id: TicketIdOrSlug) -> TicketResult<TicketRelationView> {
self.backend.relation_view(id)
}
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> TicketResult<OrchestrationPlanRecord> {
self.backend.add_orchestration_plan_record(id, record)
}
fn query_orchestration_plan_records(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> TicketResult<Vec<OrchestrationPlanRecord>> {
self.backend.query_orchestration_plan_records(ticket, kind)
}
fn doctor(&self) -> TicketResult<TicketDoctorReport> {
self.backend.doctor()
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCreateParams { struct TicketCreateParams {
/// Ticket title. Must not be empty. /// Ticket title. Must not be empty.
@ -565,67 +720,67 @@ struct TicketDoctorOutput {
#[derive(Clone)] #[derive(Clone)]
struct TicketCreateTool { struct TicketCreateTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketListTool { struct TicketListTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketShowTool { struct TicketShowTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketCommentTool { struct TicketCommentTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketReviewTool { struct TicketReviewTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketIntakeReadyTool { struct TicketIntakeReadyTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketWorkflowStateTool { struct TicketWorkflowStateTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketCloseTool { struct TicketCloseTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketRelationRecordTool { struct TicketRelationRecordTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketRelationQueryTool { struct TicketRelationQueryTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketOrchestrationPlanRecordTool { struct TicketOrchestrationPlanRecordTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketOrchestrationPlanQueryTool { struct TicketOrchestrationPlanQueryTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[derive(Clone)] #[derive(Clone)]
struct TicketDoctorTool { struct TicketDoctorTool {
backend: LocalTicketBackend, backend: TicketToolBackend,
} }
#[async_trait] #[async_trait]
@ -1308,9 +1463,9 @@ fn json_output(summary: String, value: impl Serialize) -> ToolOutput {
} }
} }
fn tool_definition<T>(name: &'static str, backend: LocalTicketBackend) -> ToolDefinition fn tool_definition<T>(name: &'static str, backend: TicketToolBackend) -> ToolDefinition
where where
T: Tool + From<LocalTicketBackend> + 'static, T: Tool + From<TicketToolBackend> + 'static,
{ {
let description = ticket_tool_description(name, backend.record_language()); let description = ticket_tool_description(name, backend.record_language());
Arc::new(move || { Arc::new(move || {
@ -1355,8 +1510,8 @@ fn input_schema(name: &str) -> Value {
macro_rules! impl_from_backend { macro_rules! impl_from_backend {
($tool:ident) => { ($tool:ident) => {
impl From<LocalTicketBackend> for $tool { impl From<TicketToolBackend> for $tool {
fn from(backend: LocalTicketBackend) -> Self { fn from(backend: TicketToolBackend) -> Self {
Self { backend } Self { backend }
} }
} }
@ -1377,8 +1532,9 @@ impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool); impl_from_backend!(TicketDoctorTool);
/// Build all MVP Ticket tool definitions over one local backend root. /// Build all MVP Ticket tool definitions over the supplied backend.
pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> { pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
let backend = backend.into();
vec![ vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()), tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()), tool_definition::<TicketListTool>("TicketList", backend.clone()),
@ -1416,7 +1572,7 @@ mod tests {
tool tool
} }
fn tool_by_name(backend: LocalTicketBackend, name: &str) -> Arc<dyn Tool> { fn tool_by_name(backend: impl Into<TicketToolBackend>, name: &str) -> Arc<dyn Tool> {
ticket_tools(backend) ticket_tools(backend)
.into_iter() .into_iter()
.find_map(|definition| { .find_map(|definition| {
@ -1426,7 +1582,7 @@ mod tests {
.expect("tool exists") .expect("tool exists")
} }
fn tool_description_by_name(backend: LocalTicketBackend, name: &str) -> String { fn tool_description_by_name(backend: impl Into<TicketToolBackend>, name: &str) -> String {
ticket_tools(backend) ticket_tools(backend)
.into_iter() .into_iter()
.find_map(|definition| { .find_map(|definition| {
@ -2303,7 +2459,7 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let create = tool(tool_definition::<TicketCreateTool>( let create = tool(tool_definition::<TicketCreateTool>(
"TicketCreate", "TicketCreate",
backend(&temp), backend(&temp).into(),
)); ));
let _ = create; let _ = create;
} }

View File

@ -167,6 +167,12 @@ pub struct WorkingDirectoryStatus {
pub summary: WorkingDirectorySummary, 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. /// Canonical Runtime Worker creation request.
/// ///
/// Browser/product launch semantics are resolved by a backend before this /// Browser/product launch semantics are resolved by a backend before this
@ -188,6 +194,8 @@ pub struct CreateWorkerRequest {
pub working_directory_request: Option<WorkingDirectoryRequest>, pub working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryClaim>, pub working_directory: Option<WorkingDirectoryClaim>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_api: Option<WorkspaceApiRef>,
} }
/// Worker lifecycle status for the in-memory embedded runtime. /// Worker lifecycle status for the in-memory embedded runtime.

View File

@ -982,6 +982,7 @@ mod tests {
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
working_directory: None, working_directory: None,
workspace_api: None,
} }
} }
@ -1293,6 +1294,7 @@ mod ws_tests {
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
working_directory: None, working_directory: None,
workspace_api: None,
} }
} }

View File

@ -1842,6 +1842,7 @@ mod tests {
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
working_directory: None, working_directory: None,
workspace_api: None,
} }
} }

View File

@ -39,7 +39,7 @@ use tokio::sync::broadcast;
use worker::{ use worker::{
PromptLoader, Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle, PromptLoader, Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
WorkerWorkspaceContext, WorkspaceId, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
}; };
const DEFAULT_BACKEND_ID: &str = "worker-crate"; const DEFAULT_BACKEND_ID: &str = "worker-crate";
@ -240,10 +240,29 @@ impl ProfileRuntimeWorkerFactory {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum RuntimeWorkspaceBackendRef { enum RuntimeWorkspaceBackendRef {
None, None,
LocalFilesystem { root: PathBuf }, LocalFilesystem {
root: PathBuf,
},
Http {
workspace_id: String,
base_url: String,
},
} }
impl RuntimeWorkspaceBackendRef { 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 { fn from_working_directory(binding: Option<&WorkingDirectoryBinding>) -> Self {
match binding { match binding {
Some(binding) => Self::LocalFilesystem { Some(binding) => Self::LocalFilesystem {
@ -257,6 +276,13 @@ impl RuntimeWorkspaceBackendRef {
match self { match self {
Self::None => WorkerWorkspaceContext::no_workspace(), Self::None => WorkerWorkspaceContext::no_workspace(),
Self::LocalFilesystem { root } => local_workspace_context(root), 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); .unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref = let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref()); &request.request,
request.working_directory.as_ref(),
);
let workspace_context = workspace_backend_ref.worker_context(); let workspace_context = workspace_backend_ref.worker_context();
let selector = profile.as_deref().unwrap_or("builtin:default"); let selector = profile.as_deref().unwrap_or("builtin:default");
let archive = self let archive = self
@ -422,8 +450,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) )
}) })
.unwrap_or(WorkerFilesystemAuthority::None); .unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref = let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref()); &request.request,
request.working_directory.as_ref(),
);
let workspace_context = workspace_backend_ref.worker_context(); let workspace_context = workspace_backend_ref.worker_context();
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
@ -1187,6 +1217,7 @@ mod tests {
store_dir: PathBuf, store_dir: PathBuf,
worker_metadata_dir: PathBuf, worker_metadata_dir: PathBuf,
observed_cwds: Arc<Mutex<Vec<PathBuf>>>, observed_cwds: Arc<Mutex<Vec<PathBuf>>>,
observed_workspace_clients: Arc<Mutex<Vec<WorkspaceClient>>>,
} }
#[async_trait] #[async_trait]
@ -1232,10 +1263,15 @@ mod tests {
.as_ref() .as_ref()
.map(|binding| binding.root().to_path_buf()) .map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.cwd.clone()); .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(), request.working_directory.as_ref(),
); );
let workspace_context = workspace_backend_ref.worker_context(); 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 scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
@ -1354,6 +1390,7 @@ mod tests {
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
working_directory: None, working_directory: None,
workspace_api: None,
} }
} }
@ -1454,6 +1491,7 @@ mod tests {
let cwd = tempfile::tempdir().unwrap(); let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap(); let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new())); let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let observed_workspace_clients = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory { let factory = MockFactory {
client: client.clone(), client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(), runtime_base: runtime_base.path().to_path_buf(),
@ -1461,13 +1499,19 @@ mod tests {
store_dir: store.path().join("sessions"), store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"), worker_metadata_dir: store.path().join("workers"),
observed_cwds: observed_cwds.clone(), observed_cwds: observed_cwds.clone(),
observed_workspace_clients: observed_workspace_clients.clone(),
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap(); let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
runtime.store_config_bundle(test_bundle()).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 runtime
.send_input(&detail.worker_ref, WorkerInput::user("say hello")) .send_input(&detail.worker_ref, WorkerInput::user("say hello"))
@ -1495,6 +1539,10 @@ mod tests {
assert_eq!(client.captured.lock().unwrap().len(), 1); assert_eq!(client.captured.lock().unwrap().len(), 1);
assert!(observed_cwds.lock().unwrap().is_empty()); 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); let names = captured_tool_names(&client, 0);
for forbidden in core_filesystem_tool_names() { for forbidden in core_filesystem_tool_names() {
assert!( assert!(
@ -1519,6 +1567,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let store = tempfile::tempdir().unwrap(); let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new())); let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let observed_workspace_clients = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory { let factory = MockFactory {
client: client.clone(), client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(), runtime_base: runtime_base.path().to_path_buf(),
@ -1526,6 +1575,7 @@ mod tests {
store_dir: store.path().join("sessions"), store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"), worker_metadata_dir: store.path().join("workers"),
observed_cwds: observed_cwds.clone(), observed_cwds: observed_cwds.clone(),
observed_workspace_clients: observed_workspace_clients.clone(),
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()

View File

@ -33,7 +33,7 @@ use crate::spawn::tool::spawn_worker_tool;
use crate::ticket_event_notify::{ use crate::ticket_event_notify::{
TicketEventCompanionNotifyHook, companion_worker_name_for_workspace, TicketEventCompanionNotifyHook, companion_worker_name_for_workspace,
}; };
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult}; use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult, WorkspaceClient};
use protocol::{ use protocol::{
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
TurnResult, WorkerStatus, TurnResult, WorkerStatus,
@ -715,21 +715,35 @@ where
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
} }
}; };
// Ticket tools are typed operations over the currently checked-out work // Ticket tools are typed operations over the current workspace Ticket backend.
// tree. They require explicit local filesystem authority and must not // Runtime-hosted Workers prefer the workspace API URI carried by the
// use workspace identity as a cwd fallback. // Worker context; legacy/local Workers fall back to the checked-out worktree.
let ticket_cwd = local_filesystem let ticket_backend = match worker.workspace_client() {
.as_ref() WorkspaceClient::Http {
.map(|local| &local.cwd) workspace_id,
.ok_or_else(|| { base_url,
std::io::Error::new( } => crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceHttp {
std::io::ErrorKind::InvalidInput, workspace_id: workspace_id.clone(),
"ticket tools require local Worker filesystem authority", 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( feature_registry.add_module(
crate::feature::builtin::ticket::ticket_tools_feature_with_options( crate::feature::builtin::ticket::ticket_tools_feature_with_options(
ticket_cwd, ticket_backend,
feature_config.ticket.enabled.then_some(ticket_access), feature_config.ticket.enabled.then_some(ticket_access),
feature_config.ticket_orchestration.enabled, feature_config.ticket_orchestration.enabled,
), ),

View File

@ -7,12 +7,18 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use ticket::{ 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}, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{ tool::{
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_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<PathBuf> for TicketFeatureBackend {
fn from(root: PathBuf) -> Self {
Self::Local { root }
}
}
impl From<&Path> for TicketFeatureBackend {
fn from(root: &Path) -> Self {
Self::Local {
root: root.to_path_buf(),
}
}
}
impl From<&PathBuf> for TicketFeatureBackend {
fn from(root: &PathBuf) -> Self {
Self::Local { root: root.clone() }
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct TicketFeature { pub struct TicketFeature {
backend_root: PathBuf, backend: TicketFeatureBackend,
record_language: Option<String>, record_language: Option<String>,
config_error: Option<String>, config_error: Option<String>,
access: TicketFeatureAccess, access: TicketFeatureAccess,
@ -73,9 +110,23 @@ impl TicketFeature {
backend_root: impl Into<PathBuf>, backend_root: impl Into<PathBuf>,
access: Option<TicketFeatureAccess>, access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool, 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<TicketFeatureAccess>,
include_orchestration_tools: bool,
) -> Self { ) -> Self {
Self { Self {
backend_root: backend_root.into(), backend,
record_language: None, record_language: None,
config_error: None, config_error: None,
access: access.unwrap_or(TicketFeatureAccess::Lifecycle), access: access.unwrap_or(TicketFeatureAccess::Lifecycle),
@ -113,7 +164,9 @@ impl TicketFeature {
Err(error) => { Err(error) => {
let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle); let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle);
Self { Self {
backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), backend: TicketFeatureBackend::Local {
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
},
record_language: None, record_language: None,
config_error: Some(error.to_string()), config_error: Some(error.to_string()),
access: access_value, access: access_value,
@ -124,8 +177,11 @@ impl TicketFeature {
} }
} }
pub fn backend_root(&self) -> &Path { pub fn backend_root(&self) -> Option<&Path> {
&self.backend_root match &self.backend {
TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::WorkspaceHttp { .. } => None,
}
} }
pub fn access(&self) -> TicketFeatureAccess { pub fn access(&self) -> TicketFeatureAccess {
@ -150,8 +206,10 @@ impl TicketFeature {
} }
fn usable_backend_root(&self) -> Result<PathBuf, String> { fn usable_backend_root(&self) -> Result<PathBuf, String> {
let root = self let Some(root) = self.backend_root() else {
.backend_root return Err("ticket backend is not local filesystem backed".to_string());
};
let root = root
.canonicalize() .canonicalize()
.map_err(|error| format!("ticket backend root is not usable: {error}"))?; .map_err(|error| format!("ticket backend root is not usable: {error}"))?;
if !root.is_dir() { if !root.is_dir() {
@ -159,6 +217,41 @@ impl TicketFeature {
} }
Ok(root) Ok(root)
} }
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend {
TicketFeatureBackend::Local { root: _ } => {
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {reason}; root={} ",
self.backend_root()
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<non-local>".to_string())
)));
return None;
}
};
Some(
LocalTicketBackend::new(usable_root)
.with_record_language(self.record_language.as_deref())
.into(),
)
}
TicketFeatureBackend::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 { impl FeatureModule for TicketFeature {
@ -184,20 +277,9 @@ impl FeatureModule for TicketFeature {
))); )));
return Ok(()); return Ok(());
} }
let usable_root = match self.usable_backend_root() { let Some(backend) = self.tool_backend(context) else {
Ok(root) => root, return Ok(());
Err(reason) => {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {reason}; root={} ",
self.backend_root.display()
)));
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 allowed_tool_names = self.enabled_tool_names();
let mut tools = context.tools(); let mut tools = context.tools();
for definition in ticket_tools(backend) { 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<TicketBackendOperationResult> {
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::<TicketBackendHttpResponse>(&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<Vec<TicketSummary>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::List { filter }),
TicketBackendOperationResult::Tickets
)
}
fn show(&self, id: TicketIdOrSlug) -> TicketResult<Ticket> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::Show { id }),
TicketBackendOperationResult::Ticket
)
}
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
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<TicketRelation> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::AddTicketRelation { id, relation }),
TicketBackendOperationResult::Relation
)
}
fn query_ticket_relations(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<TicketRelationKind>,
) -> TicketResult<Vec<TicketRelation>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::QueryTicketRelations { ticket, kind }),
TicketBackendOperationResult::Relations
)
}
fn relation_view(&self, id: TicketIdOrSlug) -> TicketResult<TicketRelationView> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::RelationView { id }),
TicketBackendOperationResult::RelationView
)
}
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> TicketResult<OrchestrationPlanRecord> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::AddOrchestrationPlanRecord { id, record }),
TicketBackendOperationResult::OrchestrationPlanRecord
)
}
fn query_orchestration_plan_records(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> TicketResult<Vec<OrchestrationPlanRecord>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind }),
TicketBackendOperationResult::OrchestrationPlanRecords
)
}
fn doctor(&self) -> TicketResult<TicketDoctorReport> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::Doctor),
TicketBackendOperationResult::DoctorReport
)
}
}
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature { pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
TicketFeature::for_workspace(workspace) TicketFeature::for_workspace(workspace)
} }
@ -227,11 +578,11 @@ pub fn ticket_tools_feature_with_access(
} }
pub fn ticket_tools_feature_with_options( pub fn ticket_tools_feature_with_options(
workspace: impl AsRef<Path>, backend: impl Into<TicketFeatureBackend>,
access: Option<TicketFeatureAccess>, access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool, include_orchestration_tools: bool,
) -> TicketFeature { ) -> TicketFeature {
TicketFeature::for_workspace_with_options(workspace, access, include_orchestration_tools) TicketFeature::with_backend(backend.into(), access, include_orchestration_tools)
} }
#[cfg(test)] #[cfg(test)]
@ -239,6 +590,9 @@ mod tests {
use super::*; use super::*;
use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind}; use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind};
use crate::hook::HookRegistryBuilder; use crate::hook::HookRegistryBuilder;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use tempfile::TempDir; use tempfile::TempDir;
use ticket::tool::{ use ticket::tool::{
TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES, 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")); make_ticket_root(&temp.path().join("tickets"));
let feature = ticket_tools_feature(temp.path()); 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 pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default(); let mut hooks = HookRegistryBuilder::default();
@ -611,4 +968,41 @@ provider = "github"
assert!(!root.join("pending").exists()); assert!(!root.join("pending").exists());
assert!(!root.join("closed").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");
}
} }

View File

@ -135,6 +135,11 @@ pub enum WorkspaceIdError {
/// adapter code. /// adapter code.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceClient { 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 /// Runtime/host supplied a workspace API handle. The string is an opaque
/// diagnostic/backend kind, not an endpoint, path, or secret-bearing value. /// diagnostic/backend kind, not an endpoint, path, or secret-bearing value.
Available { kind: String }, Available { kind: String },
@ -147,6 +152,13 @@ impl WorkspaceClient {
Self::Available { kind: kind.into() } Self::Available { kind: kind.into() }
} }
pub fn http(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
Self::Http {
workspace_id: workspace_id.into(),
base_url: base_url.into(),
}
}
pub fn unavailable(reason: impl Into<String>) -> Self { pub fn unavailable(reason: impl Into<String>) -> Self {
Self::Unavailable { Self::Unavailable {
reason: reason.into(), reason: reason.into(),
@ -158,7 +170,7 @@ impl WorkspaceClient {
} }
pub fn is_available(&self) -> bool { pub fn is_available(&self) -> bool {
matches!(self, Self::Available { .. }) matches!(self, Self::Available { .. } | Self::Http { .. })
} }
} }

View File

@ -17,7 +17,7 @@ use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail, ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
}; };
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)] #[cfg(test)]
@ -1182,6 +1182,8 @@ impl RuntimeRegistry {
pub struct EmbeddedWorkerRuntime { pub struct EmbeddedWorkerRuntime {
runtime_id: String, runtime_id: String,
host_id: String, host_id: String,
workspace_id: String,
backend_base_url: Option<String>,
runtime: worker_runtime::Runtime, runtime: worker_runtime::Runtime,
execution_enabled: bool, execution_enabled: bool,
resource_broker: BackendResourceBroker, resource_broker: BackendResourceBroker,
@ -1234,10 +1236,18 @@ impl EmbeddedWorkerRuntime {
self self
} }
pub fn with_backend_base_url(mut self, backend_base_url: impl Into<String>) -> Self {
self.backend_base_url = Some(backend_base_url.into().trim_end_matches('/').to_string());
self
}
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self { pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
let workspace_id = workspace_id.as_ref().to_string();
Self { Self {
runtime_id: EMBEDDED_RUNTIME_ID.to_string(), 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, runtime,
execution_enabled: false, execution_enabled: false,
resource_broker: BackendResourceBroker::default(), resource_broker: BackendResourceBroker::default(),
@ -1556,6 +1566,13 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
initial_input: request.initial_input.clone(), initial_input: request.initial_input.clone(),
working_directory_request: request.resolved_working_directory_request.clone(), working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.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) { match self.runtime.create_worker(create_request) {
Ok(detail) => { Ok(detail) => {
@ -2392,6 +2409,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
initial_input: request.initial_input.clone(), initial_input: request.initial_input.clone(),
working_directory_request: request.resolved_working_directory_request.clone(), working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.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) { match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
Ok(response) => WorkerSpawnResult { Ok(response) => WorkerSpawnResult {

View File

@ -1,3 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -13,7 +14,10 @@ use chrono::{SecondsFormat, Utc};
use futures::StreamExt; use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet}; use ticket::{
LocalTicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
execute_ticket_backend_operation,
};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
@ -238,6 +242,14 @@ impl WorkspaceApi {
execution_backend, execution_backend,
) )
.map(|runtime| runtime.with_resource_broker(resource_broker.clone())) .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| { .map_err(|err| {
crate::Error::Store(format!("invalid embedded Worker backend: {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/tickets", get(list_tickets))
.route("/api/w/{workspace_id}/tickets", get(scoped_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/tickets/{id}", get(get_ticket))
.route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
.route("/api/objectives", get(list_objectives)) .route("/api/objectives", get(list_objectives))
@ -1226,6 +1242,26 @@ async fn scoped_get_ticket(
get_ticket(State(api), AxumPath(path.id)).await get_ticket(State(api), AxumPath(path.id)).await
} }
async fn scoped_ticket_backend_operation(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(operation): Json<TicketBackendOperation>,
) -> ApiResult<Json<TicketBackendHttpResponse>> {
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( async fn scoped_list_objectives(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -6265,6 +6301,7 @@ mod tests {
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
working_directory: None, working_directory: None,
workspace_api: None,
} }
} }