diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index edeeda6f..a65bacfc 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -422,12 +422,12 @@ fn list_schema() -> serde_json::Value { "type":"object", "additionalProperties": false, "properties":{ - "text":{"type":["string","null"]}, + "query":{"type":["string","null"]}, "states":{"type":"array","items":{"type":"string"},"default":[]}, "linked_ticket_id":{"type":["string","null"]}, "updated_after":{"type":["string","null"]}, "updated_before":{"type":["string","null"]}, - "sort":{"type":["string","null"],"enum":["updated_desc","title",null]}, + "sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]}, "limit":{"type":["integer","null"],"minimum":1,"maximum":100}, "cursor":{"type":["string","null"]} } @@ -510,7 +510,7 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value { #[derive(Debug, Serialize, Deserialize)] struct QueryObjectiveInput { - text: Option, + query: Option, #[serde(default)] states: Vec, linked_ticket_id: Option, diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 05cf6857..d0b5c8bb 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -69,33 +69,103 @@ impl WorkspaceTicketReadKind { } } +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketStateFilter { + Planning, + Ready, + Queued, + Inprogress, + Done, + Closed, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketEvidenceFilter { + ImplementationReport, + ImplementationReportAfterRescope, + MergeRequest, + Commit, + ApprovedReview, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketReviewFilter { + None, + Pending, + Approved, + RequestChanges, + UnresolvedChanges, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketAttentionFilter { + DoneNotClosed, + ImplementationReportNotClosed, + ReportAfterRescope, + UnresolvedReview, + MissingCommit, + Blocked, + Unblocked, + Ready, + AwaitingReview, + UnresolvedChanges, + StaleAfterRescope, + MissingEvidence, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketRelationFilter { + DependsOn, + Blocks, + Related, + Supersedes, + DuplicateOf, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +enum WorkspaceTicketSort { + Relevance, + UpdatedDesc, + CreatedDesc, + Priority, + Title, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] struct WorkspaceQueryTicketInput { /// Full-text match over Ticket title, item body, and bounded thread excerpts. - text: Option, + query: Option, /// Exact workflow states. Empty means every state. #[serde(default)] - states: Vec, + states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. #[serde(default)] event_kinds: Vec, /// Required evidence kinds: implementation_report, implementation_report_after_rescope, /// merge_request, commit, or approved_review. #[serde(default)] - evidence: Vec, - /// Current authoritative Merge Request review status. - review_status: Option, - /// Attention filters: blocked, ready, awaiting_review, unresolved_changes, - /// stale_after_rescope, or missing_evidence. + evidence: Vec, + /// Current authoritative Merge Request review status: none, pending, approved, + /// request_changes, or unresolved_changes. + review_status: Option, + /// Attention filters include done_not_closed, implementation_report_not_closed, + /// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked. #[serde(default)] - attention: Vec, + attention: Vec, related_ticket_id: Option, - relation_kind: Option, + relation_kind: Option, linked_objective_id: Option, updated_after: Option, updated_before: Option, - /// updated_desc (default), priority, or title. - sort: Option, + /// relevance (default when query is present), updated_desc, created_desc, + /// priority, or title. + sort: Option, /// Page size; bounded by the Backend to 1..=100. limit: Option, /// Opaque cursor returned by a prior QueryTicket page. @@ -306,14 +376,7 @@ impl TicketFeatureAccess { } } -const READ_ONLY_TOOL_NAMES: &[&str] = &[ - "QueryTicket", - "ShowTicket", - "TicketDependencyCheck", - "TicketDoctor", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", -]; +const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -337,12 +400,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketComment", "TicketQueue", "TicketClose", - "TicketDependencyCheck", - "TicketDoctor", "TicketRelationRecord", "TicketRelationRemove", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", ]; #[cfg(test)] @@ -353,10 +412,8 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", - "TicketDoctor", "TicketRelationRecord", "TicketRelationRemove", - "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", ]; @@ -364,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ "TicketWorkflowState", "TicketClose", + "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", + "TicketOrchestrationPlanQuery", ]; #[derive(Clone, Debug)] @@ -1232,10 +1291,30 @@ mod tests { assert!(query.input_schema["properties"]["evidence"].is_object()); assert!(query.input_schema["properties"]["attention"].is_object()); assert!(query.input_schema["properties"]["cursor"].is_object()); + let query_schema = serde_json::to_string(&query.input_schema).unwrap(); + assert!(query_schema.contains("done_not_closed")); + assert!(query_schema.contains("request_changes")); + assert!(query_schema.contains("created_desc")); + assert!( + query_schema.len() < 8_000, + "QueryTicket schema grew unexpectedly" + ); let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)(); assert_eq!(show.name, "ShowTicket"); assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); + assert_eq!(tool_names.len(), 9); + assert!( + tool_names.len() < 13, + "authoring catalog must stay below the prior broad catalog" + ); + let workflow_names = TicketFeatureAccess::workflow().tool_names(); + assert_eq!(workflow_names.len(), 10); + assert!( + workflow_names.len() < 12, + "workflow catalog must stay below the prior broad catalog" + ); + assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2); assert!(tool_names.contains(&"QueryTicket")); assert!(tool_names.contains(&"ShowTicket")); assert!(!tool_names.contains(&"TicketList")); diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 9814848b..6f9c76d1 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -10,12 +10,12 @@ use ticket::{ }; use crate::records::{ - ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveQueryItem, - ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest, - ObjectiveSummary, ProjectRecordList, QueryPage, TicketDetail, TicketEventDetail, - TicketEvidenceEvent, TicketEvidenceSummary, TicketMergeRequestSummary, TicketQueryItem, - TicketQueryRequest, TicketQueryResponse, TicketShowRequest, TicketSummary, summarize_body, - truncate_body, validate_project_id, + ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary, + ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, + ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, + TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, + TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, + TicketShowRequest, TicketSummary, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -163,6 +163,17 @@ impl SqliteWorkspaceAuthority { .into_iter() .map(|link| link.ticket_id) .collect::>(); + let linked_ticket_summaries = self + .list_tickets(1_000)? + .items + .into_iter() + .filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id)) + .map(|ticket| ObjectiveLinkedTicketSummary { + id: ticket.id, + title: ticket.title, + state: ticket.state, + }) + .collect::>(); let resources = self .store .list_objective_resources(&self.workspace_id, &record.objective_id)? @@ -207,6 +218,7 @@ impl SqliteWorkspaceAuthority { created_at: Some(record.created_at), updated_at: Some(record.updated_at), linked_tickets, + linked_ticket_summaries, resources, body, body_truncated, @@ -295,6 +307,14 @@ impl SqliteWorkspaceAuthority { .filter(|(_, event)| event.kind.as_str() == "implementation_report") .map(|(sequence, event)| ticket_evidence_event(sequence, event)) .collect::>(); + let current_assignment = self + .store + .get_current_ticket_worker_assignment(&self.workspace_id, id)? + .map(|assignment| TicketAssignmentSummary { + assignment_id: assignment.assignment_id, + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + }); let merge_request = self .merge_request_store .show_for_ticket(id) @@ -313,6 +333,7 @@ impl SqliteWorkspaceAuthority { id: ticket.meta.id, title: ticket.meta.title, state: ticket.meta.workflow_state.as_str().to_string(), + readiness: ticket.meta.readiness, priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, @@ -345,6 +366,7 @@ impl SqliteWorkspaceAuthority { relations: ticket.relations.into(), linked_objectives, implementation_reports, + current_assignment, merge_request, evidence, resolution: ticket @@ -388,7 +410,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { fn query_tickets(&self, query: TicketQueryRequest) -> Result { validate_ticket_query(&query)?; let limit = query.limit.unwrap_or(50).clamp(1, 100); - let sort = normalize_ticket_sort(query.sort.as_deref())?; + let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?; let cursor = query .cursor .as_deref() @@ -457,6 +479,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { id: record.objective_id, title: record.title, state: record.state, + created_at: Some(record.created_at), updated_at: Some(record.updated_at), summary: summarize_body(&record.body_md), linked_tickets, @@ -476,7 +499,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { query.updated_before.as_deref(), )?; let limit = query.limit.unwrap_or(50).clamp(1, 100); - let sort = normalize_objective_sort(query.sort.as_deref())?; + let sort = normalize_objective_sort(query.sort.as_deref(), query.query.is_some())?; let cursor = query .cursor .as_deref() @@ -506,7 +529,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { items.push(objective_query_item( objective, linked_tickets, - query.text.as_deref(), + query.query.as_deref(), )); } sort_objective_query_items(&mut items, sort); @@ -851,7 +874,9 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { #[derive(Clone, Copy)] enum TicketQuerySort { + Relevance, UpdatedDesc, + CreatedDesc, Priority, Title, } @@ -859,7 +884,9 @@ enum TicketQuerySort { impl std::fmt::Display for TicketQuerySort { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { + Self::Relevance => "relevance", Self::UpdatedDesc => "updated_desc", + Self::CreatedDesc => "created_desc", Self::Priority => "priority", Self::Title => "title", }) @@ -868,14 +895,18 @@ impl std::fmt::Display for TicketQuerySort { #[derive(Clone, Copy)] enum ObjectiveQuerySort { + Relevance, UpdatedDesc, + CreatedDesc, Title, } impl std::fmt::Display for ObjectiveQuerySort { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { + Self::Relevance => "relevance", Self::UpdatedDesc => "updated_desc", + Self::CreatedDesc => "created_desc", Self::Title => "title", }) } @@ -1031,7 +1062,13 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { for attention in &query.attention { if !matches!( attention.as_str(), - "blocked" + "done_not_closed" + | "implementation_report_not_closed" + | "report_after_rescope" + | "unresolved_review" + | "missing_commit" + | "blocked" + | "unblocked" | "ready" | "awaiting_review" | "unresolved_changes" @@ -1044,7 +1081,15 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { } } if let Some(status) = query.review_status.as_deref() - && !matches!(status, "pending" | "approved" | "changes_requested") + && !matches!( + status, + "none" + | "pending" + | "approved" + | "request_changes" + | "unresolved_changes" + | "changes_requested" + ) { return Err(Error::InvalidRecordId(format!( "unsupported review status `{status}`" @@ -1080,9 +1125,15 @@ fn validate_time_bounds(after: Option<&str>, before: Option<&str>) -> Result<()> Ok(()) } -fn normalize_ticket_sort(sort: Option<&str>) -> Result { - match sort.unwrap_or("updated_desc") { +fn normalize_ticket_sort(sort: Option<&str>, has_query: bool) -> Result { + match sort.unwrap_or(if has_query { + "relevance" + } else { + "updated_desc" + }) { + "relevance" => Ok(TicketQuerySort::Relevance), "updated_desc" => Ok(TicketQuerySort::UpdatedDesc), + "created_desc" => Ok(TicketQuerySort::CreatedDesc), "priority" => Ok(TicketQuerySort::Priority), "title" => Ok(TicketQuerySort::Title), other => Err(Error::InvalidRecordId(format!( @@ -1091,9 +1142,15 @@ fn normalize_ticket_sort(sort: Option<&str>) -> Result { } } -fn normalize_objective_sort(sort: Option<&str>) -> Result { - match sort.unwrap_or("updated_desc") { +fn normalize_objective_sort(sort: Option<&str>, has_query: bool) -> Result { + match sort.unwrap_or(if has_query { + "relevance" + } else { + "updated_desc" + }) { + "relevance" => Ok(ObjectiveQuerySort::Relevance), "updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc), + "created_desc" => Ok(ObjectiveQuerySort::CreatedDesc), "title" => Ok(ObjectiveQuerySort::Title), other => Err(Error::InvalidRecordId(format!( "unsupported Objective query sort `{other}`" @@ -1154,10 +1211,17 @@ fn ticket_matches_query( { return false; } - if let Some(review_status) = &query.review_status - && detail.evidence.review_status.as_ref() != Some(review_status) - { - return false; + if let Some(review_status) = &query.review_status { + let matches = match review_status.as_str() { + "none" => detail.evidence.review_status.is_none(), + "request_changes" | "unresolved_changes" => { + detail.evidence.review_status.as_deref() == Some("changes_requested") + } + status => detail.evidence.review_status.as_deref() == Some(status), + }; + if !matches { + return false; + } } if !query .evidence @@ -1179,7 +1243,15 @@ fn ticket_matches_query( .attention .iter() .all(|attention| match attention.as_str() { + "done_not_closed" => summary.state == "done", + "implementation_report_not_closed" => { + detail.evidence.has_implementation_report && summary.state != "closed" + } + "report_after_rescope" => detail.evidence.implementation_report_after_rescope, + "unresolved_review" => detail.evidence.unresolved_request_changes, + "missing_commit" => !detail.evidence.has_commit, "blocked" => !detail.relations.blockers.is_empty(), + "unblocked" => detail.relations.blockers.is_empty(), "ready" => summary.state == "ready" && detail.relations.blockers.is_empty(), "awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"), "unresolved_changes" => detail.evidence.unresolved_request_changes, @@ -1213,7 +1285,7 @@ fn ticket_matches_query( return false; } } - query.text.as_ref().is_none_or(|text| { + query.query.as_ref().is_none_or(|text| { let needle = text.to_lowercase(); summary.title.to_lowercase().contains(&needle) || detail.body.to_lowercase().contains(&needle) @@ -1234,7 +1306,7 @@ fn ticket_query_item( let mut matched_fields = Vec::new(); let mut snippet = None; let mut matching_event = None; - if let Some(text) = query.text.as_ref() { + if let Some(text) = query.query.as_ref() { let needle = text.to_lowercase(); if summary.title.to_lowercase().contains(&needle) { matched_fields.push("title".to_string()); @@ -1266,8 +1338,11 @@ fn ticket_query_item( id: summary.id, title: summary.title, state: summary.state, + readiness: detail.readiness.clone(), priority: summary.priority, + created_at: detail.created_at.clone(), updated_at: summary.updated_at, + item_revision: detail.item_revision.clone(), workspace_action_priority: summary.workspace_action_priority, matched_fields, snippet: snippet.map(|value| truncate_body(&value, 512).0), @@ -1279,6 +1354,8 @@ fn ticket_query_item( .collect(), relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), blocker_count: detail.relations.blockers.len(), + unresolved_blocker_count: detail.relations.blockers.len(), + unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), evidence: detail.evidence.clone(), merge_request: detail.merge_request.clone(), } @@ -1295,9 +1372,27 @@ fn matching_snippet(body: &str, text: &str) -> String { body[start..end].to_string() } +fn ticket_match_rank(item: &TicketQueryItem) -> usize { + if item.matched_fields.iter().any(|field| field == "title") { + 0 + } else if item.matched_fields.iter().any(|field| field == "body") { + 1 + } else if item.matched_fields.iter().any(|field| field == "event") { + 2 + } else { + 3 + } +} + fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String { match sort { + TicketQuerySort::Relevance => format!( + "{}|{}", + ticket_match_rank(item), + item.updated_at.as_deref().unwrap_or("") + ), TicketQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(), + TicketQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(), TicketQuerySort::Priority => format!( "{}|{}", match item.workspace_action_priority.as_str() { @@ -1313,10 +1408,18 @@ fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String { fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort) { items.sort_by(|left, right| match sort { + TicketQuerySort::Relevance => ticket_match_rank(left) + .cmp(&ticket_match_rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)), TicketQuerySort::UpdatedDesc => right .updated_at .cmp(&left.updated_at) .then_with(|| left.id.cmp(&right.id)), + TicketQuerySort::CreatedDesc => right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)), TicketQuerySort::Priority => { let rank = |item: &TicketQueryItem| match item.workspace_action_priority.as_str() { "ready_for_queue" => 0, @@ -1347,9 +1450,11 @@ fn ticket_item_after_cursor( ) -> bool { let key = ticket_sort_key(item, sort); match sort { - TicketQuerySort::UpdatedDesc => key < cursor.0 || (key == cursor.0 && item.id > cursor.1), + TicketQuerySort::UpdatedDesc | TicketQuerySort::CreatedDesc => { + key < cursor.0 || (key == cursor.0 && item.id > cursor.1) + } TicketQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), - TicketQuerySort::Priority => { + TicketQuerySort::Relevance | TicketQuerySort::Priority => { let (rank, updated) = key.split_once('|').unwrap_or(("9", "")); let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", "")); rank > cursor_rank @@ -1375,7 +1480,7 @@ fn objective_matches_query(objective: &ObjectiveSummary, query: &ObjectiveQueryR { return false; } - query.text.as_ref().is_none_or(|text| { + query.query.as_ref().is_none_or(|text| { let needle = text.to_lowercase(); objective.title.to_lowercase().contains(&needle) || objective.summary.to_lowercase().contains(&needle) @@ -1404,6 +1509,7 @@ fn objective_query_item( id: objective.id, title: objective.title, state: objective.state, + created_at: objective.created_at, updated_at: objective.updated_at, matched_fields, snippet, @@ -1412,19 +1518,43 @@ fn objective_query_item( } } +fn objective_match_rank(item: &ObjectiveQueryItem) -> usize { + if item.matched_fields.iter().any(|field| field == "title") { + 0 + } else if item.matched_fields.iter().any(|field| field == "body") { + 1 + } else { + 2 + } +} + fn objective_sort_key(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { match sort { + ObjectiveQuerySort::Relevance => format!( + "{}|{}", + objective_match_rank(item), + item.updated_at.as_deref().unwrap_or("") + ), ObjectiveQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(), + ObjectiveQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(), ObjectiveQuerySort::Title => item.title.to_lowercase(), } } fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQuerySort) { items.sort_by(|left, right| match sort { + ObjectiveQuerySort::Relevance => objective_match_rank(left) + .cmp(&objective_match_rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)), ObjectiveQuerySort::UpdatedDesc => right .updated_at .cmp(&left.updated_at) .then_with(|| left.id.cmp(&right.id)), + ObjectiveQuerySort::CreatedDesc => right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)), ObjectiveQuerySort::Title => left .title .to_lowercase() @@ -1444,9 +1574,17 @@ fn objective_item_after_cursor( ) -> bool { let key = objective_sort_key(item, sort); match sort { - ObjectiveQuerySort::UpdatedDesc => { + ObjectiveQuerySort::UpdatedDesc | ObjectiveQuerySort::CreatedDesc => { key < cursor.0 || (key == cursor.0 && item.id > cursor.1) } + ObjectiveQuerySort::Relevance => { + let (rank, updated) = key.split_once('|').unwrap_or(("9", "")); + let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", "")); + rank > cursor_rank + || (rank == cursor_rank + && (updated < cursor_updated + || (updated == cursor_updated && item.id > cursor.1))) + } ObjectiveQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), } } @@ -1696,9 +1834,10 @@ mod tests { assert_eq!(ticket.event_page.returned, ticket.events.len()); let ticket_query = authority .query_tickets(TicketQueryRequest { - text: Some("Ticket body".to_string()), + query: Some("Ticket body".to_string()), states: vec!["ready".to_string()], linked_objective_id: Some("00000000001J3".to_string()), + attention: vec!["unblocked".to_string(), "missing_commit".to_string()], limit: Some(1), ..TicketQueryRequest::default() }) @@ -1740,9 +1879,11 @@ mod tests { let objective = authority.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); assert!(!objective.revision.is_empty()); + assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); + assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); let objective_query = authority .query_objectives(ObjectiveQueryRequest { - text: Some("Control plane".to_string()), + query: Some("Control plane".to_string()), linked_ticket_id: Some("00000000001J2".to_string()), limit: Some(1), ..ObjectiveQueryRequest::default() diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index ec7b2249..25a4c293 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -49,6 +49,7 @@ pub struct TicketDetail { pub id: String, pub title: String, pub state: String, + pub readiness: Option, pub priority: String, pub created_at: Option, pub updated_at: Option, @@ -69,6 +70,7 @@ pub struct TicketDetail { pub relations: TicketRelationView, pub linked_objectives: Vec, pub implementation_reports: Vec, + pub current_assignment: Option, pub merge_request: Option, pub evidence: TicketEvidenceSummary, pub resolution: Option, @@ -225,6 +227,14 @@ pub struct TicketEvidenceEvent { pub excerpt: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketAssignmentSummary { + pub assignment_id: String, + pub runtime_id: String, + pub worker_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketMergeRequestSummary { @@ -257,7 +267,7 @@ pub struct TicketEvidenceSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryRequest { - pub text: Option, + pub query: Option, #[serde(default)] pub states: Vec, #[serde(default)] @@ -283,8 +293,11 @@ pub struct TicketQueryItem { pub id: String, pub title: String, pub state: String, + pub readiness: Option, pub priority: String, + pub created_at: Option, pub updated_at: Option, + pub item_revision: String, pub workspace_action_priority: String, pub matched_fields: Vec, pub snippet: Option, @@ -292,6 +305,8 @@ pub struct TicketQueryItem { pub linked_objective_ids: Vec, pub relation_count: usize, pub blocker_count: usize, + pub unresolved_blocker_count: usize, + pub unresolved_review_count: usize, pub evidence: TicketEvidenceSummary, pub merge_request: Option, } @@ -313,7 +328,7 @@ pub struct TicketShowRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ObjectiveQueryRequest { - pub text: Option, + pub query: Option, #[serde(default)] pub states: Vec, pub linked_ticket_id: Option, @@ -329,6 +344,7 @@ pub struct ObjectiveQueryItem { pub id: String, pub title: String, pub state: String, + pub created_at: Option, pub updated_at: Option, pub matched_fields: Vec, pub snippet: Option, @@ -358,11 +374,19 @@ pub struct ObjectiveEventDetail { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveLinkedTicketSummary { + pub id: String, + pub title: String, + pub state: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, pub title: String, pub state: String, + pub created_at: Option, pub updated_at: Option, pub summary: String, pub linked_tickets: Vec, @@ -378,6 +402,7 @@ pub struct ObjectiveDetail { pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, + pub linked_ticket_summaries: Vec, pub resources: Vec, pub body: String, pub body_truncated: bool, @@ -407,6 +432,7 @@ pub fn ticket_api_typescript() -> String { TicketEventDetail::decl(&config), ObjectiveLinkSummary::decl(&config), TicketEvidenceEvent::decl(&config), + TicketAssignmentSummary::decl(&config), TicketMergeRequestSummary::decl(&config), TicketEvidenceSummary::decl(&config), TicketQueryRequest::decl(&config), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3e316485..2367977b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -17799,7 +17799,7 @@ mod tests { "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"), Some(json!({ - "text": "Objective body", + "query": "Objective body", "linked_ticket_id": "00000000001J2", "limit": 1 })), diff --git a/docs/development/work-items.md b/docs/development/work-items.md index f2266f3d..8e096fea 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -41,8 +41,8 @@ Workers with the Ticket built-in feature can use typed Ticket tools: - `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input. - `TicketClose` - `TicketRelationRecord` -- `TicketRelationQuery` -- `TicketDoctor` + +Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics. These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`. diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 7beb97f9..8ebce8e5 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -61,6 +61,12 @@ export type TicketEvidenceEvent = { excerpt: string; }; +export type TicketAssignmentSummary = { + assignment_id: string; + runtime_id: string; + worker_id: string; +}; + export type TicketMergeRequestSummary = { merge_request_id: string; state: string; @@ -87,7 +93,7 @@ export type TicketEvidenceSummary = { }; export type TicketQueryRequest = { - text: string | null; + query: string | null; states: Array; event_kinds: Array; evidence: Array; @@ -107,8 +113,11 @@ export type TicketQueryItem = { id: string; title: string; state: string; + readiness: string | null; priority: string; + created_at: string | null; updated_at: string | null; + item_revision: string; workspace_action_priority: string; matched_fields: Array; snippet: string | null; @@ -116,6 +125,8 @@ export type TicketQueryItem = { linked_objective_ids: Array; relation_count: number; blocker_count: number; + unresolved_blocker_count: number; + unresolved_review_count: number; evidence: TicketEvidenceSummary; merge_request: TicketMergeRequestSummary | null; }; @@ -174,6 +185,7 @@ export type TicketDetail = { id: string; title: string; state: string; + readiness: string | null; priority: string; created_at: string | null; updated_at: string | null; @@ -194,6 +206,7 @@ export type TicketDetail = { relations: TicketRelationView; linked_objectives: Array; implementation_reports: Array; + current_assignment: TicketAssignmentSummary | null; merge_request: TicketMergeRequestSummary | null; evidence: TicketEvidenceSummary; resolution: string | null;