feat: add explicit ticket workflow state

This commit is contained in:
2026-06-07 09:02:01 +09:00
parent eec805287b
commit ab85388122
5 changed files with 1141 additions and 591 deletions
+139 -235
View File
@@ -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::<Method>().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()),
+128 -347
View File
@@ -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<String>,
pub(crate) phase: TicketPanelPhase,
pub(crate) workflow_state: TicketWorkflowState,
pub(crate) workflow_state_explicit: bool,
pub(crate) attention_required: Option<String>,
pub(crate) next_action: Option<NextUserAction>,
pub(crate) updated_at: Option<String>,
pub(crate) latest_event_kind: Option<String>,
@@ -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<NextUserAction>,
disabled_reason: Option<String>,
@@ -567,239 +523,91 @@ struct DerivedTicketState {
blocked_reason: Option<String>,
}
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<usize> {
events.iter().rposition(|event| event.kind == kind)
}
fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec<String> {
@@ -822,11 +630,13 @@ fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec<Strin
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
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::<Vec<_>>();
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]