feat: cut over Ticket and Objective read tools

This commit is contained in:
2026-08-17 05:18:38 +09:00
parent 14aa1aabea
commit 4964583868
7 changed files with 354 additions and 157 deletions
+60 -61
View File
@@ -36,8 +36,8 @@ const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -51,8 +51,8 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
];
@@ -71,8 +71,8 @@ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -91,8 +91,8 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
@@ -121,13 +121,12 @@ backend assigns the id and writes the local Ticket file layout under the configu
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
routing, closing, planning, or implementation decisions.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
artifact metadata.";
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
overview. The local backend supports workflow-state selection; Workspace-backed Workers replace this \
definition with the richer authoritative text/event/evidence/relation/Objective/time/attention query.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and artifact \
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
@@ -169,8 +168,8 @@ fn base_tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION,
"QueryTicket" => LIST_DESCRIPTION,
"ShowTicket" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION,
"TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION,
@@ -464,7 +463,7 @@ impl TicketWorkflowStateParam {
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketListStateParam {
enum QueryTicketStateParam {
Active,
Planning,
Ready,
@@ -475,7 +474,7 @@ enum TicketListStateParam {
All,
}
impl TicketListStateParam {
impl QueryTicketStateParam {
fn as_list_state(self) -> Option<TicketListState> {
match self {
Self::Planning => Some(TicketListState::Planning),
@@ -490,10 +489,10 @@ impl TicketListStateParam {
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams {
struct QueryTicketParams {
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
#[serde(default)]
state: Option<TicketListStateParam>,
state: Option<QueryTicketStateParam>,
/// Explicit workflow-state filter list. Cannot be combined with `state`.
#[serde(default)]
states: Option<Vec<TicketWorkflowStateParam>>,
@@ -502,28 +501,28 @@ struct TicketListParams {
limit: Option<usize>,
}
impl TicketListParams {
impl QueryTicketParams {
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
let query = if let Some(states) = self.states {
if self.state.is_some() {
return Err(TicketError::Conflict(
"TicketList accepts either `state` or `states`, not both".to_string(),
"QueryTicket accepts either `state` or `states`, not both".to_string(),
));
}
if states.is_empty() {
return Err(TicketError::Conflict(
"TicketList `states` must include at least one workflow state".to_string(),
"QueryTicket `states` must include at least one workflow state".to_string(),
));
}
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
} else {
match self.state.unwrap_or(TicketListStateParam::Active) {
TicketListStateParam::Active => crate::TicketListQuery::active(),
TicketListStateParam::All => crate::TicketListQuery::all(),
match self.state.unwrap_or(QueryTicketStateParam::Active) {
QueryTicketStateParam::Active => crate::TicketListQuery::active(),
QueryTicketStateParam::All => crate::TicketListQuery::all(),
state => crate::TicketListQuery::state(
state
.as_list_state()
.expect("workflow state list param maps to TicketListState"),
.expect("workflow state list param maps to QueryTicketState"),
),
}
};
@@ -533,7 +532,7 @@ impl TicketListParams {
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams {
struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided.
#[serde(default)]
id: Option<String>,
@@ -768,17 +767,17 @@ struct TicketRefOutput {
}
#[derive(Debug, Serialize)]
struct TicketListOutput {
struct QueryTicketOutput {
state_filter: String,
count: usize,
returned: usize,
truncated: bool,
limit: usize,
tickets: Vec<TicketListTicketOutput>,
tickets: Vec<QueryTicketTicketOutput>,
}
#[derive(Debug, Serialize)]
struct TicketListTicketOutput {
struct QueryTicketTicketOutput {
id: String,
title: String,
state: String,
@@ -808,12 +807,12 @@ struct TicketEditItemTool {
}
#[derive(Clone)]
struct TicketListTool {
struct QueryTicketTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketShowTool {
struct ShowTicketTool {
backend: TicketToolBackend,
}
@@ -976,28 +975,28 @@ impl Tool for TicketEditItemTool {
}
#[async_trait]
impl Tool for TicketListTool {
impl Tool for QueryTicketTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?;
let params: QueryTicketParams = parse_input("QueryTicket", input_json)?;
let (filter, state_filter, params_limit) = params
.into_query()
.map_err(|error| backend_error("TicketList", error))?;
.map_err(|error| backend_error("QueryTicket", error))?;
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self
.backend
.list(filter)
.map_err(|error| backend_error("TicketList", error))?;
.map_err(|error| backend_error("QueryTicket", error))?;
let count = tickets.len();
let returned_tickets: Vec<_> = tickets
.into_iter()
.take(limit)
.map(ticket_summary_json)
.collect();
let output = TicketListOutput {
let output = QueryTicketOutput {
state_filter: state_filter.to_string(),
count,
returned: returned_tickets.len(),
@@ -1017,13 +1016,13 @@ impl Tool for TicketListTool {
}
#[async_trait]
impl Tool for TicketShowTool {
impl Tool for ShowTicketTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketShowParams = parse_input("TicketShow", input_json)?;
let params: ShowTicketParams = parse_input("ShowTicket", input_json)?;
let query = id_or_query(params.id, params.query)?;
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
let artifact_limit = bounded(
@@ -1039,7 +1038,7 @@ impl Tool for TicketShowTool {
let ticket = self
.backend
.show(query)
.map_err(|error| backend_error("TicketShow", error))?;
.map_err(|error| backend_error("ShowTicket", error))?;
let summary = format!(
"Ticket {} state {}",
ticket.meta.id,
@@ -1484,9 +1483,9 @@ fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSl
}
}
fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput {
fn ticket_summary_json(ticket: TicketSummary) -> QueryTicketTicketOutput {
let hints = ticket_list_hints(&ticket);
TicketListTicketOutput {
QueryTicketTicketOutput {
id: ticket.id,
title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS),
state: ticket.workflow_state.as_str().to_string(),
@@ -1722,8 +1721,8 @@ fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)),
"ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)),
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
}
@@ -1769,8 +1768,8 @@ macro_rules! impl_from_backend {
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool);
impl_from_backend!(QueryTicketTool);
impl_from_backend!(ShowTicketTool);
impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool);
@@ -1793,8 +1792,8 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()),
tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()),
@@ -1861,8 +1860,8 @@ mod tests {
assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES,
[
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
@@ -1941,8 +1940,8 @@ mod tests {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let create = tool_by_name(backend.clone(), "TicketCreate");
let list = tool_by_name(backend.clone(), "TicketList");
let show = tool_by_name(backend.clone(), "TicketShow");
let list = tool_by_name(backend.clone(), "QueryTicket");
let show = tool_by_name(backend.clone(), "ShowTicket");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
let created = create
@@ -2004,7 +2003,7 @@ mod tests {
async fn ticket_list_tool_truncates_long_titles_and_hints() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let mut ticket = NewTicket::new(format!(
"Long Title {}",
"x".repeat(LIST_TITLE_MAX_CHARS + 40)
@@ -2032,7 +2031,7 @@ mod tests {
async fn ticket_list_tool_default_and_max_limits_are_bounded() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
for index in 0..(MAX_LIST_LIMIT + 5) {
backend
.create(NewTicket::new(format!("Ticket {index:03}")))
@@ -2083,7 +2082,7 @@ mod tests {
async fn ticket_list_tool_caps_all_and_closed_default_listing() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
for index in 0..(DEFAULT_LIST_LIMIT + 3) {
let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}"));
ticket.workflow_state = Some(TicketWorkflowState::Closed);
@@ -2141,7 +2140,7 @@ mod tests {
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
let mut ready_input = NewTicket::new("Ready Ticket");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
@@ -2188,7 +2187,7 @@ mod tests {
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let list = tool_by_name(backend.clone(), "QueryTicket");
let close = tool_by_name(backend.clone(), "TicketClose");
let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST";
let thread_secret = "THREAD_SECRET_DO_NOT_LIST";
@@ -2325,7 +2324,7 @@ mod tests {
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let remove = tool_by_name(backend.clone(), "TicketRelationRemove");
let query = tool_by_name(backend.clone(), "TicketRelationQuery");
let show = tool_by_name(backend.clone(), "TicketShow");
let show = tool_by_name(backend.clone(), "ShowTicket");
let recorded = record
.execute(
@@ -2785,7 +2784,7 @@ mod tests {
#[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap();
let show = tool_by_name(backend(&temp), "TicketShow");
let show = tool_by_name(backend(&temp), "ShowTicket");
let error = show
.execute(
&json!({ "id": "a", "query": "b" }).to_string(),
+2 -2
View File
@@ -2045,7 +2045,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read TicketShow body/thread/artifacts before making routing or requirements decisions."
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
@@ -3925,7 +3925,7 @@ fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str)
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
)
}
+80 -80
View File
@@ -26,36 +26,45 @@ impl WorkspaceHttpObjectiveBackend {
Self { client }
}
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> {
let mut url = format!(
"/api/w/{}/objectives",
async fn list(&self, input: QueryObjectiveInput) -> Result<ToolOutput, ToolError> {
let url = format!(
"/api/w/{}/objectives/query",
self.client.workspace_id().unwrap_or_default()
);
if let Some(limit) = input.limit {
url.push_str(&format!("?limit={}", limit.min(1000)));
}
let response = get_json::<ObjectiveListResponse>(self.client.as_ref(), &url)
.await
.map_err(backend_error)?;
let count = response.items.len();
let response = send_json::<QueryObjectiveInput, serde_json::Value>(
self.client.as_ref(),
reqwest::Method::POST,
&url,
&input,
)
.await
.map_err(backend_error)?;
Ok(ToolOutput {
summary: format!("Listed {count} objective(s)"),
summary: "Queried Objectives".to_string(),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveShow")?;
let url = self.objective_url(id);
let response = get_json::<ObjectiveDetail>(self.client.as_ref(), &url)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Read objective {}", response.id),
response,
)?)
async fn show(&self, input: ShowObjectiveInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ShowObjective")?;
let url = format!("{}/show", self.objective_url(id));
let response = send_json::<ObjectiveShowRequest, serde_json::Value>(
self.client.as_ref(),
reqwest::Method::POST,
&url,
&ObjectiveShowRequest {
event_limit: input.event_limit,
event_cursor: input.event_cursor,
},
)
.await
.map_err(backend_error)?;
Ok(ToolOutput {
summary: format!("Read objective {id}"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
@@ -195,13 +204,6 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError {
ToolError::ExecutionFailed(error.to_string())
}
async fn get_json<T: for<'de> Deserialize<'de>>(
client: &dyn WorkspaceClient,
path: &str,
) -> Result<T, WorkspaceObjectiveBackendError> {
decode_response(client.execute(WorkspaceRequest::get(path))?)
}
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
client: &dyn WorkspaceClient,
method: reqwest::Method,
@@ -270,14 +272,14 @@ pub fn workspace_http_objective_tools(client: Arc<dyn WorkspaceClient>) -> Vec<T
let backend = WorkspaceHttpObjectiveBackend::new(client);
vec![
objective_tool(
"ObjectiveList",
"QueryObjective",
LIST_DESCRIPTION,
list_schema(),
backend.clone(),
ObjectiveOperation::List,
),
objective_tool(
"ObjectiveShow",
"ShowObjective",
SHOW_DESCRIPTION,
show_schema(),
backend.clone(),
@@ -367,11 +369,11 @@ impl Tool for WorkspaceHttpObjectiveTool {
) -> Result<ToolOutput, ToolError> {
match self.operation {
ObjectiveOperation::List => {
let input = parse_input::<ObjectiveListInput>(input_json)?;
let input = parse_input::<QueryObjectiveInput>(input_json)?;
self.backend.list(input).await
}
ObjectiveOperation::Show => {
let input = parse_input::<ObjectiveShowInput>(input_json)?;
let input = parse_input::<ShowObjectiveInput>(input_json)?;
self.backend.show(input).await
}
ObjectiveOperation::Create => {
@@ -402,10 +404,8 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
const LIST_DESCRIPTION: &str =
"List Objective records through Backend Workspace API authority as bounded summaries.";
const SHOW_DESCRIPTION: &str =
"Show one Objective record by canonical id through Backend Workspace API authority.";
const LIST_DESCRIPTION: &str = "Query authoritative Objectives with bounded typed filters, stable snippets, linked-Ticket context, and cursor metadata.";
const SHOW_DESCRIPTION: &str = "Show one authoritative Objective with its revision, full linked-Ticket context, bounded body, and paged event metadata.";
const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str =
@@ -422,13 +422,29 @@ fn list_schema() -> serde_json::Value {
"type":"object",
"additionalProperties": false,
"properties":{
"limit":{"type":["integer","null"],"minimum":0,"maximum":1000}
"text":{"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]},
"limit":{"type":["integer","null"],"minimum":1,"maximum":100},
"cursor":{"type":["string","null"]}
}
})
}
fn show_schema() -> serde_json::Value {
id_schema(&["id"])
json!({
"type":"object",
"additionalProperties": false,
"required":["id"],
"properties":{
"id":{"type":"string"},
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
"event_cursor":{"type":["string","null"]}
}
})
}
fn create_schema() -> serde_json::Value {
@@ -480,17 +496,6 @@ fn unlink_ticket_schema() -> serde_json::Value {
id_ticket_schema(&["id", "ticket_id"])
}
fn id_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required": required,
"properties":{
"id":{"type":"string"}
}
})
}
fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
@@ -503,14 +508,30 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
})
}
#[derive(Debug, Deserialize)]
struct ObjectiveListInput {
#[derive(Debug, Serialize, Deserialize)]
struct QueryObjectiveInput {
text: Option<String>,
#[serde(default)]
states: Vec<String>,
linked_ticket_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
sort: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveShowInput {
struct ShowObjectiveInput {
id: String,
event_limit: Option<usize>,
event_cursor: Option<String>,
}
#[derive(Debug, Serialize)]
struct ObjectiveShowRequest {
event_limit: Option<usize>,
event_cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -574,30 +595,6 @@ fn default_state() -> String {
"active".to_string()
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveListResponse {
items: Vec<ObjectiveSummary>,
invalid_records: Vec<InvalidProjectRecord>,
record_authority: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct InvalidProjectRecord {
label: String,
reason: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveSummary {
id: String,
title: String,
state: String,
updated_at: Option<String>,
summary: String,
linked_tickets: Vec<String>,
record_source: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail {
id: String,
@@ -637,10 +634,10 @@ mod tests {
"ObjectiveCreate",
"ObjectiveEdit",
"ObjectiveLinkTicket",
"ObjectiveList",
"ObjectiveSetState",
"ObjectiveShow",
"ObjectiveUnlinkTicket",
"QueryObjective",
"ShowObjective",
]
);
}
@@ -648,9 +645,12 @@ mod tests {
#[test]
fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
let list = list_schema();
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
assert_eq!(list["properties"]["limit"]["maximum"], 100);
assert!(list["properties"]["cursor"].is_object());
assert!(list["properties"]["linked_ticket_id"].is_object());
let show = show_schema();
assert_eq!(show["required"][0], "id");
assert_eq!(show["properties"]["event_limit"]["maximum"], 50);
let create = create_schema();
assert_eq!(create["required"][0], "title");
let edit = edit_schema();
+207 -9
View File
@@ -9,6 +9,10 @@ use std::{
sync::Arc,
};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
@@ -25,8 +29,168 @@ use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
#[derive(Clone, Copy)]
enum WorkspaceTicketReadKind {
Query,
Show,
}
impl WorkspaceTicketReadKind {
fn name(self) -> &'static str {
match self {
Self::Query => "QueryTicket",
Self::Show => "ShowTicket",
}
}
fn description(self) -> &'static str {
match self {
Self::Query => {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
}
Self::Show => {
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence."
}
}
}
fn schema(self) -> Value {
match self {
Self::Query => serde_json::to_value(schemars::schema_for!(WorkspaceQueryTicketInput))
.expect("QueryTicket schema serializes"),
Self::Show => serde_json::to_value(schemars::schema_for!(WorkspaceShowTicketInput))
.expect("ShowTicket schema serializes"),
}
}
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceQueryTicketInput {
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
text: Option<String>,
/// Exact workflow states. Empty means every state.
#[serde(default)]
states: Vec<String>,
/// Exact typed event kinds that must occur in the bounded thread window.
#[serde(default)]
event_kinds: Vec<String>,
/// Required evidence kinds: implementation_report, implementation_report_after_rescope,
/// merge_request, commit, or approved_review.
#[serde(default)]
evidence: Vec<String>,
/// Current authoritative Merge Request review status.
review_status: Option<String>,
/// Attention filters: blocked, ready, awaiting_review, unresolved_changes,
/// stale_after_rescope, or missing_evidence.
#[serde(default)]
attention: Vec<String>,
related_ticket_id: Option<String>,
relation_kind: Option<String>,
linked_objective_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
/// updated_desc (default), priority, or title.
sort: Option<String>,
/// Page size; bounded by the Backend to 1..=100.
limit: Option<usize>,
/// Opaque cursor returned by a prior QueryTicket page.
cursor: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceShowTicketInput {
id: String,
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
event_limit: Option<usize>,
/// Opaque event cursor returned by a prior ShowTicket page.
event_cursor: Option<String>,
}
#[derive(Clone)]
struct WorkspaceTicketReadTool {
client: Arc<dyn WorkspaceClient>,
kind: WorkspaceTicketReadKind,
}
#[async_trait]
impl Tool for WorkspaceTicketReadTool {
async fn execute(
&self,
input: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::InvalidArgument("Workspace Ticket reads require workspace identity".into())
})?;
let (path, body) = match self.kind {
WorkspaceTicketReadKind::Query => {
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
(
format!("/api/w/{workspace_id}/tickets/query"),
serde_json::to_value(input)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
}
WorkspaceTicketReadKind::Show => {
let input: WorkspaceShowTicketInput = serde_json::from_str(&input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
if input.id.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"ShowTicket.id must not be empty".into(),
));
}
let path = format!("/api/w/{workspace_id}/tickets/{}/show", input.id.trim());
let body = json!({
"event_limit": input.event_limit,
"event_cursor": input.event_cursor,
});
(path, body)
}
};
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
path,
serde_json::to_string(&body)
.map_err(|error| ToolError::Internal(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: self.kind.name().to_string(),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn workspace_ticket_read_definition(
client: Arc<dyn WorkspaceClient>,
kind: WorkspaceTicketReadKind,
) -> ToolDefinition {
Arc::new(move || {
let meta = ToolMeta::new(kind.name())
.description(kind.description())
.input_schema(kind.schema());
let tool: Arc<dyn Tool> = Arc::new(WorkspaceTicketReadTool {
client: client.clone(),
kind,
});
(meta, tool)
})
}
const FEATURE_ID: &str = "ticket";
const FEATURE_NAME: &str = "Ticket tools";
@@ -143,8 +307,8 @@ impl TicketFeatureAccess {
}
const READ_ONLY_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
@@ -168,8 +332,8 @@ const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketClose",
@@ -183,8 +347,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
#[cfg(test)]
const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketWorkflowState",
"TicketClose",
@@ -413,6 +577,10 @@ impl FeatureModule for TicketFeature {
ticket_workflow_instruction(),
))?;
let allowed_tool_names = self.enabled_tool_names();
let workspace_client = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Some(client.clone()),
TicketFeatureBackend::Local { .. } => None,
};
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
@@ -423,6 +591,15 @@ impl FeatureModule for TicketFeature {
{
continue;
}
let definition = match (name.as_str(), workspace_client.as_ref()) {
("QueryTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)
}
("ShowTicket", Some(client)) => {
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)
}
_ => definition,
};
tools.register(ToolContribution::new(name, definition))?;
}
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
@@ -1044,6 +1221,27 @@ mod tests {
.expect("tool exists")
}
#[test]
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
let (query, _) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
assert_eq!(query.name, "QueryTicket");
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 (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!(tool_names.contains(&"QueryTicket"));
assert!(tool_names.contains(&"ShowTicket"));
assert!(!tool_names.contains(&"TicketList"));
assert!(!tool_names.contains(&"TicketShow"));
}
#[test]
fn descriptor_declares_ticket_tools() {
let temp = TempDir::new().unwrap();
@@ -1200,8 +1398,8 @@ language = "Japanese"
let descriptor_description = descriptor
.tools
.iter()
.find(|tool| tool.name == "TicketShow")
.expect("TicketShow declared")
.find(|tool| tool.name == "ShowTicket")
.expect("ShowTicket declared")
.description
.clone();
assert!(descriptor_description.contains("Ticket record language: Japanese"));
@@ -1214,7 +1412,7 @@ language = "Japanese"
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
let description = pending_tool_description(&pending_tools, "TicketShow");
let description = pending_tool_description(&pending_tools, "ShowTicket");
assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language"));
assert!(description.contains("Preserve protocol literals"));
+1 -1
View File
@@ -132,7 +132,7 @@ mod tests {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result("TicketShow", false));
hook.observe_tool_result(&tool_result("ShowTicket", false));
assert!(!request.is_requested());
}
+2 -2
View File
@@ -34,8 +34,8 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
Workers with the Ticket built-in feature can use typed Ticket tools:
- `TicketCreate`
- `TicketList`lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
- `QueryTicket`bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
- `TicketComment`
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
+2 -2
View File
@@ -1,9 +1,9 @@
## Ticket workflow
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.