ticket: add typed relation metadata
This commit is contained in:
parent
41a61d79d9
commit
4601ad2b41
|
|
@ -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 に進む。
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
List {
|
||||
ticket: Option<String>,
|
||||
kind: Option<TicketRelationKind>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<TicketCli, TicketCliError> {
|
|||
"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<TicketCliOutput,
|
|||
}
|
||||
}
|
||||
|
||||
if !ticket.relations.outgoing.is_empty()
|
||||
|| !ticket.relations.incoming.is_empty()
|
||||
|| !ticket.relations.blockers.is_empty()
|
||||
|| !ticket.relations.notices.is_empty()
|
||||
{
|
||||
stdout.push_str("\n## relations\n\n");
|
||||
if !ticket.relations.outgoing.is_empty() {
|
||||
stdout.push_str("### outgoing\n\n");
|
||||
for relation in &ticket.relations.outgoing {
|
||||
stdout.push_str(&format!("- {} {}", relation.kind.as_str(), relation.target));
|
||||
if let Some(note) = &relation.note {
|
||||
stdout.push_str(&format!(" — {}", note.replace('\n', " ")));
|
||||
}
|
||||
stdout.push('\n');
|
||||
}
|
||||
}
|
||||
if !ticket.relations.incoming.is_empty() {
|
||||
stdout.push_str("### incoming / derived inverse\n\n");
|
||||
for relation in &ticket.relations.incoming {
|
||||
stdout.push_str(&format!(
|
||||
"- {} {} (forward: {})",
|
||||
relation.inverse_kind,
|
||||
relation.source_ticket,
|
||||
relation.forward_kind.as_str()
|
||||
));
|
||||
if let Some(note) = &relation.note {
|
||||
stdout.push_str(&format!(" — {}", note.replace('\n', " ")));
|
||||
}
|
||||
stdout.push('\n');
|
||||
}
|
||||
}
|
||||
if !ticket.relations.blockers.is_empty() {
|
||||
stdout.push_str("### unresolved queue blockers\n\n");
|
||||
for blocker in &ticket.relations.blockers {
|
||||
stdout.push_str(&format!(
|
||||
"- {} via {} (state: {})\n",
|
||||
blocker.blocking_ticket,
|
||||
blocker.reason_kind,
|
||||
blocker.blocking_state.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !ticket.relations.notices.is_empty() {
|
||||
stdout.push_str("### notices\n\n");
|
||||
for notice in &ticket.relations.notices {
|
||||
stdout.push_str(&format!("- {}\n", notice.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !ticket.artifacts.is_empty() {
|
||||
stdout.push_str("\n## artifacts\n\n");
|
||||
for artifact in &ticket.artifacts {
|
||||
|
|
@ -476,6 +548,53 @@ fn close(
|
|||
Ok(success(format!("closed\t{}\n", options.query)))
|
||||
}
|
||||
|
||||
fn relation(
|
||||
backend: &LocalTicketBackend,
|
||||
options: RelationOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
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<TicketCliOutput, TicketCliError> {
|
||||
let report = backend.doctor()?;
|
||||
let mut stdout = String::new();
|
||||
|
|
@ -557,6 +676,93 @@ fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
|||
Ok(ListOptions { state })
|
||||
}
|
||||
|
||||
fn parse_relation(args: &[String]) -> Result<RelationOptions, TicketCliError> {
|
||||
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<RelationOptions, TicketCliError> {
|
||||
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<RelationOptions, TicketCliError> {
|
||||
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, TicketCliError> {
|
||||
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<CommentOptions, TicketCliError> {
|
||||
if args.is_empty() || args[0].starts_with('-') {
|
||||
return Err(TicketCliError::new("comment requires <id>"));
|
||||
|
|
@ -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 <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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user