From 4601ad2b41d6c51c9d445ca2291726a658c42ab5 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 9 Jun 2026 15:17:45 +0900 Subject: [PATCH] ticket: add typed relation metadata --- .yoi/workflow/ticket-orchestrator-routing.md | 6 +- crates/ticket/src/lib.rs | 965 +++++++++++++++++++ crates/ticket/src/tool.rs | 283 +++++- crates/tui/src/workspace_panel.rs | 105 +- crates/yoi/src/ticket_cli.rs | 268 ++++- docs/development/work-items.md | 5 + 6 files changed, 1616 insertions(+), 16 deletions(-) diff --git a/.yoi/workflow/ticket-orchestrator-routing.md b/.yoi/workflow/ticket-orchestrator-routing.md index cc9df1c3..3d945419 100644 --- a/.yoi/workflow/ticket-orchestrator-routing.md +++ b/.yoi/workflow/ticket-orchestrator-routing.md @@ -69,9 +69,10 @@ Orchestrator は以下を行う。 利用可能なら、以下を使う。 - `TicketList`: routing 候補や関連 Ticket の確認。 -- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution 確認。 +- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 +- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。 - `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。 - `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。 @@ -81,6 +82,9 @@ Orchestrator は以下を行う。 ## Queued acceptance contract +- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。 +- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。 + `state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。 - unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。 diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index a176c88c..09ed35dc 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -23,8 +23,11 @@ const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at" const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl"; +const TICKET_RELATIONS_ARTIFACT: &str = "relations.json"; const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024; const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024; +const MAX_TICKET_RELATION_NOTE_BYTES: usize = 16 * 1024; +const MAX_TICKET_RELATION_FIELD_BYTES: usize = 1024; const DEFAULT_TICKET_BODY: &str = "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n"; const JAPANESE_TICKET_BODY: &str = @@ -529,6 +532,107 @@ pub struct TicketRef { pub status: TicketStatus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketRelationKind { + DependsOn, + Blocks, + Related, + Supersedes, + DuplicateOf, +} + +impl TicketRelationKind { + pub fn as_str(self) -> &'static str { + match self { + Self::DependsOn => "depends_on", + Self::Blocks => "blocks", + Self::Related => "related", + Self::Supersedes => "supersedes", + Self::DuplicateOf => "duplicate_of", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "depends_on" => Some(Self::DependsOn), + "blocks" => Some(Self::Blocks), + "related" => Some(Self::Related), + "supersedes" => Some(Self::Supersedes), + "duplicate_of" => Some(Self::DuplicateOf), + _ => None, + } + } +} + +impl fmt::Display for TicketRelationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewTicketRelation { + pub kind: TicketRelationKind, + pub target: String, + pub note: Option, + pub author: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TicketRelation { + pub ticket_id: String, + pub kind: TicketRelationKind, + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TicketRelationArtifact { + version: u32, + #[serde(default)] + relations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedTicketRelation { + pub source_ticket: String, + pub inverse_kind: String, + pub forward_kind: TicketRelationKind, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRelationBlocker { + pub blocking_ticket: String, + pub reason_kind: String, + pub relation_kind: TicketRelationKind, + pub note: Option, + pub blocking_state: TicketWorkflowState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRelationNotice { + pub related_ticket: String, + pub kind: TicketRelationKind, + pub message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketRelationView { + pub outgoing: Vec, + pub incoming: Vec, + pub blockers: Vec, + pub notices: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrchestrationPlanKind { @@ -702,6 +806,7 @@ pub struct Ticket { pub document: TicketDocument, pub events: Vec, pub artifacts: Vec, + pub relations: TicketRelationView, pub resolution: Option, } @@ -775,6 +880,17 @@ pub trait TicketBackend { fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>; fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; + fn add_ticket_relation( + &self, + id: TicketIdOrSlug, + relation: NewTicketRelation, + ) -> Result; + fn query_ticket_relations( + &self, + ticket: Option, + kind: Option, + ) -> Result>; + fn relation_view(&self, id: TicketIdOrSlug) -> Result; fn add_orchestration_plan_record( &self, id: TicketIdOrSlug, @@ -950,6 +1066,7 @@ impl LocalTicketBackend { Vec::new() }; let artifacts = collect_artifacts(&dir.join("artifacts"))?; + let relations = self.relation_view_for_meta(&meta)?; let resolution_path = dir.join("resolution.md"); let resolution = if resolution_path.exists() { Some(MarkdownText::new( @@ -963,6 +1080,7 @@ impl LocalTicketBackend { document, events, artifacts, + relations, resolution, }) } @@ -1096,6 +1214,52 @@ impl LocalTicketBackend { dir.join("artifacts").join(ORCHESTRATION_PLAN_ARTIFACT) } + fn ticket_relations_path(&self, dir: &Path) -> PathBuf { + dir.join("artifacts").join(TICKET_RELATIONS_ARTIFACT) + } + + fn read_ticket_relations_for_dir(&self, dir: &Path) -> Result> { + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(dir, read_item_file(&item)?.frontmatter)?; + let path = self.ticket_relations_path(dir); + read_ticket_relations_artifact(&path, Some(&meta)) + } + + fn all_ticket_relation_records(&self) -> Result> { + let mut relations = Vec::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + relations.extend(self.read_ticket_relations_for_dir(&dir)?); + } + relations.sort_by(|a, b| { + a.ticket_id + .cmp(&b.ticket_id) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.at.cmp(&b.at)) + }); + Ok(relations) + } + + fn relation_view_for_meta(&self, meta: &TicketMeta) -> Result { + let states = self.ticket_state_index()?; + let all = self.all_ticket_relation_records()?; + Ok(relation_view_from_records(meta, &all, &states)) + } + + fn ticket_state_index(&self) -> Result> { + let mut states = HashMap::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + states.insert(meta.id, meta.workflow_state); + } + Ok(states) + } + + fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result> { + Ok(self.relation_view_for_meta(meta)?.blockers) + } + fn read_orchestration_plan_records_for_dir( &self, dir: &Path, @@ -1337,6 +1501,18 @@ impl TicketBackend for LocalTicketBackend { } let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; + if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress { + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + let blockers = self.relation_blockers_for_meta(&meta)?; + if !blockers.is_empty() { + return Err(TicketError::Conflict(format!( + "ticket {} has unresolved blocking relation(s): {}", + meta.id, + format_relation_blockers(&blockers) + ))); + } + } self.apply_workflow_state_change(&dir, from, to, change, &[]) } @@ -1383,6 +1559,16 @@ impl TicketBackend for LocalTicketBackend { validate_required_event_value("queued_by", queued_by)?; let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + let blockers = self.relation_blockers_for_meta(&meta)?; + if !blockers.is_empty() { + return Err(TicketError::Conflict(format!( + "ticket {} has unresolved blocking relation(s): {}", + meta.id, + format_relation_blockers(&blockers) + ))); + } let at = now_utc(); let mut change = TicketStateChange::new( TicketWorkflowState::Ready.as_str(), @@ -1451,6 +1637,106 @@ impl TicketBackend for LocalTicketBackend { ) } + fn add_ticket_relation( + &self, + id: TicketIdOrSlug, + relation: NewTicketRelation, + ) -> Result { + validate_new_ticket_relation(&relation)?; + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + if relation.target.trim() == meta.id { + return Err(TicketError::Conflict(format!( + "ticket relation cannot target itself: {}", + meta.id + ))); + } + let target_id = relation.target.trim().to_string(); + let target_dir = self.ticket_dir(&target_id)?; + if !target_dir.join("item.md").is_file() { + return Err(TicketError::NotFound(target_id)); + } + let artifacts = dir.join("artifacts"); + fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?; + let path = self.ticket_relations_path(&dir); + ensure_child_of(&artifacts, &path)?; + let mut relations = read_ticket_relations_artifact(&path, Some(&meta))?; + if relations + .iter() + .any(|existing| existing.kind == relation.kind && existing.target == target_id) + { + return Err(TicketError::Conflict(format!( + "ticket relation already exists: {} {} {}", + meta.id, relation.kind, target_id + ))); + } + let at = now_utc(); + let output = TicketRelation { + ticket_id: meta.id.clone(), + kind: relation.kind, + target: target_id, + note: relation + .note + .map(trim_owned) + .filter(|note| !note.is_empty()), + author: relation + .author + .map(trim_owned) + .unwrap_or_else(default_author), + at: at.clone(), + }; + validate_ticket_relation(&output, Some(&meta))?; + relations.push(output.clone()); + relations.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.target.cmp(&b.target))); + write_ticket_relations_artifact(&path, &relations)?; + self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; + Ok(output) + } + + fn query_ticket_relations( + &self, + ticket: Option, + kind: Option, + ) -> Result> { + let mut relations = Vec::new(); + if let Some(ticket) = ticket { + let dir = self.find_ticket_dir(&ticket)?; + let source_id = ticket_id_from_dir(&dir)?; + relations.extend(self.read_ticket_relations_for_dir(&dir)?); + relations.extend( + self.all_ticket_relation_records()? + .into_iter() + .filter(|relation| relation.target == source_id), + ); + } else { + relations.extend(self.all_ticket_relation_records()?); + } + if let Some(kind) = kind { + relations.retain(|relation| relation.kind == kind); + } + relations.sort_by(|a, b| { + a.ticket_id + .cmp(&b.ticket_id) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.at.cmp(&b.at)) + }); + relations.dedup_by(|a, b| { + a.ticket_id == b.ticket_id && a.kind == b.kind && a.target == b.target && a.at == b.at + }); + Ok(relations) + } + + fn relation_view(&self, id: TicketIdOrSlug) -> Result { + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; + self.relation_view_for_meta(&meta) + } + fn add_orchestration_plan_record( &self, id: TicketIdOrSlug, @@ -1529,6 +1815,8 @@ impl TicketBackend for LocalTicketBackend { let mut ids: HashMap = HashMap::new(); let mut duplicate_ids: BTreeSet = BTreeSet::new(); + let mut state_index: HashMap = HashMap::new(); + let mut relation_records: Vec = Vec::new(); for legacy_bucket in ["open", "pending", "closed"] { let legacy_dir = self.root.join(legacy_bucket); @@ -1604,6 +1892,9 @@ impl TicketBackend for LocalTicketBackend { ), _ => {} } + if let Ok(meta) = ticket_meta_for_dir(&dir, parsed.frontmatter.clone()) { + state_index.insert(meta.id.clone(), meta.workflow_state); + } if parsed.frontmatter.get("state").map(String::as_str) == Some("closed") && !dir.join("resolution.md").is_file() { @@ -1618,6 +1909,12 @@ impl TicketBackend for LocalTicketBackend { if artifacts.exists() { doctor_artifacts(&artifacts, &mut report)?; let meta = ticket_meta_for_dir(&dir, parsed.frontmatter.clone())?; + doctor_ticket_relations_artifact( + &artifacts.join(TICKET_RELATIONS_ARTIFACT), + &meta, + &mut report, + &mut relation_records, + )?; doctor_orchestration_plan_artifact( &artifacts.join(ORCHESTRATION_PLAN_ARTIFACT), &meta, @@ -1625,6 +1922,9 @@ impl TicketBackend for LocalTicketBackend { )?; } } + doctor_ticket_relation_references(&relation_records, &ids, &state_index, &mut report); + doctor_ticket_relation_cycles(&relation_records, &state_index, &mut report); + for duplicate in duplicate_ids { report.push_error(format!("duplicate id: {duplicate}"), None); } @@ -1947,6 +2247,318 @@ fn trim_owned(value: String) -> String { value.trim().to_string() } +fn relation_inverse_kind(kind: TicketRelationKind) -> &'static str { + match kind { + TicketRelationKind::DependsOn => "dependency_of", + TicketRelationKind::Blocks => "blocked_by", + TicketRelationKind::Related => "related", + TicketRelationKind::Supersedes => "superseded_by", + TicketRelationKind::DuplicateOf => "duplicated_by", + } +} + +fn relation_notice_for_outgoing(relation: &TicketRelation) -> Option { + match relation.kind { + TicketRelationKind::Supersedes => Some(TicketRelationNotice { + related_ticket: relation.target.clone(), + kind: relation.kind, + message: format!( + "ticket supersedes {}; verify replacement before routing", + relation.target + ), + }), + TicketRelationKind::DuplicateOf => Some(TicketRelationNotice { + related_ticket: relation.target.clone(), + kind: relation.kind, + message: format!( + "ticket is duplicate of {}; avoid duplicate implementation", + relation.target + ), + }), + _ => None, + } +} + +fn relation_notice_for_incoming(relation: &TicketRelation) -> Option { + match relation.kind { + TicketRelationKind::Supersedes => Some(TicketRelationNotice { + related_ticket: relation.ticket_id.clone(), + kind: relation.kind, + message: format!( + "ticket is superseded by {}; verify replacement before routing", + relation.ticket_id + ), + }), + TicketRelationKind::DuplicateOf => Some(TicketRelationNotice { + related_ticket: relation.ticket_id.clone(), + kind: relation.kind, + message: format!( + "ticket has duplicate {}; avoid duplicate implementation", + relation.ticket_id + ), + }), + _ => None, + } +} + +fn ticket_state_resolved(state: TicketWorkflowState) -> bool { + matches!( + state, + TicketWorkflowState::Done | TicketWorkflowState::Closed + ) +} + +fn relation_view_from_records( + meta: &TicketMeta, + records: &[TicketRelation], + states: &HashMap, +) -> TicketRelationView { + let mut view = TicketRelationView::default(); + for relation in records { + if relation.ticket_id == meta.id { + view.outgoing.push(relation.clone()); + if relation.kind == TicketRelationKind::DependsOn { + let state = states + .get(&relation.target) + .copied() + .unwrap_or(TicketWorkflowState::Planning); + if !ticket_state_resolved(state) { + view.blockers.push(TicketRelationBlocker { + blocking_ticket: relation.target.clone(), + reason_kind: "depends_on".to_string(), + relation_kind: relation.kind, + note: relation.note.clone(), + blocking_state: state, + }); + } + } + if let Some(notice) = relation_notice_for_outgoing(relation) { + view.notices.push(notice); + } + } + if relation.target == meta.id { + view.incoming.push(DerivedTicketRelation { + source_ticket: relation.ticket_id.clone(), + inverse_kind: relation_inverse_kind(relation.kind).to_string(), + forward_kind: relation.kind, + note: relation.note.clone(), + author: relation.author.clone(), + at: relation.at.clone(), + }); + if relation.kind == TicketRelationKind::Blocks { + let state = states + .get(&relation.ticket_id) + .copied() + .unwrap_or(TicketWorkflowState::Planning); + if !ticket_state_resolved(state) { + view.blockers.push(TicketRelationBlocker { + blocking_ticket: relation.ticket_id.clone(), + reason_kind: "blocked_by".to_string(), + relation_kind: relation.kind, + note: relation.note.clone(), + blocking_state: state, + }); + } + } + if let Some(notice) = relation_notice_for_incoming(relation) { + view.notices.push(notice); + } + } + } + view.outgoing.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.at.cmp(&b.at)) + }); + view.incoming.sort_by(|a, b| { + a.inverse_kind + .cmp(&b.inverse_kind) + .then_with(|| a.source_ticket.cmp(&b.source_ticket)) + .then_with(|| a.at.cmp(&b.at)) + }); + view.blockers.sort_by(|a, b| { + a.reason_kind + .cmp(&b.reason_kind) + .then_with(|| a.blocking_ticket.cmp(&b.blocking_ticket)) + }); + view.notices.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then_with(|| a.related_ticket.cmp(&b.related_ticket)) + }); + view +} + +fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { + blockers + .iter() + .map(|blocker| { + format!( + "{} via {} (state: {})", + blocker.blocking_ticket, blocker.reason_kind, blocker.blocking_state + ) + }) + .collect::>() + .join(", ") +} + +fn validate_relation_optional_text( + label: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<()> { + if let Some(value) = value { + if value.as_bytes().len() > max_bytes { + return Err(TicketError::Conflict(format!( + "ticket relation {label} exceeds {max_bytes} bytes" + ))); + } + if value.contains('\0') { + return Err(TicketError::Conflict(format!( + "ticket relation {label} must not contain NUL bytes" + ))); + } + } + Ok(()) +} + +fn validate_relation_optional_single_line( + label: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<()> { + validate_relation_optional_text(label, value, max_bytes)?; + if let Some(value) = value { + if value.contains('\n') || value.contains('\r') { + return Err(TicketError::Conflict(format!( + "ticket relation {label} must be a single line" + ))); + } + } + Ok(()) +} + +fn validate_new_ticket_relation(relation: &NewTicketRelation) -> Result<()> { + let target = relation.target.trim(); + validate_relation_optional_single_line( + "target", + Some(target), + MAX_TICKET_RELATION_FIELD_BYTES, + )?; + if target.is_empty() { + return Err(TicketError::Conflict( + "ticket relation target must not be empty".to_string(), + )); + } + validate_relation_optional_text( + "note", + relation.note.as_deref(), + MAX_TICKET_RELATION_NOTE_BYTES, + )?; + validate_relation_optional_single_line( + "author", + relation.author.as_deref(), + MAX_TICKET_RELATION_FIELD_BYTES, + ) +} + +fn validate_ticket_relation(relation: &TicketRelation, meta: Option<&TicketMeta>) -> Result<()> { + validate_relation_optional_single_line( + "ticket_id", + Some(&relation.ticket_id), + MAX_TICKET_RELATION_FIELD_BYTES, + )?; + validate_relation_optional_single_line( + "target", + Some(&relation.target), + MAX_TICKET_RELATION_FIELD_BYTES, + )?; + validate_relation_optional_text( + "note", + relation.note.as_deref(), + MAX_TICKET_RELATION_NOTE_BYTES, + )?; + validate_relation_optional_single_line( + "author", + Some(&relation.author), + MAX_TICKET_RELATION_FIELD_BYTES, + )?; + validate_relation_optional_single_line( + "at", + Some(&relation.at), + MAX_TICKET_RELATION_FIELD_BYTES, + )?; + if let Some(meta) = meta { + if relation.ticket_id != meta.id { + return Err(TicketError::Conflict(format!( + "ticket relation targets {} but artifact belongs to {}", + relation.ticket_id, meta.id + ))); + } + } + if relation.ticket_id == relation.target { + return Err(TicketError::Conflict(format!( + "ticket relation cannot target itself: {}", + relation.ticket_id + ))); + } + Ok(()) +} + +fn read_ticket_relations_artifact( + path: &Path, + meta: Option<&TicketMeta>, +) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + let artifact: TicketRelationArtifact = + serde_json::from_str(&content).map_err(|e| TicketError::Parse { + path: path.to_path_buf(), + message: format!("invalid ticket relations artifact: {e}"), + })?; + if artifact.version != 1 { + return Err(TicketError::Parse { + path: path.to_path_buf(), + message: format!( + "unsupported ticket relations artifact version {}", + artifact.version + ), + }); + } + let mut seen = BTreeSet::new(); + for relation in &artifact.relations { + validate_ticket_relation(relation, meta).map_err(|err| TicketError::Parse { + path: path.to_path_buf(), + message: format!("invalid ticket relation: {err}"), + })?; + if !seen.insert((relation.kind, relation.target.clone())) { + return Err(TicketError::Parse { + path: path.to_path_buf(), + message: format!( + "duplicate ticket relation {} {}", + relation.kind, relation.target + ), + }); + } + } + Ok(artifact.relations) +} + +fn write_ticket_relations_artifact(path: &Path, relations: &[TicketRelation]) -> Result<()> { + let artifact = TicketRelationArtifact { + version: 1, + relations: relations.to_vec(), + }; + let content = serde_json::to_string_pretty(&artifact).map_err(|e| TicketError::Parse { + path: path.to_path_buf(), + message: format!("failed to serialize ticket relations artifact: {e}"), + })? + "\n"; + fs::write(path, content).map_err(|e| io_err(path, e)) +} + fn trim_accepted_orchestration_plan(plan: AcceptedOrchestrationPlan) -> AcceptedOrchestrationPlan { AcceptedOrchestrationPlan { summary: plan.summary.trim().to_string(), @@ -2620,6 +3232,156 @@ fn collect_artifacts_inner( Ok(()) } +fn doctor_ticket_relations_artifact( + path: &Path, + meta: &TicketMeta, + report: &mut TicketDoctorReport, + relation_records: &mut Vec, +) -> Result<()> { + match read_ticket_relations_artifact(path, Some(meta)) { + Ok(relations) => { + relation_records.extend(relations); + Ok(()) + } + Err(TicketError::Parse { message, .. }) => { + report.push_error(message, Some(path.to_path_buf())); + Ok(()) + } + Err(TicketError::Conflict(message)) => { + report.push_error(message, Some(path.to_path_buf())); + Ok(()) + } + Err(err) => Err(err), + } +} + +fn doctor_ticket_relation_references( + relations: &[TicketRelation], + ticket_dirs: &HashMap, + _states: &HashMap, + report: &mut TicketDoctorReport, +) { + for relation in relations { + let path = ticket_dirs + .get(&relation.ticket_id) + .map(|dir| dir.join("artifacts").join(TICKET_RELATIONS_ARTIFACT)); + if relation.ticket_id == relation.target { + report.push_error( + format!( + "ticket relation cannot target itself: {} {} {}", + relation.ticket_id, relation.kind, relation.target + ), + path.clone(), + ); + } + if !ticket_dirs.contains_key(&relation.target) { + report.push_error( + format!( + "ticket relation has dangling target: {} {} {}", + relation.ticket_id, relation.kind, relation.target + ), + path, + ); + } + } +} + +fn doctor_ticket_relation_cycles( + relations: &[TicketRelation], + states: &HashMap, + report: &mut TicketDoctorReport, +) { + let mut graph: BTreeMap> = BTreeMap::new(); + for relation in relations { + if !matches!( + relation.kind, + TicketRelationKind::DependsOn | TicketRelationKind::Blocks + ) { + continue; + } + if !states.contains_key(&relation.ticket_id) || !states.contains_key(&relation.target) { + continue; + } + let (waiter, blocker) = match relation.kind { + TicketRelationKind::DependsOn => (&relation.ticket_id, &relation.target), + TicketRelationKind::Blocks => (&relation.target, &relation.ticket_id), + _ => unreachable!(), + }; + graph + .entry(waiter.clone()) + .or_default() + .push(blocker.clone()); + } + let mut reported = BTreeSet::new(); + for start in graph.keys() { + let mut path = Vec::new(); + detect_relation_cycle(start, start, &graph, &mut path, &mut reported, report); + if reported.len() >= 32 { + report.push_warning( + "ticket relation cycle diagnostics truncated after 32 cycles".to_string(), + None, + ); + break; + } + } +} + +fn detect_relation_cycle( + start: &str, + current: &str, + graph: &BTreeMap>, + path: &mut Vec, + reported: &mut BTreeSet, + report: &mut TicketDoctorReport, +) { + if path.len() > 64 { + return; + } + path.push(current.to_string()); + if let Some(nexts) = graph.get(current) { + for next in nexts { + if next == start { + let mut cycle = path.clone(); + cycle.push(start.to_string()); + let key = canonical_cycle_key(&cycle); + if reported.insert(key) { + report.push_error( + format!( + "ticket relation dependency/blocking cycle: {}", + cycle.join(" -> ") + ), + None, + ); + } + continue; + } + if path.iter().any(|value| value == next) { + continue; + } + detect_relation_cycle(start, next, graph, path, reported, report); + if reported.len() >= 32 { + break; + } + } + } + path.pop(); +} + +fn canonical_cycle_key(cycle: &[String]) -> String { + if cycle.len() <= 1 { + return String::new(); + } + let nodes = &cycle[..cycle.len() - 1]; + let Some((idx, _)) = nodes.iter().enumerate().min_by(|(_, a), (_, b)| a.cmp(b)) else { + return String::new(); + }; + let mut ordered = Vec::new(); + for offset in 0..nodes.len() { + ordered.push(nodes[(idx + offset) % nodes.len()].clone()); + } + ordered.join(" -> ") +} + fn doctor_orchestration_plan_artifact( path: &Path, meta: &TicketMeta, @@ -3401,6 +4163,209 @@ state: planning assert!(matches!(err, TicketError::Locked { .. })); } + #[test] + fn ticket_relations_store_forward_and_derive_inverse_blockers() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut ready_input = NewTicket::new("Ready Relation Source"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let source = backend.create(ready_input).unwrap(); + let target = backend + .create(NewTicket::new("Planning Dependency")) + .unwrap(); + + let relation = backend + .add_ticket_relation( + TicketIdOrSlug::Id(source.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: target.id.clone(), + note: Some("needs dependency first".to_string()), + author: Some("test".to_string()), + }, + ) + .unwrap(); + assert_eq!(relation.ticket_id, source.id); + assert_eq!(relation.kind, TicketRelationKind::DependsOn); + assert_eq!(relation.target, target.id); + + let source_show = backend.show(TicketIdOrSlug::Id(source.id.clone())).unwrap(); + assert_eq!(source_show.relations.outgoing.len(), 1); + assert_eq!(source_show.relations.blockers.len(), 1); + assert_eq!(source_show.relations.blockers[0].blocking_ticket, target.id); + assert_eq!(source_show.relations.blockers[0].reason_kind, "depends_on"); + + let target_show = backend.show(TicketIdOrSlug::Id(target.id.clone())).unwrap(); + assert_eq!(target_show.relations.incoming.len(), 1); + assert_eq!(target_show.relations.incoming[0].source_ticket, source.id); + assert_eq!( + target_show.relations.incoming[0].inverse_kind, + "dependency_of" + ); + + let queried = backend + .query_ticket_relations( + Some(TicketIdOrSlug::Id(target.id.clone())), + Some(TicketRelationKind::DependsOn), + ) + .unwrap(); + assert_eq!(queried.len(), 1); + assert_eq!(backend.doctor().unwrap().error_count(), 0); + } + + #[test] + fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut blocked_input = NewTicket::new("Blocked Ready"); + blocked_input.workflow_state = Some(TicketWorkflowState::Ready); + let blocked = backend.create(blocked_input).unwrap(); + let dependency = backend.create(NewTicket::new("Dependency")).unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(blocked.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: dependency.id.clone(), + note: None, + author: Some("test".to_string()), + }, + ) + .unwrap(); + let err = backend + .queue_ready(TicketIdOrSlug::Id(blocked.id.clone()), "test") + .unwrap_err() + .to_string(); + assert!(err.contains("unresolved blocking relation"), "{err}"); + assert!(err.contains(&dependency.id), "{err}"); + + let mut incoming_input = NewTicket::new("Incoming Blocked Ready"); + incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + let incoming = backend.create(incoming_input).unwrap(); + let blocker = backend.create(NewTicket::new("Blocker")).unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(blocker.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::Blocks, + target: incoming.id.clone(), + note: None, + author: Some("test".to_string()), + }, + ) + .unwrap(); + let err = backend + .queue_ready(TicketIdOrSlug::Id(incoming.id.clone()), "test") + .unwrap_err() + .to_string(); + assert!(err.contains("unresolved blocking relation"), "{err}"); + assert!(err.contains(&blocker.id), "{err}"); + } + + #[test] + fn doctor_validates_ticket_relations() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let first = backend.create(NewTicket::new("First")).unwrap(); + let second = backend.create(NewTicket::new("Second")).unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(first.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: second.id.clone(), + note: None, + author: Some("test".to_string()), + }, + ) + .unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(second.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: first.id.clone(), + note: None, + author: Some("test".to_string()), + }, + ) + .unwrap(); + + let report = backend.doctor().unwrap(); + assert!( + report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("cycle")), + "{:?}", + report.diagnostics + ); + + let artifacts = tmp.path().join("tickets").join(&first.id).join("artifacts"); + fs::write( + artifacts.join(TICKET_RELATIONS_ARTIFACT), + format!( + r#"{{ + "version": 1, + "relations": [ + {{"ticket_id":"{}","kind":"related","target":"{}","author":"test","at":"2026-06-09T00:00:00Z"}} + ] +}} +"#, + first.id, first.id + ), + ) + .unwrap(); + let report = backend.doctor().unwrap(); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("ticket relation cannot target itself") + })); + + fs::write( + artifacts.join(TICKET_RELATIONS_ARTIFACT), + format!( + r#"{{ + "version": 1, + "relations": [ + {{"ticket_id":"{}","kind":"related","target":"missing-ticket","author":"test","at":"2026-06-09T00:00:01Z"}} + ] +}} +"#, + first.id + ), + ) + .unwrap(); + let report = backend.doctor().unwrap(); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("ticket relation has dangling target") + })); + + fs::write( + artifacts.join(TICKET_RELATIONS_ARTIFACT), + format!( + r#"{{ + "version": 1, + "relations": [ + {{"ticket_id":"{}","kind":"parent","target":"{}","author":"test","at":"2026-06-09T00:00:00Z"}} + ] +}} +"#, + first.id, second.id + ), + ) + .unwrap(); + let report = backend.doctor().unwrap(); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("invalid ticket relations artifact") + })); + } + #[test] fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 8434ea8e..363cfa31 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -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, + /// Optional record author. + #[serde(default)] + author: Option, +} + +#[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, + /// Optional forward relation kind filter. + #[serde(default)] + kind: Option, + /// Maximum records to return. Defaults to 100, max 200. + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Serialize)] +struct TicketRelationQueryOutput { + count: usize, + returned: usize, + truncated: bool, + relations: Vec, +} + #[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 { + 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 { + 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::>(); + 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 { @@ -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 { backend.clone(), ), tool_definition::("TicketClose", CLOSE_DESCRIPTION, backend.clone()), + tool_definition::( + "TicketRelationRecord", + RELATION_RECORD_DESCRIPTION, + backend.clone(), + ), + tool_definition::( + "TicketRelationQuery", + RELATION_QUERY_DESCRIPTION, + backend.clone(), + ), tool_definition::( "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(); diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 88adb445..a9b37852 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -4,7 +4,7 @@ use protocol::PodStatus; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, - TicketMeta, TicketSummary, TicketWorkflowState, + TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, }; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; @@ -601,7 +601,13 @@ pub(crate) fn build_current_ticket_row( } let summary = ticket_summary_from_meta(&ticket.meta); let registry = PanelRegistrySnapshot::empty(); - Ok(ticket_row(summary, &ticket.events, pods, ®istry)) + Ok(ticket_row( + summary, + &ticket.events, + &ticket.relations.blockers, + pods, + ®istry, + )) } fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { @@ -635,7 +641,13 @@ fn build_ticket_rows( continue; } let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?; - rows.push(ticket_row(summary, &ticket.events, pods, registry)); + rows.push(ticket_row( + summary, + &ticket.events, + &ticket.relations.blockers, + pods, + registry, + )); } Ok(rows) } @@ -643,12 +655,13 @@ fn build_ticket_rows( fn ticket_row( summary: TicketSummary, events: &[TicketEvent], + relation_blockers: &[TicketRelationBlocker], pods: &PodList, registry: &PanelRegistrySnapshot, ) -> PanelRow { let local_claim = local_claim_for_ticket(&summary, pods, registry); let related_pods = related_pods_for_ticket(&summary, pods, registry); - let derived = derive_ticket_state(&summary); + let derived = derive_ticket_state(&summary, relation_blockers); let latest_event = events.last(); let entry = TicketPanelEntry { id: summary.id.clone(), @@ -691,7 +704,37 @@ struct DerivedTicketState { blocked_reason: Option, } -fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState { +fn derive_ticket_state( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], +) -> DerivedTicketState { + if !relation_blockers.is_empty() { + let blockers = relation_blockers + .iter() + .take(3) + .map(|blocker| { + format!( + "{} via {} (state: {})", + blocker.blocking_ticket, + blocker.reason_kind, + blocker.blocking_state.as_str() + ) + }) + .collect::>() + .join(", "); + return DerivedTicketState { + kind: PanelRowKind::Blocked, + priority: ActionPriority::UserReply, + action: Some(NextUserAction::Edit), + disabled_reason: Some( + "Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued." + .to_string(), + ), + key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()), + blocked_reason: Some(blockers), + }; + } + if let Some(reason) = summary .attention_required .as_deref() @@ -945,7 +988,7 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; - use ticket::{NewTicket, TicketWorkflowState}; + use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState}; fn empty_pods() -> PodList { PodList::from_sources( @@ -1105,6 +1148,56 @@ mod tests { assert_eq!(queued.next_action, Some(NextUserAction::Wait)); } + #[test] + fn workspace_panel_marks_ready_ticket_with_unresolved_relation_blocked() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut ready_input = NewTicket::new("Ready Blocked By Relation"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let dependency = backend + .create(NewTicket::new("Relation Dependency")) + .unwrap(); + backend + .add_ticket_relation( + TicketIdOrSlug::Id(ready.id.clone()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: dependency.id.clone(), + note: None, + author: Some("test".to_string()), + }, + ) + .unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let row = model + .rows + .iter() + .find(|row| row.title == "Ready Blocked By Relation") + .unwrap(); + + assert_eq!(row.kind, PanelRowKind::Blocked); + assert_eq!(row.next_action, Some(NextUserAction::Edit)); + assert_eq!(row.priority, ActionPriority::UserReply); + assert!( + row.disabled_reason + .as_deref() + .unwrap() + .contains("Unresolved Ticket relation") + ); + assert!( + row.ticket + .as_ref() + .unwrap() + .blocked_reason + .as_deref() + .unwrap() + .contains(&dependency.id) + ); + } + #[test] fn workspace_panel_treats_yaml_null_attention_required_as_unblocked_planning() { let temp = TempDir::new().unwrap(); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 1e4b404f..7722d3f4 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -8,9 +8,9 @@ use ticket::config::{ ticket_config_scaffold, }; use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, + LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, - TicketReview, TicketReviewResult, TicketWorkflowState, + TicketRelationKind, TicketReview, TicketReviewResult, TicketWorkflowState, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,6 +29,7 @@ pub enum TicketCommand { Review(ReviewOptions), State(StateOptions), Close(CloseOptions), + Relation(RelationOptions), Doctor, } @@ -89,6 +90,25 @@ pub struct CloseOptions { pub resolution: BodySource, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelationAction { + Add { + ticket: String, + kind: TicketRelationKind, + target: String, + note: Option, + }, + List { + ticket: Option, + kind: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelationOptions { + pub action: RelationAction, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum BodySource { Message(String), @@ -163,6 +183,7 @@ pub fn parse_ticket_args(args: &[String]) -> Result { "review" => TicketCommand::Review(parse_review(&args[1..])?), "state" => TicketCommand::State(parse_state(&args[1..])?), "close" => TicketCommand::Close(parse_close(&args[1..])?), + "relation" => TicketCommand::Relation(parse_relation(&args[1..])?), "doctor" => { if args.len() != 1 { return Err(TicketCliError::new("ticket doctor takes no arguments")); @@ -216,6 +237,7 @@ fn run_command( TicketCommand::Review(options) => review(&backend, options), TicketCommand::State(options) => state(&backend, options), TicketCommand::Close(options) => close(&backend, options), + TicketCommand::Relation(options) => relation(&backend, options), TicketCommand::Doctor => doctor(&backend), TicketCommand::Init => unreachable!("init handled before backend setup"), } @@ -362,6 +384,56 @@ fn show(backend: &LocalTicketBackend, query: String) -> Result Result { + match options.action { + RelationAction::Add { + ticket, + kind, + target, + note, + } => { + let created = backend.add_ticket_relation( + TicketIdOrSlug::Query(ticket.clone()), + NewTicketRelation { + kind, + target: target.clone(), + note, + author: Some("yoi ticket".to_string()), + }, + )?; + Ok(success(format!( + "relation\t{}\t{}\t{}\n", + created.ticket_id, + created.kind.as_str(), + created.target + ))) + } + RelationAction::List { ticket, kind } => { + let ticket = ticket.map(TicketIdOrSlug::Query); + let relations = backend.query_ticket_relations(ticket, kind)?; + let mut stdout = String::from("ticket\tkind\ttarget\tauthor\tat\tnote\n"); + for relation in relations { + stdout.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\t{}\n", + relation.ticket_id, + relation.kind.as_str(), + relation.target, + relation.author, + relation.at, + relation.note.unwrap_or_default().replace('\n', " ") + )); + } + Ok(success(stdout)) + } + } +} + fn doctor(backend: &LocalTicketBackend) -> Result { let report = backend.doctor()?; let mut stdout = String::new(); @@ -557,6 +676,93 @@ fn parse_list(args: &[String]) -> Result { Ok(ListOptions { state }) } +fn parse_relation(args: &[String]) -> Result { + if args.is_empty() { + return Err(TicketCliError::new( + "ticket relation requires `add` or `list`", + )); + } + match args[0].as_str() { + "add" => parse_relation_add(&args[1..]), + "list" => parse_relation_list(&args[1..]), + other => Err(TicketCliError::new(format!( + "unknown ticket relation action: {other}" + ))), + } +} + +fn parse_relation_add(args: &[String]) -> Result { + let mut ticket = None; + let mut kind = None; + let mut target = None; + let mut note = None; + let mut i = 0; + while i < args.len() { + match option_with_value(args, &mut i)? { + Some(("--ticket", value)) => ticket = Some(value), + Some(("--kind", value)) => kind = Some(parse_relation_kind(&value)?), + Some(("--target", value)) => target = Some(value), + Some(("--note", value)) => note = Some(value), + Some((name, _)) => { + return Err(TicketCliError::new(format!( + "unknown relation add argument: {name}" + ))); + } + None => { + return Err(TicketCliError::new(format!( + "unknown relation add argument: {}", + args[i] + ))); + } + } + } + let ticket = ticket.ok_or_else(|| TicketCliError::new("relation add requires --ticket"))?; + let kind = kind.ok_or_else(|| TicketCliError::new("relation add requires --kind"))?; + let target = target.ok_or_else(|| TicketCliError::new("relation add requires --target"))?; + Ok(RelationOptions { + action: RelationAction::Add { + ticket, + kind, + target, + note, + }, + }) +} + +fn parse_relation_list(args: &[String]) -> Result { + let mut ticket = None; + let mut kind = None; + let mut i = 0; + while i < args.len() { + match option_with_value(args, &mut i)? { + Some(("--ticket", value)) => ticket = Some(value), + Some(("--kind", value)) => kind = Some(parse_relation_kind(&value)?), + Some((name, _)) => { + return Err(TicketCliError::new(format!( + "unknown relation list argument: {name}" + ))); + } + None => { + return Err(TicketCliError::new(format!( + "unknown relation list argument: {}", + args[i] + ))); + } + } + } + Ok(RelationOptions { + action: RelationAction::List { ticket, kind }, + }) +} + +fn parse_relation_kind(value: &str) -> Result { + TicketRelationKind::parse(value).ok_or_else(|| { + TicketCliError::new(format!( + "unknown relation kind `{value}`; expected depends_on, blocks, related, supersedes, or duplicate_of" + )) + }) +} + fn parse_comment(args: &[String]) -> Result { if args.is_empty() || args[0].starts_with('-') { return Err(TicketCliError::new("comment requires ")); @@ -712,6 +918,10 @@ fn option_with_value( "--file", "--message", "--resolution", + "--ticket", + "--kind", + "--target", + "--note", ] { if arg == name { let value = args @@ -811,7 +1021,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title \n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" } #[cfg(test)] @@ -1011,6 +1221,58 @@ mod tests { ); } + #[test] + fn ticket_cli_records_lists_and_shows_relations() { + let temp = TempDir::new().unwrap(); + let source = created_id(&run(&temp, &["create", "--title", "Relation Source"])); + let target = created_id(&run(&temp, &["create", "--title", "Relation Target"])); + + let added = run( + &temp, + &[ + "relation", + "add", + "--ticket", + &source, + "--kind", + "depends_on", + "--target", + &target, + "--note", + "target first", + ], + ); + assert_eq!( + added.stdout, + format!("relation\t{source}\tdepends_on\t{target}\n") + ); + + let listed = run(&temp, &["relation", "list", "--ticket", &target]); + assert!(listed.stdout.contains("ticket\tkind\ttarget")); + assert!( + listed + .stdout + .contains(&format!("{source}\tdepends_on\t{target}")) + ); + + let shown_source = run(&temp, &["show", &source]); + assert!(shown_source.stdout.contains("## relations")); + assert!( + shown_source + .stdout + .contains(&format!("- depends_on {target}")) + ); + assert!(shown_source.stdout.contains("unresolved queue blockers")); + + let shown_target = run(&temp, &["show", &target]); + assert!(shown_target.stdout.contains("incoming / derived inverse")); + assert!( + shown_target + .stdout + .contains(&format!("dependency_of {source}")) + ); + } + #[test] fn ticket_cli_uses_configured_backend_root() { let temp = TempDir::new().unwrap(); diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 6632a131..97e55f23 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -14,6 +14,7 @@ Do not treat ad-hoc chat summaries, memory records, or Pod notifications as the - `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Pod or task-specific helper Pod. - `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment. - `LocalTicketBackend`: the current `.yoi/tickets/` markdown/thread/artifacts storage backend. +- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored. A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms. @@ -38,10 +39,14 @@ Pods with the Ticket built-in feature can use typed Ticket tools: - `TicketReview` - `TicketWorkflowState` - `TicketClose` +- `TicketRelationRecord` +- `TicketRelationQuery` - `TicketDoctor` These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`. +Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Pod/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions. + Use them when a Pod needs to materialize or update project records: - Intake creates a new Ticket after user agreement.