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