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 {
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<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 {
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<String> 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<String> 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<String>,
@ -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<String>,
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<String>,
@ -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<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 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 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<TicketRelation>,
}
#[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<TicketRelation>,
pub incoming: Vec<DerivedTicketRelation>,
@ -702,7 +705,7 @@ pub struct AcceptedOrchestrationPlan {
pub role_plan: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewOrchestrationPlanRecord {
pub kind: OrchestrationPlanKind,
pub related_ticket: Option<String>,
@ -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<String, String>,
}
#[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<String>,
}
#[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<TicketSummary>,
pub invalid_records: Vec<TicketInvalidRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketPartial {
pub ticket: Ticket,
pub invalid_records: Vec<TicketInvalidRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketDocument {
pub body: MarkdownText,
pub raw_frontmatter: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketEvent {
pub kind: TicketEventKind,
pub author: Option<String>,
@ -805,13 +808,13 @@ pub struct TicketEvent {
pub attributes: BTreeMap<String, String>,
}
#[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<MarkdownText>,
}
#[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<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketDoctorReport {
pub diagnostics: Vec<TicketDoctorDiagnostic>,
}
@ -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<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>;
@ -915,6 +919,195 @@ pub trait TicketBackend {
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)]
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<Vec<TicketSummary>> {
let mut tickets = Vec::new();
for dir in self.iter_ticket_dirs(filter)? {

View File

@ -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<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)]
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<T>(name: &'static str, backend: LocalTicketBackend) -> ToolDefinition
fn tool_definition<T>(name: &'static str, backend: TicketToolBackend) -> ToolDefinition
where
T: Tool + From<LocalTicketBackend> + 'static,
T: Tool + From<TicketToolBackend> + '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<LocalTicketBackend> for $tool {
fn from(backend: LocalTicketBackend) -> Self {
impl From<TicketToolBackend> 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<ToolDefinition> {
/// Build all MVP Ticket tool definitions over the supplied backend.
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
let backend = backend.into();
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()),
@ -1416,7 +1572,7 @@ mod tests {
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)
.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<TicketToolBackend>, 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::<TicketCreateTool>(
"TicketCreate",
backend(&temp),
backend(&temp).into(),
));
let _ = create;
}

View File

@ -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<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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.

View File

@ -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,
}
}

View File

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

View File

@ -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<Mutex<Vec<PathBuf>>>,
observed_workspace_clients: Arc<Mutex<Vec<WorkspaceClient>>>,
}
#[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()

View File

@ -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,
),

View File

@ -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<PathBuf> for TicketFeatureBackend {
fn from(root: PathBuf) -> Self {
Self::Local { root }
}
}
impl From<&Path> for TicketFeatureBackend {
fn from(root: &Path) -> Self {
Self::Local {
root: root.to_path_buf(),
}
}
}
impl From<&PathBuf> for TicketFeatureBackend {
fn from(root: &PathBuf) -> Self {
Self::Local { root: root.clone() }
}
}
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend_root: PathBuf,
backend: TicketFeatureBackend,
record_language: Option<String>,
config_error: Option<String>,
access: TicketFeatureAccess,
@ -73,9 +110,23 @@ impl TicketFeature {
backend_root: impl Into<PathBuf>,
access: Option<TicketFeatureAccess>,
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 {
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<PathBuf, String> {
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<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 {
@ -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<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 {
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<Path>,
backend: impl Into<TicketFeatureBackend>,
access: Option<TicketFeatureAccess>,
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");
}
}

View File

@ -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<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 {
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 { .. })
}
}

View File

@ -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<String>,
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<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 {
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 {

View File

@ -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<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(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -6265,6 +6301,7 @@ mod tests {
initial_input: None,
working_directory_request: None,
working_directory: None,
workspace_api: None,
}
}