ticket: add typed relation metadata

This commit is contained in:
2026-06-09 15:17:45 +09:00
parent 41a61d79d9
commit 4601ad2b41
6 changed files with 1616 additions and 16 deletions
File diff suppressed because it is too large Load Diff
+277 -6
View File
@@ -13,10 +13,10 @@ use serde_json::{Value, json};
use crate::{
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
TicketSummary, TicketWorkflowState,
TicketIdOrSlug, TicketIntakeSummary, TicketRelationKind, TicketReview, TicketReviewResult,
TicketStateChange, TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 100;
@@ -30,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 11] = [
pub const TICKET_TOOL_NAMES: [&str; 13] = [
"TicketCreate",
"TicketList",
"TicketShow",
@@ -39,25 +39,29 @@ pub const TICKET_TOOL_NAMES: [&str; 11] = [
"TicketIntakeReady",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
"TicketList",
"TicketShow",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
"TicketCreate",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord",
];
@@ -85,6 +89,11 @@ transition is accepted and recorded. Orchestrator may return `ready` or `queued`
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event.";
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
inverse views are derived, not stored.";
const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \
is provided, both outgoing records owned by it and incoming forward records that target it are returned.";
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \
Ticket artifacts and do not move state, reorder queues, or start work.";
@@ -311,6 +320,65 @@ struct TicketCloseParams {
resolution: String,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketRelationKindParam {
DependsOn,
Blocks,
Related,
Supersedes,
DuplicateOf,
}
impl TicketRelationKindParam {
fn into_kind(self) -> TicketRelationKind {
match self {
Self::DependsOn => TicketRelationKind::DependsOn,
Self::Blocks => TicketRelationKind::Blocks,
Self::Related => TicketRelationKind::Related,
Self::Supersedes => TicketRelationKind::Supersedes,
Self::DuplicateOf => TicketRelationKind::DuplicateOf,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationRecordParams {
/// Ticket id that owns the forward relation.
ticket: String,
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
kind: TicketRelationKindParam,
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
target: String,
/// Optional bounded rationale/note.
#[serde(default)]
note: Option<String>,
/// Optional record author.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationQueryParams {
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
#[serde(default)]
ticket: Option<String>,
/// Optional forward relation kind filter.
#[serde(default)]
kind: Option<TicketRelationKindParam>,
/// Maximum records to return. Defaults to 100, max 200.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct TicketRelationQueryOutput {
count: usize,
returned: usize,
truncated: bool,
relations: Vec<Value>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum OrchestrationPlanKindParam {
@@ -467,6 +535,16 @@ struct TicketCloseTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketRelationRecordTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketRelationQueryTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketOrchestrationPlanRecordTool {
backend: LocalTicketBackend,
@@ -715,6 +793,64 @@ impl Tool for TicketCloseTool {
}
}
#[async_trait]
impl Tool for TicketRelationRecordTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketRelationRecordParams = parse_input("TicketRelationRecord", input_json)?;
let relation = NewTicketRelation {
kind: params.kind.into_kind(),
target: params.target.clone(),
note: params.note,
author: params.author,
};
let output = self
.backend
.add_ticket_relation(TicketIdOrSlug::Id(params.ticket.clone()), relation)
.map_err(|error| backend_error("TicketRelationRecord", error))?;
Ok(json_output(
format!(
"Recorded ticket relation {} {} {}",
output.ticket_id, output.kind, output.target
),
ticket_relation_json(&output),
))
}
}
#[async_trait]
impl Tool for TicketRelationQueryTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketRelationQueryParams = parse_input("TicketRelationQuery", input_json)?;
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let ticket = params.ticket.clone().map(TicketIdOrSlug::Id);
let kind = params.kind.map(TicketRelationKindParam::into_kind);
let relations = self
.backend
.query_ticket_relations(ticket, kind)
.map_err(|error| backend_error("TicketRelationQuery", error))?;
let count = relations.len();
let truncated = count > limit;
let returned_relations = relations
.into_iter()
.take(limit)
.map(|relation| ticket_relation_json(&relation))
.collect::<Vec<_>>();
Ok(json_output(
format!(
"Found {} ticket relation(s){}",
count,
if truncated { " (truncated)" } else { "" }
),
TicketRelationQueryOutput {
count,
returned: returned_relations.len(),
truncated,
relations: returned_relations,
},
))
}
}
#[async_trait]
impl Tool for TicketOrchestrationPlanRecordTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@@ -849,6 +985,73 @@ fn ticket_summary_json(ticket: TicketSummary) -> Value {
})
}
fn ticket_relation_json(relation: &crate::TicketRelation) -> Value {
json!({
"ticket_id": relation.ticket_id,
"kind": relation.kind.as_str(),
"target": relation.target,
"note": relation.note,
"author": relation.author,
"at": relation.at,
})
}
fn ticket_relations_json(ticket: &Ticket) -> Value {
let outgoing: Vec<_> = ticket
.relations
.outgoing
.iter()
.map(ticket_relation_json)
.collect();
let incoming: Vec<_> = ticket
.relations
.incoming
.iter()
.map(|relation| {
json!({
"source_ticket": relation.source_ticket,
"inverse_kind": relation.inverse_kind,
"forward_kind": relation.forward_kind.as_str(),
"note": relation.note,
"author": relation.author,
"at": relation.at,
})
})
.collect();
let blockers: Vec<_> = ticket
.relations
.blockers
.iter()
.map(|blocker| {
json!({
"blocking_ticket": blocker.blocking_ticket,
"reason_kind": blocker.reason_kind,
"relation_kind": blocker.relation_kind.as_str(),
"note": blocker.note,
"blocking_state": blocker.blocking_state.as_str(),
})
})
.collect();
let notices: Vec<_> = ticket
.relations
.notices
.iter()
.map(|notice| {
json!({
"related_ticket": notice.related_ticket,
"kind": notice.kind.as_str(),
"message": notice.message,
})
})
.collect();
json!({
"outgoing": outgoing,
"incoming": incoming,
"blockers": blockers,
"notices": notices,
})
}
fn ticket_json(
ticket: &Ticket,
event_limit: usize,
@@ -911,6 +1114,7 @@ fn ticket_json(
"truncated": artifact_count > artifacts.len(),
"items": artifacts,
},
"relations": ticket_relations_json(ticket),
"resolution": ticket.resolution.as_ref().map(|resolution| truncate_text(resolution.as_str(), body_max_bytes)),
})
}
@@ -997,6 +1201,12 @@ fn input_schema(name: &str) -> Value {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
}
"TicketRelationQuery" => {
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
}
"TicketOrchestrationPlanRecord" => {
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
}
@@ -1027,6 +1237,8 @@ impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool);
impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool);
@@ -1050,6 +1262,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
backend.clone(),
),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketRelationRecordTool>(
"TicketRelationRecord",
RELATION_RECORD_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketRelationQueryTool>(
"TicketRelationQuery",
RELATION_QUERY_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord",
ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
@@ -1095,6 +1317,7 @@ mod tests {
[
"TicketList",
"TicketShow",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor"
]
@@ -1108,6 +1331,7 @@ mod tests {
"TicketIntakeReady",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord"
]
);
@@ -1187,6 +1411,53 @@ mod tests {
assert!(report.summary.contains("0 error(s)"));
}
#[tokio::test]
async fn ticket_relation_tools_record_query_and_show_derived_view() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let source = backend.create(NewTicket::new("Relation Source")).unwrap();
let target = backend.create(NewTicket::new("Relation Target")).unwrap();
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let query = tool_by_name(backend.clone(), "TicketRelationQuery");
let show = tool_by_name(backend.clone(), "TicketShow");
let recorded = record
.execute(
&json!({
"ticket": source.id.clone(),
"kind": "depends_on",
"target": target.id.clone(),
"note": "target first",
"author": "test"
})
.to_string(),
)
.await
.unwrap();
assert!(recorded.summary.contains("Recorded ticket relation"));
let recorded_json: Value = serde_json::from_str(&recorded.content.unwrap()).unwrap();
assert_eq!(recorded_json["kind"], "depends_on");
assert_eq!(recorded_json["target"], target.id);
let queried = query
.execute(&json!({ "ticket": target.id.clone() }).to_string())
.await
.unwrap();
let queried_json: Value = serde_json::from_str(&queried.content.unwrap()).unwrap();
assert_eq!(queried_json["count"], 1);
assert_eq!(queried_json["relations"][0]["ticket_id"], source.id);
let shown = show
.execute(&json!({ "id": target.id.clone() }).to_string())
.await
.unwrap();
let shown_json: Value = serde_json::from_str(&shown.content.unwrap()).unwrap();
assert_eq!(
shown_json["relations"]["incoming"][0]["inverse_kind"],
"dependency_of"
);
}
#[tokio::test]
async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() {
let temp = TempDir::new().unwrap();