fix: align read projections with query contracts

This commit is contained in:
2026-08-17 05:37:06 +09:00
parent 4964583868
commit 977c8e7b21
7 changed files with 320 additions and 61 deletions
@@ -422,12 +422,12 @@ fn list_schema() -> serde_json::Value {
"type":"object", "type":"object",
"additionalProperties": false, "additionalProperties": false,
"properties":{ "properties":{
"text":{"type":["string","null"]}, "query":{"type":["string","null"]},
"states":{"type":"array","items":{"type":"string"},"default":[]}, "states":{"type":"array","items":{"type":"string"},"default":[]},
"linked_ticket_id":{"type":["string","null"]}, "linked_ticket_id":{"type":["string","null"]},
"updated_after":{"type":["string","null"]}, "updated_after":{"type":["string","null"]},
"updated_before":{"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}, "limit":{"type":["integer","null"],"minimum":1,"maximum":100},
"cursor":{"type":["string","null"]} "cursor":{"type":["string","null"]}
} }
@@ -510,7 +510,7 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct QueryObjectiveInput { struct QueryObjectiveInput {
text: Option<String>, query: Option<String>,
#[serde(default)] #[serde(default)]
states: Vec<String>, states: Vec<String>,
linked_ticket_id: Option<String>, linked_ticket_id: Option<String>,
+104 -25
View File
@@ -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)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceQueryTicketInput { struct WorkspaceQueryTicketInput {
/// Full-text match over Ticket title, item body, and bounded thread excerpts. /// Full-text match over Ticket title, item body, and bounded thread excerpts.
text: Option<String>, query: Option<String>,
/// Exact workflow states. Empty means every state. /// Exact workflow states. Empty means every state.
#[serde(default)] #[serde(default)]
states: Vec<String>, states: Vec<WorkspaceTicketStateFilter>,
/// Exact typed event kinds that must occur in the bounded thread window. /// Exact typed event kinds that must occur in the bounded thread window.
#[serde(default)] #[serde(default)]
event_kinds: Vec<String>, event_kinds: Vec<String>,
/// Required evidence kinds: implementation_report, implementation_report_after_rescope, /// Required evidence kinds: implementation_report, implementation_report_after_rescope,
/// merge_request, commit, or approved_review. /// merge_request, commit, or approved_review.
#[serde(default)] #[serde(default)]
evidence: Vec<String>, evidence: Vec<WorkspaceTicketEvidenceFilter>,
/// Current authoritative Merge Request review status. /// Current authoritative Merge Request review status: none, pending, approved,
review_status: Option<String>, /// request_changes, or unresolved_changes.
/// Attention filters: blocked, ready, awaiting_review, unresolved_changes, review_status: Option<WorkspaceTicketReviewFilter>,
/// stale_after_rescope, or missing_evidence. /// Attention filters include done_not_closed, implementation_report_not_closed,
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked.
#[serde(default)] #[serde(default)]
attention: Vec<String>, attention: Vec<WorkspaceTicketAttentionFilter>,
related_ticket_id: Option<String>, related_ticket_id: Option<String>,
relation_kind: Option<String>, relation_kind: Option<WorkspaceTicketRelationFilter>,
linked_objective_id: Option<String>, linked_objective_id: Option<String>,
updated_after: Option<String>, updated_after: Option<String>,
updated_before: Option<String>, updated_before: Option<String>,
/// updated_desc (default), priority, or title. /// relevance (default when query is present), updated_desc, created_desc,
sort: Option<String>, /// priority, or title.
sort: Option<WorkspaceTicketSort>,
/// Page size; bounded by the Backend to 1..=100. /// Page size; bounded by the Backend to 1..=100.
limit: Option<usize>, limit: Option<usize>,
/// Opaque cursor returned by a prior QueryTicket page. /// Opaque cursor returned by a prior QueryTicket page.
@@ -306,14 +376,7 @@ impl TicketFeatureAccess {
} }
} }
const READ_ONLY_TOOL_NAMES: &[&str] = &[ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
const AUTHORING_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate", "TicketCreate",
@@ -337,12 +400,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketComment", "TicketComment",
"TicketQueue", "TicketQueue",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
]; ];
#[cfg(test)] #[cfg(test)]
@@ -353,10 +412,8 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck", "TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
]; ];
@@ -364,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationRemove", "TicketRelationRemove",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
]; ];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -1232,10 +1291,30 @@ mod tests {
assert!(query.input_schema["properties"]["evidence"].is_object()); assert!(query.input_schema["properties"]["evidence"].is_object());
assert!(query.input_schema["properties"]["attention"].is_object()); assert!(query.input_schema["properties"]["attention"].is_object());
assert!(query.input_schema["properties"]["cursor"].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)(); let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
assert_eq!(show.name, "ShowTicket"); assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object()); assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); 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(&"QueryTicket"));
assert!(tool_names.contains(&"ShowTicket")); assert!(tool_names.contains(&"ShowTicket"));
assert!(!tool_names.contains(&"TicketList")); assert!(!tool_names.contains(&"TicketList"));
+168 -27
View File
@@ -10,12 +10,12 @@ use ticket::{
}; };
use crate::records::{ use crate::records::{
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveQueryItem, ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary,
ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest, ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
ObjectiveSummary, ProjectRecordList, QueryPage, TicketDetail, TicketEventDetail, ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
TicketEvidenceEvent, TicketEvidenceSummary, TicketMergeRequestSummary, TicketQueryItem, TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
TicketQueryRequest, TicketQueryResponse, TicketShowRequest, TicketSummary, summarize_body, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse,
truncate_body, validate_project_id, TicketShowRequest, TicketSummary, summarize_body, truncate_body, validate_project_id,
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -163,6 +163,17 @@ impl SqliteWorkspaceAuthority {
.into_iter() .into_iter()
.map(|link| link.ticket_id) .map(|link| link.ticket_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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::<Vec<_>>();
let resources = self let resources = self
.store .store
.list_objective_resources(&self.workspace_id, &record.objective_id)? .list_objective_resources(&self.workspace_id, &record.objective_id)?
@@ -207,6 +218,7 @@ impl SqliteWorkspaceAuthority {
created_at: Some(record.created_at), created_at: Some(record.created_at),
updated_at: Some(record.updated_at), updated_at: Some(record.updated_at),
linked_tickets, linked_tickets,
linked_ticket_summaries,
resources, resources,
body, body,
body_truncated, body_truncated,
@@ -295,6 +307,14 @@ impl SqliteWorkspaceAuthority {
.filter(|(_, event)| event.kind.as_str() == "implementation_report") .filter(|(_, event)| event.kind.as_str() == "implementation_report")
.map(|(sequence, event)| ticket_evidence_event(sequence, event)) .map(|(sequence, event)| ticket_evidence_event(sequence, event))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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 let merge_request = self
.merge_request_store .merge_request_store
.show_for_ticket(id) .show_for_ticket(id)
@@ -313,6 +333,7 @@ impl SqliteWorkspaceAuthority {
id: ticket.meta.id, id: ticket.meta.id,
title: ticket.meta.title, title: ticket.meta.title,
state: ticket.meta.workflow_state.as_str().to_string(), state: ticket.meta.workflow_state.as_str().to_string(),
readiness: ticket.meta.readiness,
priority: ticket.meta.priority, priority: ticket.meta.priority,
created_at: ticket.meta.created_at, created_at: ticket.meta.created_at,
updated_at: ticket.meta.updated_at, updated_at: ticket.meta.updated_at,
@@ -345,6 +366,7 @@ impl SqliteWorkspaceAuthority {
relations: ticket.relations.into(), relations: ticket.relations.into(),
linked_objectives, linked_objectives,
implementation_reports, implementation_reports,
current_assignment,
merge_request, merge_request,
evidence, evidence,
resolution: ticket resolution: ticket
@@ -388,7 +410,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> { fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> {
validate_ticket_query(&query)?; validate_ticket_query(&query)?;
let limit = query.limit.unwrap_or(50).clamp(1, 100); 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 let cursor = query
.cursor .cursor
.as_deref() .as_deref()
@@ -457,6 +479,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
id: record.objective_id, id: record.objective_id,
title: record.title, title: record.title,
state: record.state, state: record.state,
created_at: Some(record.created_at),
updated_at: Some(record.updated_at), updated_at: Some(record.updated_at),
summary: summarize_body(&record.body_md), summary: summarize_body(&record.body_md),
linked_tickets, linked_tickets,
@@ -476,7 +499,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
query.updated_before.as_deref(), query.updated_before.as_deref(),
)?; )?;
let limit = query.limit.unwrap_or(50).clamp(1, 100); 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 let cursor = query
.cursor .cursor
.as_deref() .as_deref()
@@ -506,7 +529,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
items.push(objective_query_item( items.push(objective_query_item(
objective, objective,
linked_tickets, linked_tickets,
query.text.as_deref(), query.query.as_deref(),
)); ));
} }
sort_objective_query_items(&mut items, sort); sort_objective_query_items(&mut items, sort);
@@ -851,7 +874,9 @@ impl MemoryAuthority for SqliteWorkspaceAuthority {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum TicketQuerySort { enum TicketQuerySort {
Relevance,
UpdatedDesc, UpdatedDesc,
CreatedDesc,
Priority, Priority,
Title, Title,
} }
@@ -859,7 +884,9 @@ enum TicketQuerySort {
impl std::fmt::Display for TicketQuerySort { impl std::fmt::Display for TicketQuerySort {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self { formatter.write_str(match self {
Self::Relevance => "relevance",
Self::UpdatedDesc => "updated_desc", Self::UpdatedDesc => "updated_desc",
Self::CreatedDesc => "created_desc",
Self::Priority => "priority", Self::Priority => "priority",
Self::Title => "title", Self::Title => "title",
}) })
@@ -868,14 +895,18 @@ impl std::fmt::Display for TicketQuerySort {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum ObjectiveQuerySort { enum ObjectiveQuerySort {
Relevance,
UpdatedDesc, UpdatedDesc,
CreatedDesc,
Title, Title,
} }
impl std::fmt::Display for ObjectiveQuerySort { impl std::fmt::Display for ObjectiveQuerySort {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self { formatter.write_str(match self {
Self::Relevance => "relevance",
Self::UpdatedDesc => "updated_desc", Self::UpdatedDesc => "updated_desc",
Self::CreatedDesc => "created_desc",
Self::Title => "title", Self::Title => "title",
}) })
} }
@@ -1031,7 +1062,13 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
for attention in &query.attention { for attention in &query.attention {
if !matches!( if !matches!(
attention.as_str(), attention.as_str(),
"blocked" "done_not_closed"
| "implementation_report_not_closed"
| "report_after_rescope"
| "unresolved_review"
| "missing_commit"
| "blocked"
| "unblocked"
| "ready" | "ready"
| "awaiting_review" | "awaiting_review"
| "unresolved_changes" | "unresolved_changes"
@@ -1044,7 +1081,15 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
} }
} }
if let Some(status) = query.review_status.as_deref() 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!( return Err(Error::InvalidRecordId(format!(
"unsupported review status `{status}`" "unsupported review status `{status}`"
@@ -1080,9 +1125,15 @@ fn validate_time_bounds(after: Option<&str>, before: Option<&str>) -> Result<()>
Ok(()) Ok(())
} }
fn normalize_ticket_sort(sort: Option<&str>) -> Result<TicketQuerySort> { fn normalize_ticket_sort(sort: Option<&str>, has_query: bool) -> Result<TicketQuerySort> {
match sort.unwrap_or("updated_desc") { match sort.unwrap_or(if has_query {
"relevance"
} else {
"updated_desc"
}) {
"relevance" => Ok(TicketQuerySort::Relevance),
"updated_desc" => Ok(TicketQuerySort::UpdatedDesc), "updated_desc" => Ok(TicketQuerySort::UpdatedDesc),
"created_desc" => Ok(TicketQuerySort::CreatedDesc),
"priority" => Ok(TicketQuerySort::Priority), "priority" => Ok(TicketQuerySort::Priority),
"title" => Ok(TicketQuerySort::Title), "title" => Ok(TicketQuerySort::Title),
other => Err(Error::InvalidRecordId(format!( other => Err(Error::InvalidRecordId(format!(
@@ -1091,9 +1142,15 @@ fn normalize_ticket_sort(sort: Option<&str>) -> Result<TicketQuerySort> {
} }
} }
fn normalize_objective_sort(sort: Option<&str>) -> Result<ObjectiveQuerySort> { fn normalize_objective_sort(sort: Option<&str>, has_query: bool) -> Result<ObjectiveQuerySort> {
match sort.unwrap_or("updated_desc") { match sort.unwrap_or(if has_query {
"relevance"
} else {
"updated_desc"
}) {
"relevance" => Ok(ObjectiveQuerySort::Relevance),
"updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc), "updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc),
"created_desc" => Ok(ObjectiveQuerySort::CreatedDesc),
"title" => Ok(ObjectiveQuerySort::Title), "title" => Ok(ObjectiveQuerySort::Title),
other => Err(Error::InvalidRecordId(format!( other => Err(Error::InvalidRecordId(format!(
"unsupported Objective query sort `{other}`" "unsupported Objective query sort `{other}`"
@@ -1154,10 +1211,17 @@ fn ticket_matches_query(
{ {
return false; return false;
} }
if let Some(review_status) = &query.review_status if let Some(review_status) = &query.review_status {
&& detail.evidence.review_status.as_ref() != Some(review_status) let matches = match review_status.as_str() {
{ "none" => detail.evidence.review_status.is_none(),
return false; "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 if !query
.evidence .evidence
@@ -1179,7 +1243,15 @@ fn ticket_matches_query(
.attention .attention
.iter() .iter()
.all(|attention| match attention.as_str() { .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(), "blocked" => !detail.relations.blockers.is_empty(),
"unblocked" => detail.relations.blockers.is_empty(),
"ready" => summary.state == "ready" && detail.relations.blockers.is_empty(), "ready" => summary.state == "ready" && detail.relations.blockers.is_empty(),
"awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"), "awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"),
"unresolved_changes" => detail.evidence.unresolved_request_changes, "unresolved_changes" => detail.evidence.unresolved_request_changes,
@@ -1213,7 +1285,7 @@ fn ticket_matches_query(
return false; return false;
} }
} }
query.text.as_ref().is_none_or(|text| { query.query.as_ref().is_none_or(|text| {
let needle = text.to_lowercase(); let needle = text.to_lowercase();
summary.title.to_lowercase().contains(&needle) summary.title.to_lowercase().contains(&needle)
|| detail.body.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 matched_fields = Vec::new();
let mut snippet = None; let mut snippet = None;
let mut matching_event = 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(); let needle = text.to_lowercase();
if summary.title.to_lowercase().contains(&needle) { if summary.title.to_lowercase().contains(&needle) {
matched_fields.push("title".to_string()); matched_fields.push("title".to_string());
@@ -1266,8 +1338,11 @@ fn ticket_query_item(
id: summary.id, id: summary.id,
title: summary.title, title: summary.title,
state: summary.state, state: summary.state,
readiness: detail.readiness.clone(),
priority: summary.priority, priority: summary.priority,
created_at: detail.created_at.clone(),
updated_at: summary.updated_at, updated_at: summary.updated_at,
item_revision: detail.item_revision.clone(),
workspace_action_priority: summary.workspace_action_priority, workspace_action_priority: summary.workspace_action_priority,
matched_fields, matched_fields,
snippet: snippet.map(|value| truncate_body(&value, 512).0), snippet: snippet.map(|value| truncate_body(&value, 512).0),
@@ -1279,6 +1354,8 @@ fn ticket_query_item(
.collect(), .collect(),
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
blocker_count: detail.relations.blockers.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(), evidence: detail.evidence.clone(),
merge_request: detail.merge_request.clone(), merge_request: detail.merge_request.clone(),
} }
@@ -1295,9 +1372,27 @@ fn matching_snippet(body: &str, text: &str) -> String {
body[start..end].to_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 { fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String {
match sort { 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::UpdatedDesc => item.updated_at.clone().unwrap_or_default(),
TicketQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(),
TicketQuerySort::Priority => format!( TicketQuerySort::Priority => format!(
"{}|{}", "{}|{}",
match item.workspace_action_priority.as_str() { 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) { fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort) {
items.sort_by(|left, right| match sort { 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 TicketQuerySort::UpdatedDesc => right
.updated_at .updated_at
.cmp(&left.updated_at) .cmp(&left.updated_at)
.then_with(|| left.id.cmp(&right.id)), .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 => { TicketQuerySort::Priority => {
let rank = |item: &TicketQueryItem| match item.workspace_action_priority.as_str() { let rank = |item: &TicketQueryItem| match item.workspace_action_priority.as_str() {
"ready_for_queue" => 0, "ready_for_queue" => 0,
@@ -1347,9 +1450,11 @@ fn ticket_item_after_cursor(
) -> bool { ) -> bool {
let key = ticket_sort_key(item, sort); let key = ticket_sort_key(item, sort);
match 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::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 (rank, updated) = key.split_once('|').unwrap_or(("9", ""));
let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", "")); let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", ""));
rank > cursor_rank rank > cursor_rank
@@ -1375,7 +1480,7 @@ fn objective_matches_query(objective: &ObjectiveSummary, query: &ObjectiveQueryR
{ {
return false; return false;
} }
query.text.as_ref().is_none_or(|text| { query.query.as_ref().is_none_or(|text| {
let needle = text.to_lowercase(); let needle = text.to_lowercase();
objective.title.to_lowercase().contains(&needle) objective.title.to_lowercase().contains(&needle)
|| objective.summary.to_lowercase().contains(&needle) || objective.summary.to_lowercase().contains(&needle)
@@ -1404,6 +1509,7 @@ fn objective_query_item(
id: objective.id, id: objective.id,
title: objective.title, title: objective.title,
state: objective.state, state: objective.state,
created_at: objective.created_at,
updated_at: objective.updated_at, updated_at: objective.updated_at,
matched_fields, matched_fields,
snippet, 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 { fn objective_sort_key(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String {
match sort { 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::UpdatedDesc => item.updated_at.clone().unwrap_or_default(),
ObjectiveQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(),
ObjectiveQuerySort::Title => item.title.to_lowercase(), ObjectiveQuerySort::Title => item.title.to_lowercase(),
} }
} }
fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQuerySort) { fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQuerySort) {
items.sort_by(|left, right| match sort { 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 ObjectiveQuerySort::UpdatedDesc => right
.updated_at .updated_at
.cmp(&left.updated_at) .cmp(&left.updated_at)
.then_with(|| left.id.cmp(&right.id)), .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 ObjectiveQuerySort::Title => left
.title .title
.to_lowercase() .to_lowercase()
@@ -1444,9 +1574,17 @@ fn objective_item_after_cursor(
) -> bool { ) -> bool {
let key = objective_sort_key(item, sort); let key = objective_sort_key(item, sort);
match sort { match sort {
ObjectiveQuerySort::UpdatedDesc => { ObjectiveQuerySort::UpdatedDesc | ObjectiveQuerySort::CreatedDesc => {
key < cursor.0 || (key == cursor.0 && item.id > cursor.1) 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), 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()); assert_eq!(ticket.event_page.returned, ticket.events.len());
let ticket_query = authority let ticket_query = authority
.query_tickets(TicketQueryRequest { .query_tickets(TicketQueryRequest {
text: Some("Ticket body".to_string()), query: Some("Ticket body".to_string()),
states: vec!["ready".to_string()], states: vec!["ready".to_string()],
linked_objective_id: Some("00000000001J3".to_string()), linked_objective_id: Some("00000000001J3".to_string()),
attention: vec!["unblocked".to_string(), "missing_commit".to_string()],
limit: Some(1), limit: Some(1),
..TicketQueryRequest::default() ..TicketQueryRequest::default()
}) })
@@ -1740,9 +1879,11 @@ mod tests {
let objective = authority.objective("00000000001J3").unwrap(); let objective = authority.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body")); assert!(objective.body.contains("Objective body"));
assert!(!objective.revision.is_empty()); 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 let objective_query = authority
.query_objectives(ObjectiveQueryRequest { .query_objectives(ObjectiveQueryRequest {
text: Some("Control plane".to_string()), query: Some("Control plane".to_string()),
linked_ticket_id: Some("00000000001J2".to_string()), linked_ticket_id: Some("00000000001J2".to_string()),
limit: Some(1), limit: Some(1),
..ObjectiveQueryRequest::default() ..ObjectiveQueryRequest::default()
+28 -2
View File
@@ -49,6 +49,7 @@ pub struct TicketDetail {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub readiness: Option<String>,
pub priority: String, pub priority: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
@@ -69,6 +70,7 @@ pub struct TicketDetail {
pub relations: TicketRelationView, pub relations: TicketRelationView,
pub linked_objectives: Vec<ObjectiveLinkSummary>, pub linked_objectives: Vec<ObjectiveLinkSummary>,
pub implementation_reports: Vec<TicketEvidenceEvent>, pub implementation_reports: Vec<TicketEvidenceEvent>,
pub current_assignment: Option<TicketAssignmentSummary>,
pub merge_request: Option<TicketMergeRequestSummary>, pub merge_request: Option<TicketMergeRequestSummary>,
pub evidence: TicketEvidenceSummary, pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>, pub resolution: Option<String>,
@@ -225,6 +227,14 @@ pub struct TicketEvidenceEvent {
pub excerpt: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketMergeRequestSummary { pub struct TicketMergeRequestSummary {
@@ -257,7 +267,7 @@ pub struct TicketEvidenceSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryRequest { pub struct TicketQueryRequest {
pub text: Option<String>, pub query: Option<String>,
#[serde(default)] #[serde(default)]
pub states: Vec<String>, pub states: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -283,8 +293,11 @@ pub struct TicketQueryItem {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub readiness: Option<String>,
pub priority: String, pub priority: String,
pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub item_revision: String,
pub workspace_action_priority: String, pub workspace_action_priority: String,
pub matched_fields: Vec<String>, pub matched_fields: Vec<String>,
pub snippet: Option<String>, pub snippet: Option<String>,
@@ -292,6 +305,8 @@ pub struct TicketQueryItem {
pub linked_objective_ids: Vec<String>, pub linked_objective_ids: Vec<String>,
pub relation_count: usize, pub relation_count: usize,
pub blocker_count: usize, pub blocker_count: usize,
pub unresolved_blocker_count: usize,
pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary, pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>, pub merge_request: Option<TicketMergeRequestSummary>,
} }
@@ -313,7 +328,7 @@ pub struct TicketShowRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveQueryRequest { pub struct ObjectiveQueryRequest {
pub text: Option<String>, pub query: Option<String>,
#[serde(default)] #[serde(default)]
pub states: Vec<String>, pub states: Vec<String>,
pub linked_ticket_id: Option<String>, pub linked_ticket_id: Option<String>,
@@ -329,6 +344,7 @@ pub struct ObjectiveQueryItem {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub matched_fields: Vec<String>, pub matched_fields: Vec<String>,
pub snippet: Option<String>, pub snippet: Option<String>,
@@ -358,11 +374,19 @@ pub struct ObjectiveEventDetail {
pub created_at: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary { pub struct ObjectiveSummary {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub summary: String, pub summary: String,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
@@ -378,6 +402,7 @@ pub struct ObjectiveDetail {
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
pub resources: Vec<ObjectiveResourceSummary>, pub resources: Vec<ObjectiveResourceSummary>,
pub body: String, pub body: String,
pub body_truncated: bool, pub body_truncated: bool,
@@ -407,6 +432,7 @@ pub fn ticket_api_typescript() -> String {
TicketEventDetail::decl(&config), TicketEventDetail::decl(&config),
ObjectiveLinkSummary::decl(&config), ObjectiveLinkSummary::decl(&config),
TicketEvidenceEvent::decl(&config), TicketEvidenceEvent::decl(&config),
TicketAssignmentSummary::decl(&config),
TicketMergeRequestSummary::decl(&config), TicketMergeRequestSummary::decl(&config),
TicketEvidenceSummary::decl(&config), TicketEvidenceSummary::decl(&config),
TicketQueryRequest::decl(&config), TicketQueryRequest::decl(&config),
+1 -1
View File
@@ -17799,7 +17799,7 @@ mod tests {
"POST", "POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"), &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"),
Some(json!({ Some(json!({
"text": "Objective body", "query": "Objective body",
"linked_ticket_id": "00000000001J2", "linked_ticket_id": "00000000001J2",
"limit": 1 "limit": 1
})), })),
+2 -2
View File
@@ -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. - `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
- `TicketClose` - `TicketClose`
- `TicketRelationRecord` - `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/`. These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
+14 -1
View File
@@ -61,6 +61,12 @@ export type TicketEvidenceEvent = {
excerpt: string; excerpt: string;
}; };
export type TicketAssignmentSummary = {
assignment_id: string;
runtime_id: string;
worker_id: string;
};
export type TicketMergeRequestSummary = { export type TicketMergeRequestSummary = {
merge_request_id: string; merge_request_id: string;
state: string; state: string;
@@ -87,7 +93,7 @@ export type TicketEvidenceSummary = {
}; };
export type TicketQueryRequest = { export type TicketQueryRequest = {
text: string | null; query: string | null;
states: Array<string>; states: Array<string>;
event_kinds: Array<string>; event_kinds: Array<string>;
evidence: Array<string>; evidence: Array<string>;
@@ -107,8 +113,11 @@ export type TicketQueryItem = {
id: string; id: string;
title: string; title: string;
state: string; state: string;
readiness: string | null;
priority: string; priority: string;
created_at: string | null;
updated_at: string | null; updated_at: string | null;
item_revision: string;
workspace_action_priority: string; workspace_action_priority: string;
matched_fields: Array<string>; matched_fields: Array<string>;
snippet: string | null; snippet: string | null;
@@ -116,6 +125,8 @@ export type TicketQueryItem = {
linked_objective_ids: Array<string>; linked_objective_ids: Array<string>;
relation_count: number; relation_count: number;
blocker_count: number; blocker_count: number;
unresolved_blocker_count: number;
unresolved_review_count: number;
evidence: TicketEvidenceSummary; evidence: TicketEvidenceSummary;
merge_request: TicketMergeRequestSummary | null; merge_request: TicketMergeRequestSummary | null;
}; };
@@ -174,6 +185,7 @@ export type TicketDetail = {
id: string; id: string;
title: string; title: string;
state: string; state: string;
readiness: string | null;
priority: string; priority: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
@@ -194,6 +206,7 @@ export type TicketDetail = {
relations: TicketRelationView; relations: TicketRelationView;
linked_objectives: Array<ObjectiveLinkSummary>; linked_objectives: Array<ObjectiveLinkSummary>;
implementation_reports: Array<TicketEvidenceEvent>; implementation_reports: Array<TicketEvidenceEvent>;
current_assignment: TicketAssignmentSummary | null;
merge_request: TicketMergeRequestSummary | null; merge_request: TicketMergeRequestSummary | null;
evidence: TicketEvidenceSummary; evidence: TicketEvidenceSummary;
resolution: string | null; resolution: string | null;