From ab85388122dcf060f04f6efb1dc3af8135df8a73 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 7 Jun 2026 08:32:22 +0900 Subject: [PATCH] feat: add explicit ticket workflow state --- crates/client/src/ticket_role.rs | 14 +- crates/ticket/src/lib.rs | 464 ++++++++++++++++++++++++++++- crates/ticket/src/tool.rs | 405 ++++++++++++++++++++++++- crates/tui/src/multi_pod.rs | 374 +++++++++-------------- crates/tui/src/workspace_panel.rs | 475 ++++++++---------------------- 5 files changed, 1141 insertions(+), 591 deletions(-) diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 0f283666..1212f685 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -91,9 +91,9 @@ impl TicketIntakeHandoff { out.push_str("\nPanel handoff:\n"); push_bounded_bullet(out, "workspace", &self.workspace_label); push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod); - out.push_str("- When Intake has clarified the request and created/updated the Ticket, notify/report readiness to this Orchestrator.\n"); - out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, readiness, needs_preflight, risk_flags, user_go_required, intake_summary.\n"); - out.push_str("- Do not start implementation automatically; wait for Orchestrator routing/preflight and human Go gates.\n"); + out.push_str("- When Intake has clarified the request and created/updated the Ticket, use the typed Ticket tool surface to append `intake_summary` and set `workflow_state = ready` when the Ticket is ready to queue.\n"); + out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, workflow_state, needs_preflight, risk_flags, intake_summary.\n"); + out.push_str("- Do not start implementation automatically; the user queues a ready Ticket via panel (`ready -> queued`), and Orchestrator treats `queued` as schedulable before moving it to `inprogress` when starting.\n"); } } @@ -849,8 +849,12 @@ workflow = "ticket-review-workflow" assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo")); assert!(handoff_text.contains("workspace: Demo workspace")); assert!(handoff_text.contains("created_or_updated_ticket_id_or_slug")); - assert!(handoff_text.contains("Do not start implementation automatically")); - assert!(handoff_text.contains("human Go gates")); + assert!(handoff_text.contains("workflow_state")); + assert!(handoff_text.contains("Ticket tool surface")); + assert!(handoff_text.contains("ready -> queued")); + assert!(handoff_text.contains("queued` as schedulable")); + assert!(!handoff_text.contains("user_go_required")); + assert!(!handoff_text.contains("human Go gates")); let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator); orchestrator.ticket = Some(TicketRef::slug("launcher")); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 064c6684..9a7771a5 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -153,6 +153,66 @@ impl From for ExtensibleTicketStatus { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TicketWorkflowState { + Intake, + Ready, + Queued, + InProgress, + Done, +} + +impl TicketWorkflowState { + pub fn as_str(self) -> &'static str { + match self { + Self::Intake => "intake", + Self::Ready => "ready", + Self::Queued => "queued", + Self::InProgress => "inprogress", + Self::Done => "done", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "intake" => Some(Self::Intake), + "ready" => Some(Self::Ready), + "queued" => Some(Self::Queued), + "inprogress" => Some(Self::InProgress), + "done" => Some(Self::Done), + _ => None, + } + } + + pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self { + match status { + ExtensibleTicketStatus::Closed => Self::Done, + _ => Self::Intake, + } + } + + pub fn is_intake_ready_transition(from: Self, to: Self) -> bool { + from == Self::Intake && to == Self::Ready + } + + pub fn is_queue_transition(from: Self, to: Self) -> bool { + from == Self::Ready && to == Self::Queued + } + + pub fn is_role_transition(from: Self, to: Self) -> bool { + matches!( + (from, to), + (Self::Queued, Self::InProgress) | (Self::InProgress, Self::Done) + ) + } +} + +impl fmt::Display for TicketWorkflowState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarkdownText(pub String); @@ -417,6 +477,10 @@ pub struct NewTicket { pub needs_preflight: Option, pub risk_flags: Vec, pub action_required: Option, + pub workflow_state: Option, + pub attention_required: Option, + pub queued_by: Option, + pub queued_at: Option, } impl NewTicket { @@ -437,6 +501,10 @@ impl NewTicket { needs_preflight: None, risk_flags: Vec::new(), action_required: None, + workflow_state: None, + attention_required: None, + queued_by: None, + queued_at: None, } } } @@ -482,6 +550,11 @@ pub struct TicketMeta { pub needs_preflight: Option, pub risk_flags: Vec, pub action_required: Option, + pub workflow_state: TicketWorkflowState, + pub workflow_state_explicit: bool, + pub attention_required: Option, + pub queued_by: Option, + pub queued_at: Option, pub raw: BTreeMap, } @@ -497,6 +570,11 @@ pub struct TicketSummary { pub readiness: Option, pub needs_preflight: Option, pub action_required: Option, + pub workflow_state: TicketWorkflowState, + pub workflow_state_explicit: bool, + pub attention_required: Option, + pub queued_by: Option, + pub queued_at: Option, pub updated_at: Option, } @@ -597,6 +675,14 @@ pub trait TicketBackend { field: &str, change: TicketStateChange, ) -> Result<()>; + fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; + fn mark_intake_ready( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + ) -> Result<()>; + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>; fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; @@ -729,6 +815,45 @@ impl LocalTicketBackend { }) } + fn ticket_workflow_state_from_item(&self, item: &Path) -> Result { + let parsed = read_item_file(item)?; + let meta = ticket_meta(parsed.frontmatter); + Ok(meta.workflow_state) + } + + fn apply_workflow_state_change( + &self, + dir: &Path, + expected_from: TicketWorkflowState, + to: TicketWorkflowState, + change: TicketStateChange, + extra_updates: &[(&str, &str)], + ) -> Result<()> { + validate_state_change(&change)?; + if change.from.as_str() != expected_from.as_str() || change.to.as_str() != to.as_str() { + return Err(TicketError::Conflict(format!( + "workflow_state change payload mismatch: expected {} -> {}, got {} -> {}", + expected_from.as_str(), + to.as_str(), + change.from, + change.to + ))); + } + let item = dir.join("item.md"); + let current = self.ticket_workflow_state_from_item(&item)?; + if current != expected_from { + return Err(TicketError::Conflict(format!( + "workflow_state changed concurrently: expected `{}`, found `{}`", + expected_from.as_str(), + current.as_str() + ))); + } + self.append_state_changed_event(dir, &change, Some("workflow_state"))?; + let mut updates = vec![("workflow_state", to.as_str())]; + updates.extend_from_slice(extra_updates); + self.set_frontmatter_fields(&item, &updates) + } + fn append_thread_event( &self, dir: &Path, @@ -836,6 +961,11 @@ impl TicketBackend for LocalTicketBackend { readiness: meta.readiness, needs_preflight: meta.needs_preflight, action_required: meta.action_required, + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + attention_required: meta.attention_required, + queued_by: meta.queued_by, + queued_at: meta.queued_at, updated_at: meta.updated_at, }); } @@ -898,6 +1028,14 @@ impl TicketBackend for LocalTicketBackend { fields.push(("kind".to_string(), input.kind)); fields.push(("priority".to_string(), input.priority)); fields.push(("labels".to_string(), labels_yaml(&input.labels))); + fields.push(( + "workflow_state".to_string(), + input + .workflow_state + .unwrap_or(TicketWorkflowState::Intake) + .as_str() + .to_string(), + )); fields.push(("created_at".to_string(), created.clone())); fields.push(("updated_at".to_string(), created.clone())); fields.push(( @@ -920,6 +1058,15 @@ impl TicketBackend for LocalTicketBackend { if let Some(action_required) = input.action_required { fields.push(("action_required".to_string(), action_required)); } + if let Some(attention_required) = input.attention_required { + fields.push(("attention_required".to_string(), attention_required)); + } + if let Some(queued_by) = input.queued_by { + fields.push(("queued_by".to_string(), queued_by)); + } + if let Some(queued_at) = input.queued_at { + fields.push(("queued_at".to_string(), queued_at)); + } let item = serialize_item(&fields, input.body.as_str()); atomic_write(&dir.join("item.md"), item.as_bytes())?; let thread = format!( @@ -967,6 +1114,11 @@ impl TicketBackend for LocalTicketBackend { change: TicketStateChange, ) -> Result<()> { validate_state_field_name(field)?; + if field == "workflow_state" { + return Err(TicketError::Conflict( + "workflow_state transitions must use dedicated workflow APIs".to_string(), + )); + } let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; let item = dir.join("item.md"); @@ -986,6 +1138,91 @@ impl TicketBackend for LocalTicketBackend { self.set_frontmatter_fields(&item, &[(field, change.to.as_str())]) } + fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { + let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { + TicketError::Conflict(format!( + "invalid workflow_state transition source: {}", + change.from + )) + })?; + let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| { + TicketError::Conflict(format!( + "invalid workflow_state transition target: {}", + change.to + )) + })?; + if !TicketWorkflowState::is_role_transition(from, to) { + return Err(TicketError::Conflict(format!( + "workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated intake-ready or queue APIs for gated transitions", + from.as_str(), + to.as_str() + ))); + } + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + self.apply_workflow_state_change(&dir, from, to, change, &[]) + } + + fn mark_intake_ready( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + ) -> Result<()> { + let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { + TicketError::Conflict(format!( + "invalid workflow_state transition source: {}", + change.from + )) + })?; + let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| { + TicketError::Conflict(format!( + "invalid workflow_state transition target: {}", + change.to + )) + })?; + if !TicketWorkflowState::is_intake_ready_transition(from, to) { + return Err(TicketError::Conflict(format!( + "mark_intake_ready only allows workflow_state intake -> ready, got {} -> {}", + from.as_str(), + to.as_str() + ))); + } + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let current = self.ticket_workflow_state_from_item(&dir.join("item.md"))?; + if current != from { + return Err(TicketError::Conflict(format!( + "workflow_state changed concurrently: expected `{}`, found `{}`", + from.as_str(), + current.as_str() + ))); + } + self.append_intake_summary_event(&dir, &summary)?; + self.apply_workflow_state_change(&dir, from, to, change, &[]) + } + + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> { + validate_required_event_value("queued_by", queued_by)?; + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let at = now_utc(); + let mut change = TicketStateChange::new( + TicketWorkflowState::Ready.as_str(), + TicketWorkflowState::Queued.as_str(), + "queued", + "Ticket queued for Orchestrator routing.\n", + ); + change.author = Some(queued_by.to_string()); + self.apply_workflow_state_change( + &dir, + TicketWorkflowState::Ready, + TicketWorkflowState::Queued, + change, + &[("queued_by", queued_by), ("queued_at", at.as_str())], + ) + } + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> { let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; @@ -1060,9 +1297,25 @@ impl TicketBackend for LocalTicketBackend { fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?; } let at = now_utc(); + let current_workflow_state = + self.ticket_workflow_state_from_item(&closed_dir.join("item.md"))?; + if current_workflow_state != TicketWorkflowState::Done { + let mut change = TicketStateChange::new( + current_workflow_state.as_str(), + TicketWorkflowState::Done.as_str(), + "closed", + "Ticket closed; workflow_state set to done.\n", + ); + change.author = Some(default_author()); + self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?; + } self.set_frontmatter_fields( &closed_dir.join("item.md"), - &[("status", "closed"), ("updated_at", &at)], + &[ + ("status", "closed"), + ("workflow_state", TicketWorkflowState::Done.as_str()), + ("updated_at", &at), + ], )?; atomic_write( &closed_dir.join("resolution.md"), @@ -1178,6 +1431,28 @@ impl TicketBackend for LocalTicketBackend { Some(item.clone()), ); } + match parsed.frontmatter.get("workflow_state").map(String::as_str) { + Some(value) if TicketWorkflowState::parse(value).is_none() => report + .push_error( + format!("invalid workflow_state '{value}': {}", item.display()), + Some(item.clone()), + ), + _ => {} + } + if status == TicketStatus::Closed + && parsed + .frontmatter + .get("workflow_state") + .is_none_or(|value| value != TicketWorkflowState::Done.as_str()) + { + report.push_warning( + format!( + "closed ticket should have workflow_state: done: {}", + item.display() + ), + Some(item.clone()), + ); + } if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() { report.push_warning( format!("closed ticket missing resolution.md: {}", dir.display()), @@ -1309,6 +1584,11 @@ fn ticket_meta(frontmatter: BTreeMap) -> TicketMeta { .or_else(|| frontmatter.get("risks")) .map(|value| parse_yaml_list(value)) .unwrap_or_default(); + let workflow_state_explicit = frontmatter.contains_key("workflow_state"); + let workflow_state = frontmatter + .get("workflow_state") + .and_then(|value| TicketWorkflowState::parse(value)) + .unwrap_or_else(|| TicketWorkflowState::default_for_status(&status)); TicketMeta { id, slug, @@ -1331,6 +1611,11 @@ fn ticket_meta(frontmatter: BTreeMap) -> TicketMeta { .and_then(|value| parse_bool(value)), risk_flags, action_required: frontmatter.get("action_required").cloned(), + workflow_state, + workflow_state_explicit, + attention_required: frontmatter.get("attention_required").cloned(), + queued_by: frontmatter.get("queued_by").cloned(), + queued_at: frontmatter.get("queued_at").cloned(), raw: frontmatter, } } @@ -1929,6 +2214,10 @@ readiness: implementation-ready needs_preflight: false risk_flags: [low, local] action_required: none +workflow_state: ready +attention_required: none +queued_by: workspace-panel +queued_at: 2026-06-05T00:01:00Z --- ## Body @@ -1941,6 +2230,11 @@ action_required: none assert_eq!(meta.needs_preflight, Some(false)); assert_eq!(meta.risk_flags, vec!["low", "local"]); assert_eq!(meta.action_required.as_deref(), Some("none")); + assert_eq!(meta.workflow_state, TicketWorkflowState::Ready); + assert!(meta.workflow_state_explicit); + assert_eq!(meta.attention_required.as_deref(), Some("none")); + assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel")); + assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z")); } #[test] @@ -1955,6 +2249,9 @@ action_required: none assert!(dir.join("thread.md").exists()); assert!(dir.join("artifacts/.gitkeep").exists()); assert_eq!(ticket.slug, "example-ticket"); + let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake); + assert!(record.meta.workflow_state_explicit); let report = backend.doctor().unwrap(); assert!(report.is_ok(), "{:?}", report.diagnostics); } @@ -2167,6 +2464,171 @@ action_required: none )); } + #[test] + fn workflow_state_defaults_and_queue_transition_round_trip() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut missing_frontmatter = BTreeMap::new(); + missing_frontmatter.insert("status".to_string(), "open".to_string()); + let missing_meta = ticket_meta(missing_frontmatter); + assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake); + assert!(!missing_meta.workflow_state_explicit); + + let mut closed_frontmatter = BTreeMap::new(); + closed_frontmatter.insert("status".to_string(), "closed".to_string()); + let closed_meta = ticket_meta(closed_frontmatter); + assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done); + assert!(!closed_meta.workflow_state_explicit); + + let mut ready_input = NewTicket::new("Ready Workflow"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + backend + .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") + .unwrap(); + + let queued = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap(); + assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert!(queued.meta.workflow_state_explicit); + assert_eq!(queued.meta.queued_by.as_deref(), Some("workspace-panel")); + assert!(queued.meta.queued_at.is_some()); + let event = queued + .events + .iter() + .find(|event| event.kind == TicketEventKind::StateChanged) + .unwrap(); + assert_eq!(event.state_field.as_deref(), Some("workflow_state")); + assert_eq!(event.from.as_deref(), Some("ready")); + assert_eq!(event.to.as_deref(), Some("queued")); + assert_eq!(event.reason.as_deref(), Some("queued")); + } + + #[test] + fn workflow_queue_rejects_non_ready_ticket_without_mutation() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend.create(NewTicket::new("Intake Ticket")).unwrap(); + + assert!(matches!( + backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"), + Err(TicketError::Conflict(_)) + )); + let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake); + assert!(record.meta.queued_by.is_none()); + assert!( + !record + .events + .iter() + .any(|event| event.kind == TicketEventKind::StateChanged) + ); + } + + #[test] + fn workflow_state_cannot_be_changed_through_generic_state_field_api() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend + .create(NewTicket::new("Generic Workflow Bypass")) + .unwrap(); + let change = TicketStateChange::new( + "intake", + "done", + "bypass", + "Generic state field API must not mutate workflow_state.", + ); + + assert!(matches!( + backend.set_state_field( + TicketIdOrSlug::Id(ticket.id.clone()), + "workflow_state", + change + ), + Err(TicketError::Conflict(_)) + )); + let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake); + } + + #[test] + fn mark_intake_ready_records_summary_and_state_change() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend.create(NewTicket::new("Intake Ready")).unwrap(); + let mut summary = TicketIntakeSummary::new("Concise accepted requirements."); + summary.author = Some("intake".to_string()); + let mut change = + TicketStateChange::new("intake", "ready", "accepted", "Ticket is ready to queue."); + change.author = Some("intake".to_string()); + + backend + .mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change) + .unwrap(); + let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert!( + record + .events + .iter() + .any(|event| event.kind == TicketEventKind::IntakeSummary) + ); + assert!(record.events.iter().any(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + && event.from.as_deref() == Some("intake") + && event.to.as_deref() == Some("ready") + })); + } + + #[test] + fn close_sets_workflow_state_done() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut input = NewTicket::new("Close Workflow"); + input.workflow_state = Some(TicketWorkflowState::Queued); + let ticket = backend.create(input).unwrap(); + + backend + .close( + TicketIdOrSlug::Id(ticket.id.clone()), + MarkdownText::new("Completed."), + ) + .unwrap(); + let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); + assert!(record.events.iter().any(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + && event.to.as_deref() == Some("done") + })); + } + + #[test] + fn doctor_reports_invalid_workflow_state() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("tickets"); + fs::create_dir_all(root.join("open/bad/artifacts")).unwrap(); + fs::write( + root.join("open/bad/item.md"), + "---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nworkflow_state: almost\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n", + ) + .unwrap(); + fs::write(root.join("open/bad/thread.md"), "").unwrap(); + fs::create_dir_all(root.join("pending")).unwrap(); + fs::create_dir_all(root.join("closed")).unwrap(); + + let report = LocalTicketBackend::new(&root).doctor().unwrap(); + let messages = report + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect::>() + .join("\n"); + assert!(!report.is_ok()); + assert!(messages.contains("invalid workflow_state")); + } + #[test] fn doctor_validates_typed_thread_event_attributes() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index e754e220..ae43f264 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -14,8 +14,8 @@ use serde_json::{Value, json}; use crate::{ ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, - TicketEventKind, TicketIdOrSlug, TicketRef, TicketReview, TicketReviewResult, TicketStatus, - TicketSummary, + TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview, + TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 100; @@ -29,12 +29,14 @@ 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; 8] = [ +pub const TICKET_TOOL_NAMES: [&str; 10] = [ "TicketCreate", "TicketList", "TicketShow", "TicketComment", "TicketReview", + "TicketIntakeReady", + "TicketWorkflowState", "TicketStatus", "TicketClose", "TicketDoctor", @@ -54,6 +56,12 @@ const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` mu configured Ticket backend root."; const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \ `request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root."; +const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket intake as ready through the typed \ +Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \ +for `workflow_state`, and transitions workflow_state to `ready`."; +const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \ +Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \ +status; use `TicketStatus` or `TicketClose` for local status changes."; const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \ Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \ by `yoi ticket doctor`."; @@ -103,6 +111,40 @@ struct TicketCreateParams { /// Optional action-required frontmatter value. #[serde(default)] action_required: Option, + /// Optional workflow_state frontmatter value. Defaults to `intake`. + #[serde(default)] + workflow_state: Option, + /// Optional attention_required overlay frontmatter value. + #[serde(default)] + attention_required: Option, + /// Optional queued_by frontmatter value. + #[serde(default)] + queued_by: Option, + /// Optional queued_at frontmatter value. + #[serde(default)] + queued_at: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum TicketWorkflowStateParam { + Intake, + Ready, + Queued, + Inprogress, + Done, +} + +impl TicketWorkflowStateParam { + fn into_state(self) -> TicketWorkflowState { + match self { + Self::Intake => TicketWorkflowState::Intake, + Self::Ready => TicketWorkflowState::Ready, + Self::Queued => TicketWorkflowState::Queued, + Self::Inprogress => TicketWorkflowState::InProgress, + Self::Done => TicketWorkflowState::Done, + } + } } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -212,6 +254,40 @@ struct TicketStatusParams { status: TicketStatusParam, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketIntakeReadyParams { + /// Ticket id or slug. + ticket: String, + /// Concise bounded intake summary to append as a typed intake_summary event. + intake_summary: String, + /// Optional author for both intake_summary and state_changed events. + #[serde(default)] + author: Option, + /// Reason attached to the state_changed event. Defaults to `intake_ready`. + #[serde(default)] + reason: Option, + /// Optional state_changed body. If omitted, a concise default is used. + #[serde(default)] + state_change_body: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketWorkflowStateParams { + /// Ticket id or slug. + ticket: String, + /// Expected current workflow_state. The backend rejects stale transitions. + from: TicketWorkflowStateParam, + /// Target workflow_state. + to: TicketWorkflowStateParam, + /// Reason attached to the typed state_changed event. + reason: String, + /// Markdown body for the typed state_changed event. + body: String, + /// Optional thread author. + #[serde(default)] + author: Option, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketCloseParams { /// Ticket id or slug. @@ -278,6 +354,16 @@ struct TicketReviewTool { backend: LocalTicketBackend, } +#[derive(Clone)] +struct TicketIntakeReadyTool { + backend: LocalTicketBackend, +} + +#[derive(Clone)] +struct TicketWorkflowStateTool { + backend: LocalTicketBackend, +} + #[derive(Clone)] struct TicketStatusTool { backend: LocalTicketBackend, @@ -316,6 +402,12 @@ impl Tool for TicketCreateTool { input.needs_preflight = params.needs_preflight; input.risk_flags = params.risk_flags; input.action_required = params.action_required; + input.workflow_state = params + .workflow_state + .map(TicketWorkflowStateParam::into_state); + input.attention_required = params.attention_required; + input.queued_by = params.queued_by; + input.queued_at = params.queued_at; let created = self .backend @@ -470,6 +562,76 @@ impl Tool for TicketReviewTool { } } +#[async_trait] +impl Tool for TicketIntakeReadyTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?; + let from = TicketWorkflowState::Intake; + let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string()); + let body = params.state_change_body.unwrap_or_else(|| { + format!( + "Ticket intake complete; workflow_state {} -> ready.\n", + from.as_str() + ) + }); + let mut summary = TicketIntakeSummary::new(params.intake_summary); + summary.author = params.author.clone(); + let mut change = TicketStateChange::new( + from.as_str(), + TicketWorkflowState::Ready.as_str(), + reason, + body, + ); + change.author = params.author; + self.backend + .mark_intake_ready( + TicketIdOrSlug::Query(params.ticket.clone()), + summary, + change, + ) + .map_err(|error| backend_error("TicketIntakeReady", error))?; + Ok(json_output( + format!("Marked ticket {} workflow_state ready", params.ticket), + json!({ "ticket": params.ticket, "workflow_state": "ready", "ok": true }), + )) + } +} + +#[async_trait] +impl Tool for TicketWorkflowStateTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TicketWorkflowStateParams = parse_input("TicketWorkflowState", input_json)?; + let from = params.from.into_state(); + let to = params.to.into_state(); + if from == to { + return Err(ToolError::InvalidArgument( + "workflow_state transition must change state".to_string(), + )); + } + let mut change = + TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body); + change.author = params.author; + self.backend + .set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change) + .map_err(|error| backend_error("TicketWorkflowState", error))?; + Ok(json_output( + format!( + "Transitioned ticket {} workflow_state {} -> {}", + params.ticket, + from.as_str(), + to.as_str() + ), + json!({ + "ticket": params.ticket, + "from": from.as_str(), + "to": to.as_str(), + "workflow_state": to.as_str(), + "ok": true + }), + )) + } +} + #[async_trait] impl Tool for TicketStatusTool { async fn execute(&self, input_json: &str) -> Result { @@ -586,6 +748,11 @@ fn ticket_summary_json(ticket: TicketSummary) -> Value { "readiness": ticket.readiness, "needs_preflight": ticket.needs_preflight, "action_required": ticket.action_required, + "workflow_state": ticket.workflow_state.as_str(), + "workflow_state_explicit": ticket.workflow_state_explicit, + "attention_required": ticket.attention_required, + "queued_by": ticket.queued_by, + "queued_at": ticket.queued_at, "updated_at": ticket.updated_at, }) } @@ -641,6 +808,11 @@ fn ticket_json( "needs_preflight": ticket.meta.needs_preflight, "risk_flags": ticket.meta.risk_flags, "action_required": ticket.meta.action_required, + "workflow_state": ticket.meta.workflow_state.as_str(), + "workflow_state_explicit": ticket.meta.workflow_state_explicit, + "attention_required": ticket.meta.attention_required, + "queued_by": ticket.meta.queued_by, + "queued_at": ticket.meta.queued_at, }, "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { @@ -736,6 +908,10 @@ fn input_schema(name: &str) -> Value { "TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), "TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)), "TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)), + "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), + "TicketWorkflowState" => { + serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) + } "TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), "TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)), @@ -759,6 +935,8 @@ impl_from_backend!(TicketListTool); impl_from_backend!(TicketShowTool); impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketReviewTool); +impl_from_backend!(TicketIntakeReadyTool); +impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketStatusTool); impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketDoctorTool); @@ -771,6 +949,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec { tool_definition::("TicketShow", SHOW_DESCRIPTION, backend.clone()), tool_definition::("TicketComment", COMMENT_DESCRIPTION, backend.clone()), tool_definition::("TicketReview", REVIEW_DESCRIPTION, backend.clone()), + tool_definition::( + "TicketIntakeReady", + INTAKE_READY_DESCRIPTION, + backend.clone(), + ), + tool_definition::( + "TicketWorkflowState", + WORKFLOW_STATE_DESCRIPTION, + backend.clone(), + ), tool_definition::("TicketStatus", STATUS_DESCRIPTION, backend.clone()), tool_definition::("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::("TicketDoctor", DOCTOR_DESCRIPTION, backend), @@ -913,6 +1101,217 @@ mod tests { ); } + #[tokio::test] + async fn ticket_workflow_tools_mark_ready_and_transition_state() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend.create(NewTicket::new("Workflow Tool")).unwrap(); + let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); + let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); + + intake_ready + .execute( + &json!({ + "ticket": created.slug, + "intake_summary": "Requirements accepted; implementation can be queued.", + "author": "intake-pod" + }) + .to_string(), + ) + .await + .unwrap(); + backend + .queue_ready(TicketIdOrSlug::Id(created.id.clone()), "panel") + .unwrap(); + workflow + .execute( + &json!({ + "ticket": created.slug, + "from": "queued", + "to": "inprogress", + "reason": "orchestrator_started", + "body": "Orchestrator started implementation.\n", + "author": "orchestrator" + }) + .to_string(), + ) + .await + .unwrap(); + workflow + .execute( + &json!({ + "ticket": created.slug, + "from": "inprogress", + "to": "done", + "reason": "implementation_complete", + "body": "Implementation finished and is ready for close.\n", + "author": "orchestrator" + }) + .to_string(), + ) + .await + .unwrap(); + + let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); + assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open)); + assert!( + record + .events + .iter() + .any(|event| event.kind == TicketEventKind::IntakeSummary) + ); + let transitions = record + .events + .iter() + .filter(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + }) + .map(|event| (event.from.as_deref(), event.to.as_deref())) + .collect::>(); + assert_eq!( + transitions, + vec![ + (Some("intake"), Some("ready")), + (Some("ready"), Some("queued")), + (Some("queued"), Some("inprogress")), + (Some("inprogress"), Some("done")) + ] + ); + } + + #[tokio::test] + async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend + .create(NewTicket::new("Stale Workflow Tool")) + .unwrap(); + let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); + + let error = workflow + .execute( + &json!({ + "ticket": created.id, + "from": "queued", + "to": "inprogress", + "reason": "orchestrator_started", + "body": "Should not apply.\n" + }) + .to_string(), + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("workflow_state changed concurrently") + ); + let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake); + assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open)); + assert!(!record.events.iter().any(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + })); + } + + #[tokio::test] + async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); + + let mut ready_input = NewTicket::new("Ready Bypass"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let ready_error = workflow + .execute( + &json!({ + "ticket": ready.id, + "from": "ready", + "to": "inprogress", + "reason": "bypass_queue", + "body": "Should not bypass Queue.\n" + }) + .to_string(), + ) + .await + .unwrap_err(); + assert!(ready_error.to_string().contains("not allowed")); + + let mut done_input = NewTicket::new("Backward Bypass"); + done_input.workflow_state = Some(TicketWorkflowState::Done); + let done = backend.create(done_input).unwrap(); + let backward_error = workflow + .execute( + &json!({ + "ticket": done.id, + "from": "done", + "to": "intake", + "reason": "backwards", + "body": "Should not move backwards.\n" + }) + .to_string(), + ) + .await + .unwrap_err(); + assert!(backward_error.to_string().contains("not allowed")); + + let mut queued_input = NewTicket::new("Skip Bypass"); + queued_input.workflow_state = Some(TicketWorkflowState::Queued); + let queued = backend.create(queued_input).unwrap(); + let skip_error = workflow + .execute( + &json!({ + "ticket": queued.id, + "from": "queued", + "to": "done", + "reason": "skip_inprogress", + "body": "Should not skip inprogress.\n" + }) + .to_string(), + ) + .await + .unwrap_err(); + assert!(skip_error.to_string().contains("not allowed")); + } + + #[tokio::test] + async fn ticket_intake_ready_tool_rejects_non_intake_ticket() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let mut input = NewTicket::new("Already Ready"); + input.workflow_state = Some(TicketWorkflowState::Ready); + let created = backend.create(input).unwrap(); + let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady"); + + let error = intake_ready + .execute( + &json!({ + "ticket": created.id, + "intake_summary": "Should not rewrite ready ticket." + }) + .to_string(), + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("workflow_state changed concurrently") + ); + let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert!(!record.events.iter().any(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + })); + } + #[tokio::test] async fn ticket_show_requires_exactly_one_identifier() { let temp = TempDir::new().unwrap(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 744a6566..394e371d 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -23,7 +23,7 @@ use session_store::FsStore; use ticket::config::TicketConfig; use ticket::{ LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug, - TicketStatus, + TicketStatus, TicketWorkflowState, }; use tokio::net::UnixStream; use unicode_width::UnicodeWidthStr; @@ -1297,13 +1297,24 @@ async fn dispatch_ticket_action( } match request.action { - NextUserAction::Go | NextUserAction::ApproveIntake => { - append_panel_decision(&backend, &request.ticket_id, panel_go_body(current_ticket))?; + NextUserAction::Queue => { + if current_ticket.workflow_state != TicketWorkflowState::Ready { + return Err(TicketActionError::Stale( + "Queue is only valid while workflow_state is ready; reload and retry" + .to_string(), + )); + } + backend + .queue_ready( + TicketIdOrSlug::Id(request.ticket_id.clone()), + "workspace-panel", + ) + .map_err(|error| TicketActionError::Ticket(error.to_string()))?; let notification = notify_workspace_orchestrator(request.orchestrator, current_ticket).await; Ok(TicketActionOutcome { notice: format!( - "Recorded Panel Go for Ticket {}; {}. No implementation was started.", + "Queued Ticket {}; {}. No implementation was started.", current_ticket.slug, notification.sentence() ), @@ -1341,12 +1352,6 @@ async fn dispatch_ticket_action( }; Ok(TicketActionOutcome { notice }) } - NextUserAction::Review => Ok(TicketActionOutcome { - notice: format!( - "Review for Ticket {} requires explicit approve/request-changes evidence; no review was recorded.", - current_ticket.slug - ), - }), NextUserAction::Close => Ok(TicketActionOutcome { notice: format!( "Close for Ticket {} requires explicit resolution text; no close was recorded.", @@ -1379,16 +1384,9 @@ fn append_panel_decision( .map_err(|error| TicketActionError::Ticket(error.to_string())) } -fn panel_go_body(ticket: &crate::workspace_panel::TicketPanelEntry) -> String { - format!( - "Panel Go recorded by a human for Ticket `{}` (`{}`). The workspace Orchestrator may route or run preflight after re-checking current Ticket authority. This is not authorization to start implementation directly and does not enqueue or spawn coder/reviewer Pods.", - ticket.slug, ticket.id - ) -} - fn panel_defer_body(ticket: &crate::workspace_panel::TicketPanelEntry) -> String { format!( - "Panel Defer recorded by a human for Ticket `{}` (`{}`). Keep this Ticket out of immediate Orchestrator routing until a later explicit Go; no scheduler or implementation Pod was started.", + "Panel Defer recorded by a human for Ticket `{}` (`{}`). Keep this Ticket out of immediate Orchestrator routing until a later explicit Queue; no scheduler or implementation Pod was started.", ticket.slug, ticket.id ) } @@ -1403,7 +1401,7 @@ async fn notify_workspace_orchestrator( ); }; let message = format!( - "Workspace panel Go for Ticket `{}` (`{}`): human authorized Orchestrator routing/preflight. Re-check Ticket authority before acting. Do not start implementation directly from this notification; follow routing/preflight gates.", + "Workspace panel Queue for Ticket `{}` (`{}`): human authorized Orchestrator routing/preflight. Re-check Ticket authority before acting. Do not start implementation directly from this notification; follow routing/preflight gates.", ticket.slug, ticket.id ); match send_notify_only(&target.socket_path, message).await { @@ -1941,7 +1939,7 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { } else { format!(" {total} rows") }; - let text = truncate_with_ellipsis(&format!("--actions{detail}---"), width as usize); + let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize); Line::from(Span::styled( text, Style::default() @@ -1950,14 +1948,9 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { )) } -const TICKET_PRIORITY_COLUMN_WIDTH: usize = 11; -const TICKET_ACTION_COLUMN_WIDTH: usize = 7; -const TICKET_STATUS_COLUMN_WIDTH: usize = 24; -const TICKET_PHASE_COLUMN_WIDTH: usize = 12; +const TICKET_STATE_COLUMN_WIDTH: usize = 10; const TICKET_ID_COLUMN_WIDTH: usize = 32; const POD_STATUS_COLUMN_WIDTH: usize = 18; -const POD_ACTION_COLUMN_WIDTH: usize = 8; -const POD_KIND_COLUMN_WIDTH: usize = 3; fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { let marker = if selected { "▶ " } else { " " }; @@ -1968,12 +1961,6 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { } else { Style::default().fg(Color::Magenta) }; - let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); - let phase = row - .ticket - .as_ref() - .map(|ticket| ticket.phase.label()) - .unwrap_or("-"); let ticket_ref = panel_ticket_reference(row); let mut spans = Vec::new(); let mut remaining = width as usize; @@ -1990,32 +1977,11 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { }, &mut remaining, ); - push_column_span( - &mut spans, - row.priority.label(), - TICKET_PRIORITY_COLUMN_WIDTH, - panel_priority_style(row.priority), - &mut remaining, - ); - push_column_span( - &mut spans, - action, - TICKET_ACTION_COLUMN_WIDTH, - Style::default().fg(Color::Magenta), - &mut remaining, - ); push_column_span( &mut spans, &row.status, - TICKET_STATUS_COLUMN_WIDTH, - Style::default().fg(Color::DarkGray), - &mut remaining, - ); - push_column_span( - &mut spans, - phase, - TICKET_PHASE_COLUMN_WIDTH, - Style::default().fg(Color::DarkGray), + TICKET_STATE_COLUMN_WIDTH, + panel_priority_style(row.priority), &mut remaining, ); push_column_span( @@ -2085,10 +2051,7 @@ fn padded_cell(value: &str, width: usize) -> String { fn panel_priority_style(priority: ActionPriority) -> Style { match priority { ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ActionPriority::ReadyForGo => Style::default().fg(Color::Green), - ActionPriority::Decision => Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), + ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), ActionPriority::Blocked => Style::default().fg(Color::Red), ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), ActionPriority::Background => Style::default().fg(Color::DarkGray), @@ -2132,13 +2095,6 @@ fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { Style::default().fg(Color::Cyan) }; let (status, status_style) = row_status_label(entry); - let action = if entry.actions.can_send_now { - "send" - } else if entry.actions.can_open { - "open" - } else { - "disabled" - }; let mut spans = Vec::new(); let mut remaining = width as usize; @@ -2161,20 +2117,6 @@ fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { status_style, &mut remaining, ); - push_column_span( - &mut spans, - action, - POD_ACTION_COLUMN_WIDTH, - Style::default().fg(Color::DarkGray), - &mut remaining, - ); - push_column_span( - &mut spans, - "pod", - POD_KIND_COLUMN_WIDTH, - Style::default().fg(Color::DarkGray), - &mut remaining, - ); push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining); Line::from(spans) @@ -2191,80 +2133,48 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) { } fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let mut line = if let Some(row) = app + let target = if let Some(row) = app .selected_panel_row() .filter(|row| row.is_ticket_action()) { + let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); Line::from(vec![ - Span::styled("action ", Style::default().fg(Color::DarkGray)), + Span::styled("composer ", Style::default().fg(Color::DarkGray)), Span::styled( - row.title.clone(), - Style::default().add_modifier(Modifier::BOLD), + app.composer_target().label(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), ), - Span::raw(" "), + Span::styled(" · ticket ", Style::default().fg(Color::DarkGray)), + Span::styled(row.status.clone(), panel_priority_style(row.priority)), + Span::styled(" · ", Style::default().fg(Color::DarkGray)), + Span::styled(action, Style::default().fg(Color::Magenta)), + ]) + } else if let Some(entry) = app.selected_pod_entry() { + let (status, status_style) = row_status_label(entry); + Line::from(vec![ + Span::styled("composer ", Style::default().fg(Color::DarkGray)), Span::styled( - format!("[{}]", row.priority.label()), - panel_priority_style(row.priority), - ), - Span::raw(" "), - Span::styled( - row.next_action.map(NextUserAction::label).unwrap_or("View"), - Style::default().fg(Color::Magenta), - ), - Span::styled( - " dispatch via Enter; re-checks Ticket before mutation", - Style::default().fg(Color::DarkGray), + app.composer_target().label(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), ), + Span::styled(" · pod ", Style::default().fg(Color::DarkGray)), + Span::styled(status.to_string(), status_style), ]) } else { - match app.selected_pod_entry() { - Some(entry) => { - let (status, status_style) = row_status_label(entry); - let send_text = if entry.actions.can_send_now { - "send enabled" - } else { - "send disabled" - }; - Line::from(vec![ - Span::styled("target ", Style::default().fg(Color::DarkGray)), - Span::styled( - entry.name.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(format!("[{status}]"), status_style), - Span::raw(" "), - Span::styled( - send_text, - if entry.actions.can_send_now { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }, - ), - ]) - } - None => Line::from(Span::styled( - "target — none", + Line::from(vec![ + Span::styled("composer ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), Style::default().fg(Color::DarkGray), - )), - } + ), + Span::styled(" · no selection", Style::default().fg(Color::DarkGray)), + ]) }; - let mut prefix = vec![ - Span::styled("composer ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default() - .fg(match app.composer_target() { - ComposerTarget::Companion => Color::Green, - ComposerTarget::TicketIntake => Color::Magenta, - }) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · ", Style::default().fg(Color::DarkGray)), - ]; - prefix.append(&mut line.spans); - frame.render_widget(Paragraph::new(Line::from(prefix)), area); + frame.render_widget(Paragraph::new(target), area); } fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { @@ -2364,7 +2274,10 @@ mod tests { use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; use std::fs; use tempfile::TempDir; - use ticket::{LocalTicketBackend, MarkdownText, NewTicket, TicketBackend, TicketReview}; + use ticket::{ + LocalTicketBackend, MarkdownText, NewTicket, TicketBackend, TicketEventKind, TicketReview, + TicketStateChange, TicketWorkflowState, + }; fn ready_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) { let temp = TempDir::new().unwrap(); @@ -2385,9 +2298,13 @@ mod tests { author: None, assignee: None, labels: Vec::new(), - readiness: Some("ready".to_string()), + readiness: None, action_required: None, - needs_preflight: Some(true), + workflow_state: Some(TicketWorkflowState::Ready), + attention_required: None, + queued_by: None, + queued_at: None, + needs_preflight: None, risk_flags: Vec::new(), legacy_ticket: None, }) @@ -2409,28 +2326,32 @@ mod tests { } #[tokio::test] - async fn ticket_go_action_records_decision_without_starting_implementation() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-go"); + async fn ticket_queue_action_transitions_ready_ticket_without_starting_implementation() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-queue"); let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Go)) + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) .await .unwrap(); - assert!(outcome.notice.contains("Recorded Panel Go")); + assert!(outcome.notice.contains("Queued Ticket")); assert!(outcome.notice.contains("No implementation was started")); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open)); - let decision = ticket + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); + assert!(ticket.meta.queued_at.is_some()); + let state_change = ticket .events .iter() .find(|event| { - event.kind == TicketEventKind::Decision - && event.body.as_str().contains("Panel Go recorded") + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("queued") }) - .expect("panel Go decision is recorded"); - assert_eq!(decision.author.as_deref(), Some("workspace-panel")); - assert!(decision.body.as_str().contains("does not enqueue or spawn")); + .expect("queue state_changed event is recorded"); + assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); } #[tokio::test] @@ -2441,7 +2362,7 @@ mod tests { .await .unwrap_err(); - assert!(error.to_string().contains("current action is Go")); + assert!(error.to_string().contains("current action is Queue")); } #[tokio::test] @@ -2450,15 +2371,17 @@ mod tests { fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap(); let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Go)) + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) .await .unwrap_err(); assert!(error.to_string().contains("Ticket config is absent")); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); + assert!(ticket.meta.queued_by.is_none()); assert!(!ticket.events.iter().any(|event| { - event.kind == TicketEventKind::Decision - && event.body.as_str().contains("Panel Go recorded") + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("workflow_state") })); } @@ -2498,6 +2421,31 @@ mod tests { TicketReview::approve("reviewed"), ) .unwrap(); + backend + .queue_ready(TicketIdOrSlug::Id(ticket_id.clone()), "panel") + .unwrap(); + backend + .set_workflow_state( + TicketIdOrSlug::Id(ticket_id.clone()), + TicketStateChange::new( + "queued", + "inprogress", + "implemented", + "Implementation started.", + ), + ) + .unwrap(); + backend + .set_workflow_state( + TicketIdOrSlug::Id(ticket_id.clone()), + TicketStateChange::new( + "inprogress", + "done", + "implemented", + "Ready for close diagnostic.", + ), + ) + .unwrap(); let outcome = dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) @@ -2520,19 +2468,12 @@ mod tests { ) .unwrap(); - let outcome = dispatch_ticket_action(request_for( - &temp, - ticket_id.clone(), - NextUserAction::Review, - )) - .await - .unwrap(); + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait)) + .await + .unwrap_err(); - assert!( - outcome - .notice - .contains("requires explicit approve/request-changes") - ); + assert!(error.to_string().contains("current action is Queue")); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); assert!( !ticket @@ -2543,7 +2484,7 @@ mod tests { } #[tokio::test] - async fn ticket_go_notification_sends_notify_when_socket_available() { + async fn ticket_queue_notification_sends_notify_when_socket_available() { let temp = TempDir::new().unwrap(); let socket_path = temp.path().join("orchestrator.sock"); let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); @@ -2572,13 +2513,13 @@ mod tests { reader.next::().await.unwrap().unwrap() }); - send_notify_only(&socket_path, "panel Go".to_string()) + send_notify_only(&socket_path, "panel Queue".to_string()) .await .unwrap(); let method = server.await.unwrap(); assert!(matches!( method, - Method::Notify { message } if message == "panel Go" + Method::Notify { message } if message == "panel Queue" )); } @@ -2810,38 +2751,27 @@ mod tests { let review_row = panel_test_ticket_row( "workspace-panel-composer-targets", "Workspace panel composer targets", - ActionPriority::Decision, - NextUserAction::Review, - "implementation reported", - crate::workspace_panel::TicketPanelPhase::Reviewing, + ActionPriority::ActiveWork, + NextUserAction::Wait, + "inprogress", ); let ready_row = panel_test_ticket_row( "ticket-slug", "Long Ticket title that should be rendered after short columns", - ActionPriority::ReadyForGo, - NextUserAction::Go, - "ready for Go", - crate::workspace_panel::TicketPanelPhase::Preflight, + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", ); let review_line = plain_line(&panel_row_line(&review_row, true, 160)); let ready_line = plain_line(&panel_row_line(&ready_row, false, 160)); - let action_start = 2 + TICKET_PRIORITY_COLUMN_WIDTH + 1; - let status_start = action_start + TICKET_ACTION_COLUMN_WIDTH + 1; - let phase_start = status_start + TICKET_STATUS_COLUMN_WIDTH + 1; - let id_start = phase_start + TICKET_PHASE_COLUMN_WIDTH + 1; + let state_start = 2; + let id_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1; assert!(!review_line.starts_with("▶ Workspace panel composer targets")); - assert_eq!(display_column(&review_line, "Review"), action_start); - assert_eq!(display_column(&ready_line, "Go"), action_start); - assert_eq!( - display_column(&review_line, "implementation reported"), - status_start - ); - assert_eq!(display_column(&ready_line, "ready for Go"), status_start); - assert_eq!(display_column(&review_line, "review"), phase_start); - assert_eq!(display_column(&ready_line, "preflight"), phase_start); + assert_eq!(display_column(&review_line, "inprogress"), state_start); + assert_eq!(display_column(&ready_line, "ready"), state_start); assert_eq!( display_column(&review_line, "workspace-panel-composer-targets"), id_start @@ -2862,24 +2792,13 @@ mod tests { let row = panel_test_ticket_row( "ticket-slug", "Very long Ticket title that should truncate only after the aligned short columns", - ActionPriority::ReadyForGo, - NextUserAction::Go, - "ready for Go", - crate::workspace_panel::TicketPanelPhase::Preflight, + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", ); let line = plain_line(&panel_row_line(&row, false, 112)); - let title_start = 2 - + TICKET_PRIORITY_COLUMN_WIDTH - + 1 - + TICKET_ACTION_COLUMN_WIDTH - + 1 - + TICKET_STATUS_COLUMN_WIDTH - + 1 - + TICKET_PHASE_COLUMN_WIDTH - + 1 - + TICKET_ID_COLUMN_WIDTH - + 1; + let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1; assert_eq!(line.width(), 112); assert_eq!( @@ -2911,15 +2830,11 @@ mod tests { let idle_line = plain_line(&row_line(idle, false, 120)); let running_line = plain_line(&row_line(running, false, 120)); - let action_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; - let kind_start = action_start + POD_ACTION_COLUMN_WIDTH + 1; - let name_start = kind_start + POD_KIND_COLUMN_WIDTH + 1; + let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; assert!(!running_line.starts_with(" very-long-background-worker-name")); - assert_eq!(display_column(&idle_line, "send"), action_start); - assert_eq!(display_column(&running_line, "open"), action_start); - assert_eq!(display_column(&idle_line, "pod"), kind_start); - assert_eq!(display_column(&running_line, "pod"), kind_start); + assert_eq!(display_column(&idle_line, "live idle"), 2); + assert_eq!(display_column(&running_line, "live running"), 2); assert_eq!(display_column(&idle_line, "companion"), name_start); assert_eq!( display_column(&running_line, "very-long-background-worker-name"), @@ -2928,7 +2843,7 @@ mod tests { } #[test] - fn panel_pod_name_truncates_after_status_action_and_kind() { + fn panel_pod_name_truncates_after_status() { let app = test_app(vec![live_info( "very-long-background-worker-name-that-keeps-going", PodStatus::Running, @@ -2936,23 +2851,10 @@ mod tests { let entry = app.list.selected_entry().unwrap(); let line = plain_line(&row_line(entry, false, 58)); - let name_start = 2 - + POD_STATUS_COLUMN_WIDTH - + 1 - + POD_ACTION_COLUMN_WIDTH - + 1 - + POD_KIND_COLUMN_WIDTH - + 1; + let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; assert_eq!(line.width(), 58); - assert_eq!( - display_column(&line, "open"), - 2 + POD_STATUS_COLUMN_WIDTH + 1 - ); - assert_eq!( - display_column(&line, "pod"), - name_start - POD_KIND_COLUMN_WIDTH - 1 - ); + assert_eq!(display_column(&line, "live running"), 2); assert_eq!(display_column(&line, "very-long"), name_start); assert!(line.ends_with('…')); } @@ -3556,7 +3458,6 @@ mod tests { priority: ActionPriority, next_action: NextUserAction, status: &str, - phase: crate::workspace_panel::TicketPanelPhase, ) -> PanelRow { let ticket = crate::workspace_panel::TicketPanelEntry { id: format!("20260606-000000-{slug}"), @@ -3566,7 +3467,10 @@ mod tests { kind: "task".to_string(), priority: "P2".to_string(), labels: Vec::new(), - phase, + workflow_state: TicketWorkflowState::parse(status) + .unwrap_or(TicketWorkflowState::Intake), + workflow_state_explicit: true, + attention_required: None, next_action: Some(next_action), updated_at: None, latest_event_kind: Some("implementation_report".to_string()), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index b89a126e..c6babc78 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -4,8 +4,7 @@ use protocol::PodStatus; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::{ ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, - TicketEventKind, TicketFilter, TicketIdOrSlug, TicketMeta, TicketReviewResult, TicketStatus, - TicketSummary, + TicketFilter, TicketIdOrSlug, TicketMeta, TicketStatus, TicketSummary, TicketWorkflowState, }; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; @@ -152,32 +151,16 @@ pub(crate) enum PanelRowKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum ActionPriority { UserReply, - ReadyForGo, - Decision, + ReadyForQueue, Blocked, ActiveWork, Background, } -impl ActionPriority { - pub(crate) fn label(self) -> &'static str { - match self { - Self::UserReply => "user action", - Self::ReadyForGo => "ready", - Self::Decision => "decision", - Self::Blocked => "blocked", - Self::ActiveWork => "active", - Self::Background => "background", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NextUserAction { Clarify, - ApproveIntake, - Go, - Review, + Queue, Close, Defer, Edit, @@ -190,9 +173,7 @@ impl NextUserAction { pub(crate) fn label(self) -> &'static str { match self { Self::Clarify => "Clarify", - Self::ApproveIntake => "Approve", - Self::Go => "Go", - Self::Review => "Review", + Self::Queue => "Queue", Self::Close => "Close", Self::Defer => "Defer", Self::Edit => "Edit", @@ -203,37 +184,6 @@ impl NextUserAction { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TicketPanelPhase { - Intake, - RequirementsSync, - Preflight, - Spike, - Implementing, - Reviewing, - CloseReady, - Blocked, - Open, - Pending, -} - -impl TicketPanelPhase { - pub(crate) fn label(self) -> &'static str { - match self { - Self::Intake => "intake", - Self::RequirementsSync => "requirements", - Self::Preflight => "preflight", - Self::Spike => "spike", - Self::Implementing => "implementing", - Self::Reviewing => "review", - Self::CloseReady => "close-ready", - Self::Blocked => "blocked", - Self::Open => "open", - Self::Pending => "pending", - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketPanelEntry { pub(crate) id: String, @@ -243,7 +193,9 @@ pub(crate) struct TicketPanelEntry { pub(crate) kind: String, pub(crate) priority: String, pub(crate) labels: Vec, - pub(crate) phase: TicketPanelPhase, + pub(crate) workflow_state: TicketWorkflowState, + pub(crate) workflow_state_explicit: bool, + pub(crate) attention_required: Option, pub(crate) next_action: Option, pub(crate) updated_at: Option, pub(crate) latest_event_kind: Option, @@ -270,7 +222,6 @@ pub(crate) struct PanelRow { impl PanelRow { pub(crate) fn is_ticket_action(&self) -> bool { !matches!(self.kind, PanelRowKind::Pod) - && (self.priority != ActionPriority::Background || self.next_action.is_some()) } } @@ -500,6 +451,11 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { readiness: meta.readiness.clone(), needs_preflight: meta.needs_preflight, action_required: meta.action_required.clone(), + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + attention_required: meta.attention_required.clone(), + queued_by: meta.queued_by.clone(), + queued_at: meta.queued_at.clone(), updated_at: meta.updated_at.clone(), } } @@ -521,7 +477,7 @@ fn build_ticket_rows( fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow { let related_pods = related_pods_for_ticket(&summary, pods); - let derived = derive_ticket_state(&summary, events); + let derived = derive_ticket_state(&summary); let latest_event = events.last(); let entry = TicketPanelEntry { id: summary.id.clone(), @@ -531,7 +487,9 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> kind: summary.kind.clone(), priority: summary.priority.clone(), labels: summary.labels.clone(), - phase: derived.phase, + workflow_state: summary.workflow_state, + workflow_state_explicit: summary.workflow_state_explicit, + attention_required: summary.attention_required.clone(), next_action: derived.action, updated_at: summary.updated_at.clone(), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), @@ -545,7 +503,7 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> kind: derived.kind, title: summary.title, subtitle, - status: derived.status, + status: summary.workflow_state.as_str().to_string(), priority: derived.priority, next_action: derived.action, ticket: Some(entry), @@ -558,8 +516,6 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> #[derive(Debug, Clone, PartialEq, Eq)] struct DerivedTicketState { kind: PanelRowKind, - phase: TicketPanelPhase, - status: String, priority: ActionPriority, action: Option, disabled_reason: Option, @@ -567,239 +523,91 @@ struct DerivedTicketState { blocked_reason: Option, } -fn derive_ticket_state(summary: &TicketSummary, events: &[TicketEvent]) -> DerivedTicketState { - let action_required = summary.action_required.as_deref().map(str::trim); - let action_required_lc = action_required.map(lowercase); - let intake = is_intake_ticket(summary); - let spike = is_spike_ticket(summary); - - if let Some(reason) = action_required_lc.as_deref() { - if reason.contains("block") || reason.contains("blocked") { - return DerivedTicketState { - kind: PanelRowKind::Blocked, - phase: TicketPanelPhase::Blocked, - status: "blocked".to_string(), - priority: ActionPriority::Blocked, - action: Some(NextUserAction::Edit), - disabled_reason: Some( - "Requires an explicit human/project decision before work continues." - .to_string(), - ), - key_hint: Some("Edit/decide in Ticket; no automatic unblock".to_string()), - blocked_reason: action_required.map(ToOwned::to_owned), - }; - } - return DerivedTicketState { - kind: if intake { - PanelRowKind::Intake - } else { - PanelRowKind::Ticket - }, - phase: if intake { - TicketPanelPhase::Intake - } else { - TicketPanelPhase::RequirementsSync - }, - status: action_required.unwrap_or("action required").to_string(), - priority: ActionPriority::UserReply, - action: Some(if intake { - NextUserAction::ApproveIntake - } else { - NextUserAction::Clarify - }), - disabled_reason: None, - key_hint: Some( - "Human response is required; dispatch must re-check Ticket state".to_string(), - ), - blocked_reason: None, - }; - } - - let latest_impl = latest_event_index(events, TicketEventKind::ImplementationReport); - let latest_review = latest_event_index(events, TicketEventKind::Review); - let latest_plan = latest_event_index(events, TicketEventKind::Plan); - let latest_review_result = latest_review.and_then(|index| events[index].status.as_deref()); - - if latest_review_result == Some(TicketReviewResult::Approve.as_str()) - && latest_review > latest_impl - { - return DerivedTicketState { - kind: PanelRowKind::Review, - phase: TicketPanelPhase::CloseReady, - status: "review approved".to_string(), - priority: ActionPriority::Decision, - action: Some(NextUserAction::Close), - disabled_reason: None, - key_hint: Some("Close affordance only; closing must write a resolution".to_string()), - blocked_reason: None, - }; - } - - if latest_impl.is_some() && latest_impl > latest_review { - return DerivedTicketState { - kind: PanelRowKind::Review, - phase: TicketPanelPhase::Reviewing, - status: "implementation reported".to_string(), - priority: ActionPriority::Decision, - action: Some(NextUserAction::Review), - disabled_reason: None, - key_hint: Some("Review affordance only; inspect evidence before approving".to_string()), - blocked_reason: None, - }; - } - - if latest_review_result == Some(TicketReviewResult::RequestChanges.as_str()) { - return DerivedTicketState { - kind: PanelRowKind::ActiveWork, - phase: TicketPanelPhase::Implementing, - status: "changes requested".to_string(), - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Waiting for implementation changes after review.".to_string()), - key_hint: None, - blocked_reason: None, - }; - } - +fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState { if summary.status.as_local() == Some(TicketStatus::Pending) { return DerivedTicketState { kind: PanelRowKind::Blocked, - phase: TicketPanelPhase::Pending, - status: "pending/deferred".to_string(), priority: ActionPriority::Blocked, action: Some(NextUserAction::Defer), disabled_reason: Some( - "Pending Ticket is shown for visibility; no automation is implied.".to_string(), + "Pending Ticket is deferred; queueing is disabled until it is reopened and readied." + .to_string(), ), - key_hint: None, + key_hint: Some("Open/defer operation lives in Ticket controls".to_string()), blocked_reason: None, }; } - if intake { + if let Some(reason) = summary + .attention_required + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { return DerivedTicketState { - kind: PanelRowKind::Intake, - phase: TicketPanelPhase::Intake, - status: "intake draft".to_string(), + kind: PanelRowKind::Blocked, priority: ActionPriority::UserReply, - action: Some(NextUserAction::ApproveIntake), - disabled_reason: None, - key_hint: Some("Approve/edit intake before routing".to_string()), - blocked_reason: None, + action: Some(NextUserAction::Edit), + disabled_reason: Some( + "attention_required is set; resolve it before queueing or routing.".to_string(), + ), + key_hint: Some( + "Resolve attention_required in the Ticket frontmatter/thread".to_string(), + ), + blocked_reason: Some(reason.to_string()), }; } - if looks_ready_for_go(summary) { - return DerivedTicketState { + match summary.workflow_state { + TicketWorkflowState::Ready => DerivedTicketState { kind: PanelRowKind::Ticket, - phase: if summary.needs_preflight.unwrap_or(false) { - TicketPanelPhase::Preflight - } else { - TicketPanelPhase::Open - }, - status: "ready for Go".to_string(), - priority: ActionPriority::ReadyForGo, - action: Some(NextUserAction::Go), + priority: ActionPriority::ReadyForQueue, + action: Some(NextUserAction::Queue), disabled_reason: None, key_hint: Some( - "Go is an authorization affordance; routing/preflight gates still apply" - .to_string(), + "Queue transitions ready -> queued and may notify Orchestrator".to_string(), ), blocked_reason: None, - }; - } - - if spike && latest_plan.is_some() { - return DerivedTicketState { + }, + TicketWorkflowState::Queued => DerivedTicketState { kind: PanelRowKind::ActiveWork, - phase: TicketPanelPhase::Spike, - status: "spike running".to_string(), priority: ActionPriority::ActiveWork, action: Some(NextUserAction::Wait), - disabled_reason: Some("Spike has a plan but no implementation report yet.".to_string()), + disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), key_hint: None, blocked_reason: None, - }; - } - - if spike { - return DerivedTicketState { - kind: PanelRowKind::Ticket, - phase: TicketPanelPhase::Spike, - status: "spike needed".to_string(), + }, + TicketWorkflowState::InProgress => DerivedTicketState { + kind: PanelRowKind::ActiveWork, + priority: ActionPriority::ActiveWork, + action: Some(NextUserAction::Wait), + disabled_reason: Some("Ticket is already in progress.".to_string()), + key_hint: None, + blocked_reason: None, + }, + TicketWorkflowState::Done => DerivedTicketState { + kind: PanelRowKind::Review, priority: ActionPriority::Background, - action: None, + action: Some(NextUserAction::Close), disabled_reason: Some( - "Spike candidate is shown as background until explicitly readied or planned." - .to_string(), + "workflow_state is done; close if a resolution is still missing.".to_string(), ), key_hint: None, blocked_reason: None, - }; - } - - if latest_plan.is_some() { - return DerivedTicketState { - kind: PanelRowKind::ActiveWork, - phase: TicketPanelPhase::Implementing, - status: "planned/active".to_string(), - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), + }, + TicketWorkflowState::Intake => DerivedTicketState { + kind: PanelRowKind::Intake, + priority: ActionPriority::Background, + action: Some(NextUserAction::Clarify), disabled_reason: Some( - "Ticket has a plan but no implementation report yet.".to_string(), + "Ticket is still in intake; mark it ready before queueing.".to_string(), + ), + key_hint: Some( + "Intake/Orchestrator helpers can set workflow_state = ready".to_string(), ), - key_hint: None, blocked_reason: None, - }; + }, } - - DerivedTicketState { - kind: PanelRowKind::Ticket, - phase: TicketPanelPhase::Open, - status: "open backlog".to_string(), - priority: ActionPriority::Background, - action: None, - disabled_reason: Some( - "Open Ticket is not marked ready; keep it out of the action section for now." - .to_string(), - ), - key_hint: None, - blocked_reason: None, - } -} - -fn looks_ready_for_go(summary: &TicketSummary) -> bool { - summary - .readiness - .as_deref() - .map(lowercase) - .is_some_and(|value| value.contains("ready")) - || summary.needs_preflight.unwrap_or(false) - || summary - .labels - .iter() - .any(|label| lowercase(label).contains("ready")) -} - -fn is_intake_ticket(summary: &TicketSummary) -> bool { - summary.kind == "intake" - || summary.labels.iter().any(|label| label == "intake") - || lowercase(&summary.slug).contains("intake") - || lowercase(&summary.title).contains("intake") -} - -fn is_spike_ticket(summary: &TicketSummary) -> bool { - lowercase(&summary.kind).contains("spike") - || summary - .labels - .iter() - .any(|label| lowercase(label).contains("spike")) - || lowercase(&summary.slug).contains("spike") - || lowercase(&summary.title).contains("spike") -} - -fn latest_event_index(events: &[TicketEvent], kind: TicketEventKind) -> Option { - events.iter().rposition(|event| event.kind == kind) } fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec { @@ -822,11 +630,13 @@ fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec Option { let mut parts = vec![format!( - "{} · {} · {}", + "{} · {}", entry.slug, - entry.phase.label(), - entry.priority + entry.workflow_state.as_str() )]; + if let Some(reason) = entry.attention_required.as_deref() { + parts.push(format!("attention: {reason}")); + } if !entry.related_pods.is_empty() { parts.push(format!("pods: {}", entry.related_pods.join(", "))); } @@ -941,7 +751,7 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; - use ticket::{MarkdownText, NewTicket, NewTicketEvent, TicketReview}; + use ticket::{NewTicket, TicketWorkflowState}; fn empty_pods() -> PodList { PodList::from_sources( @@ -1021,16 +831,16 @@ mod tests { } #[test] - fn workspace_panel_prioritizes_human_actions_before_background_pods() { + fn workspace_panel_uses_explicit_workflow_state_for_queue_priority() { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket(&backend, "Ready Ticket", "ready-ticket", |input| { - input.readiness = Some("implementation-ready".to_string()); + input.workflow_state = Some(TicketWorkflowState::Ready); }); create_ticket(&backend, "Needs User", "needs-user", |input| { - input.action_required = Some("answer clarification".to_string()); - input.labels = vec!["intake".to_string()]; + input.workflow_state = Some(TicketWorkflowState::Ready); + input.attention_required = Some("answer clarification".to_string()); }); let model = build_workspace_panel(temp.path(), &empty_pods()); @@ -1041,128 +851,99 @@ mod tests { let rows = model .rows .iter() - .map(|row| (row.title.as_str(), row.priority, row.next_action)) + .map(|row| { + ( + row.title.as_str(), + row.status.as_str(), + row.priority, + row.next_action, + ) + }) .collect::>(); assert_eq!(rows[0].0, "Needs User"); - assert_eq!(rows[0].1, ActionPriority::UserReply); - assert_eq!(rows[0].2, Some(NextUserAction::ApproveIntake)); + assert_eq!(rows[0].1, "ready"); + assert_eq!(rows[0].2, ActionPriority::UserReply); + assert_eq!(rows[0].3, Some(NextUserAction::Edit)); assert_eq!(rows[1].0, "Ready Ticket"); - assert_eq!(rows[1].1, ActionPriority::ReadyForGo); - assert_eq!(rows[1].2, Some(NextUserAction::Go)); + assert_eq!(rows[1].1, "ready"); + assert_eq!(rows[1].2, ActionPriority::ReadyForQueue); + assert_eq!(rows[1].3, Some(NextUserAction::Queue)); } #[test] - fn workspace_panel_derives_spike_phase_without_marking_unready_spikes_ready_for_go() { + fn workspace_panel_does_not_infer_workflow_state_from_labels_readiness_or_thread() { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket( &backend, - "Investigate Spike", - "investigate-spike", + "Readiness Heuristic", + "readiness-heuristic", |input| { - input.labels = vec!["spike".to_string()]; + input.readiness = Some("implementation-ready".to_string()); + input.needs_preflight = Some(false); }, ); - create_ticket(&backend, "Running Spike", "running-spike", |input| { - input.kind = "spike".to_string(); + create_ticket(&backend, "Label Heuristic", "label-heuristic", |input| { + input.labels = vec!["spike".to_string(), "intake".to_string()]; + }); + create_ticket(&backend, "Queued Explicit", "queued-explicit", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); }); - backend - .add_event( - TicketIdOrSlug::Query("running-spike".to_string()), - NewTicketEvent::new(TicketEventKind::Plan, "Run the spike."), - ) - .unwrap(); let model = build_workspace_panel(temp.path(), &empty_pods()); - let needed = model + let readiness = model .rows .iter() - .find(|row| row.title == "Investigate Spike") + .find(|row| row.title == "Readiness Heuristic") .unwrap(); - let running = model + let label = model .rows .iter() - .find(|row| row.title == "Running Spike") + .find(|row| row.title == "Label Heuristic") + .unwrap(); + let queued = model + .rows + .iter() + .find(|row| row.title == "Queued Explicit") .unwrap(); - assert_eq!( - needed.ticket.as_ref().unwrap().phase, - TicketPanelPhase::Spike - ); - assert_eq!(needed.priority, ActionPriority::Background); - assert_eq!(needed.next_action, None); - assert!(!needed.is_ticket_action()); - assert_eq!( - running.ticket.as_ref().unwrap().phase, - TicketPanelPhase::Spike - ); - assert_eq!(running.priority, ActionPriority::ActiveWork); - assert_eq!(running.next_action, Some(NextUserAction::Wait)); + assert_eq!(readiness.status, "intake"); + assert_eq!(readiness.next_action, Some(NextUserAction::Clarify)); + assert_eq!(label.status, "intake"); + assert_eq!(label.next_action, Some(NextUserAction::Clarify)); + assert_eq!(queued.status, "queued"); + assert_eq!(queued.next_action, Some(NextUserAction::Wait)); } #[test] - fn workspace_panel_keeps_ordinary_open_backlog_out_of_action_section() { + fn workspace_panel_defaults_missing_open_state_to_intake_and_displays_done_state() { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {}); + create_ticket(&backend, "Done Explicit", "done-explicit", |input| { + input.workflow_state = Some(TicketWorkflowState::Done); + }); let model = build_workspace_panel(temp.path(), &empty_pods()); - let row = model + let backlog = model .rows .iter() .find(|row| row.title == "Plain Backlog") .unwrap(); - - assert_eq!(row.priority, ActionPriority::Background); - assert_eq!(row.next_action, None); - assert!(!row.is_ticket_action()); - } - - #[test] - fn workspace_panel_derives_review_and_close_actions_from_thread_roles() { - let temp = TempDir::new().unwrap(); - write_ticket_config(temp.path()); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket(&backend, "Needs Review", "needs-review", |_| {}); - create_ticket(&backend, "Close Ready", "close-ready", |_| {}); - backend - .add_event( - TicketIdOrSlug::Query("needs-review".to_string()), - NewTicketEvent::new(TicketEventKind::ImplementationReport, "Implemented."), - ) - .unwrap(); - backend - .add_event( - TicketIdOrSlug::Query("close-ready".to_string()), - NewTicketEvent::new(TicketEventKind::ImplementationReport, "Implemented."), - ) - .unwrap(); - backend - .review( - TicketIdOrSlug::Query("close-ready".to_string()), - TicketReview::approve(MarkdownText::new("Approved.")), - ) - .unwrap(); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - let review = model + let done = model .rows .iter() - .find(|row| row.title == "Needs Review") - .unwrap(); - let close = model - .rows - .iter() - .find(|row| row.title == "Close Ready") + .find(|row| row.title == "Done Explicit") .unwrap(); - assert_eq!(review.priority, ActionPriority::Decision); - assert_eq!(review.next_action, Some(NextUserAction::Review)); - assert_eq!(close.priority, ActionPriority::Decision); - assert_eq!(close.next_action, Some(NextUserAction::Close)); + assert_eq!(backlog.status, "intake"); + assert_eq!(backlog.next_action, Some(NextUserAction::Clarify)); + assert!(backlog.is_ticket_action()); + assert_eq!(done.status, "done"); + assert_eq!(done.next_action, Some(NextUserAction::Close)); } #[test]