feat: route ticket tools through workspace backend
This commit is contained in:
parent
66abd53e51
commit
983ffe6017
|
|
@ -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)? {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1842,6 +1842,7 @@ mod tests {
|
|||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
working_directory: None,
|
||||
workspace_api: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { .. })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user