ticket: share workspace actionable projection

This commit is contained in:
Keisuke Hirata 2026-07-20 17:42:20 +09:00
parent 556adb429c
commit a40d9c2027
No known key found for this signature in database
2 changed files with 570 additions and 251 deletions

View File

@ -739,6 +739,415 @@ pub struct TicketRelationView {
pub notices: Vec<TicketRelationNotice>, pub notices: Vec<TicketRelationNotice>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceActionPriority {
ReadyForQueue,
ActiveWork,
Background,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceNextAction {
Clarify,
QueueForOrchestrator,
Close,
WaitForOrchestrator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceRowKind {
Planning,
Ticket,
Review,
ActiveWork,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkspaceStateOverlay {
pub source: String,
pub workflow_state: TicketWorkflowState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketQueueGuard {
pub can_queue_for_orchestrator: bool,
pub reason: Option<String>,
pub blocked_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkspaceProjection {
pub kind: TicketWorkspaceRowKind,
pub priority: TicketWorkspaceActionPriority,
pub next_action: Option<TicketWorkspaceNextAction>,
pub visible_state: String,
pub visible_overlay: Option<TicketWorkspaceStateOverlay>,
pub disabled_reason: Option<String>,
pub key_hint: Option<String>,
pub blocked_reason: Option<String>,
pub queue_guard: TicketQueueGuard,
}
pub fn project_ticket_workspace_item(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
) -> TicketWorkspaceProjection {
let visible_overlay = orchestration_overlay
.filter(|overlay| {
ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
})
.cloned();
let mut projection = derive_ticket_workspace_projection(summary, relation_blockers);
if let Some(overlay) = visible_overlay.as_ref() {
apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay);
}
projection.visible_state =
ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref());
projection.visible_overlay = visible_overlay;
projection.queue_guard = ticket_queue_guard(
summary,
relation_blockers,
projection.visible_overlay.as_ref(),
);
projection
}
pub fn ticket_queue_guard(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
) -> TicketQueueGuard {
if orchestration_overlay.is_some() {
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(
"orchestration overlay already shows progress; duplicate queue is suppressed"
.to_string(),
),
blocked_reason: None,
};
}
if summary.workflow_state != TicketWorkflowState::Ready {
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(format!(
"Ticket state is {}; only ready Tickets can be queued for Orchestrator",
summary.workflow_state.as_str()
)),
blocked_reason: None,
};
}
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
let blockers = format_workspace_relation_blockers(&active_blockers);
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(format!("waiting for {blockers}")),
blocked_reason: Some(blockers),
};
}
TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
}
}
fn derive_ticket_workspace_projection(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
) -> TicketWorkspaceProjection {
if !relation_blockers.is_empty() {
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
let blockers_to_report = if active_blockers.is_empty() {
relation_blockers.iter().collect::<Vec<_>>()
} else {
active_blockers
};
let blockers = format_workspace_relation_blockers(&blockers_to_report);
let waiting_reason = format!("waiting for {blockers}");
return TicketWorkspaceProjection {
kind: workspace_row_kind_for_state(summary.workflow_state),
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
TicketWorkspaceActionPriority::ActiveWork
}
_ => TicketWorkspaceActionPriority::Background,
},
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(waiting_reason),
blocked_reason: None,
},
};
}
let blockers = format_workspace_relation_blockers(
&relation_blockers
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
return TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Ticket,
priority: TicketWorkspaceActionPriority::ReadyForQueue,
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: None,
key_hint: Some(format!(
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
)),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
},
};
}
match summary.workflow_state {
TicketWorkflowState::Ready => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Ticket,
priority: TicketWorkspaceActionPriority::ReadyForQueue,
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: None,
key_hint: Some(
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
},
},
TicketWorkflowState::Queued => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::ActiveWork,
priority: TicketWorkspaceActionPriority::ActiveWork,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is already queued for Orchestrator routing".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::InProgress => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::ActiveWork,
priority: TicketWorkspaceActionPriority::ActiveWork,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is already in progress.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is already in progress".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Done => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Review,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::Close),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(
"state is done; close if a resolution is still missing.".to_string(),
),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is done; close or review instead of queueing".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Planning => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Planning,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::Clarify),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(
"Ticket is still in planning; mark it ready before queueing.".to_string(),
),
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is still in planning".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Closed => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Review,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is closed.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is closed".to_string()),
blocked_reason: None,
},
},
}
}
fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind {
match state {
TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
TicketWorkspaceRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review,
TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket,
}
}
fn apply_workspace_overlay_to_projection(
projection: &mut TicketWorkspaceProjection,
local: TicketWorkflowState,
overlay: &TicketWorkspaceStateOverlay,
) {
projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator);
let overlay_state = overlay.workflow_state.as_str();
match overlay.workflow_state {
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
projection.kind = TicketWorkspaceRowKind::Review;
projection.priority = TicketWorkspaceActionPriority::Background;
projection.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
overlay.source,
local.as_str()
));
projection.key_hint = Some(format!(
"Merge pending: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
projection.kind = TicketWorkspaceRowKind::ActiveWork;
projection.priority = TicketWorkspaceActionPriority::ActiveWork;
projection.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
overlay.source,
local.as_str()
));
projection.key_hint = Some(format!(
"Progress overlay: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
}
}
fn ticket_workspace_state_display(
local: TicketWorkflowState,
overlay: Option<&TicketWorkspaceStateOverlay>,
) -> String {
match overlay {
Some(overlay) => format!(
"{}→{}",
compact_ticket_state_label(local),
compact_ticket_state_label(overlay.workflow_state)
),
None => local.as_str().to_string(),
}
}
fn ticket_overlay_state_has_progressed(
local: TicketWorkflowState,
overlay: TicketWorkflowState,
) -> bool {
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
}
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
match state {
TicketWorkflowState::Planning => 0,
TicketWorkflowState::Ready => 1,
TicketWorkflowState::Queued => 2,
TicketWorkflowState::InProgress => 3,
TicketWorkflowState::Done => 4,
TicketWorkflowState::Closed => 5,
}
}
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
match state {
TicketWorkflowState::Planning => "plan",
TicketWorkflowState::Ready => "ready",
TicketWorkflowState::Queued => "q",
TicketWorkflowState::InProgress => "prog",
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
let shown_blockers = blockers.iter().take(3).count();
let mut formatted = blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
}
formatted
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum OrchestrationPlanKind { pub enum OrchestrationPlanKind {
@ -3962,6 +4371,108 @@ mod tests {
LocalTicketBackend::new(dir.path().join("tickets")) LocalTicketBackend::new(dir.path().join("tickets"))
} }
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary {
id: "000TEST".to_string(),
slug: "000TEST".to_string(),
title: "Test Ticket".to_string(),
status: ExtensibleTicketStatus::Open,
kind: "ticket".to_string(),
priority: "P2".to_string(),
labels: Vec::new(),
readiness: None,
workflow_state: state,
workflow_state_explicit: true,
queued_by: None,
queued_at: None,
updated_at: Some("2026-07-20T00:00:00Z".to_string()),
}
}
fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker {
TicketRelationBlocker {
blocking_ticket: "000BLOCK".to_string(),
reason_kind: "depends_on".to_string(),
relation_kind: TicketRelationKind::DependsOn,
note: None,
blocking_state: state,
}
}
#[test]
fn workspace_projection_queues_ready_ticket_for_orchestrator() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let projection = project_ticket_workspace_item(&summary, &[], None);
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
assert_eq!(
projection.priority,
TicketWorkspaceActionPriority::ReadyForQueue
);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.disabled_reason.is_none());
}
#[test]
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
);
assert!(!projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_some());
assert!(projection.disabled_reason.is_some());
}
#[test]
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_none());
assert!(
projection
.key_hint
.as_deref()
.unwrap_or_default()
.contains("Orchestrator will preserve order")
);
}
#[test]
fn workspace_projection_overlay_suppresses_duplicate_queue() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let overlay = TicketWorkspaceStateOverlay {
source: "orchestration".to_string(),
workflow_state: TicketWorkflowState::InProgress,
};
let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay));
assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
);
assert_eq!(projection.visible_state, "ready→prog");
assert!(!projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.visible_overlay.is_some());
}
#[test] #[test]
fn workflow_state_rejects_legacy_intake_alias() { fn workflow_state_rejects_legacy_intake_alias() {
assert_eq!( assert_eq!(

View File

@ -12,7 +12,8 @@ use ticket::config::{
use ticket::{ use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
TicketWorkflowState, TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction,
TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item,
}; };
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
@ -267,11 +268,7 @@ pub(crate) struct TicketPanelEntry {
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>, pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay;
pub(crate) struct TicketStateOverlay {
pub(crate) source: String,
pub(crate) workflow_state: TicketWorkflowState,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketAssociatedIntakeEntry { pub(crate) struct TicketAssociatedIntakeEntry {
@ -1233,29 +1230,24 @@ fn ticket_row(
related_workers.push(worker_name); related_workers.push(worker_name);
} }
} }
let visible_overlay = orchestration_overlay let projection =
.filter(|overlay| { project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay);
overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
})
.cloned();
let mut derived = derive_ticket_state(&summary, relation_blockers);
if let Some(overlay) = visible_overlay.as_ref() {
apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay);
}
let latest_event = events.last(); let latest_event = events.last();
let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref()); let kind = panel_row_kind_from_workspace(projection.kind);
let priority = action_priority_from_workspace(projection.priority);
let next_action = projection.next_action.map(next_user_action_from_workspace);
let entry = TicketPanelEntry { let entry = TicketPanelEntry {
id: summary.id.clone(), id: summary.id.clone(),
title: summary.title.clone(), title: summary.title.clone(),
priority: summary.priority.clone(), priority: summary.priority.clone(),
workflow_state: summary.workflow_state, workflow_state: summary.workflow_state,
workflow_state_explicit: summary.workflow_state_explicit, workflow_state_explicit: summary.workflow_state_explicit,
orchestration_overlay: visible_overlay, orchestration_overlay: projection.visible_overlay.clone(),
next_action: derived.action, next_action,
updated_at: summary.updated_at.clone(), updated_at: summary.updated_at.clone(),
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)), latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
blocked_reason: derived.blocked_reason.clone(), blocked_reason: projection.blocked_reason.clone(),
related_workers: related_workers.clone(), related_workers: related_workers.clone(),
local_claim, local_claim,
intake_workers, intake_workers,
@ -1263,251 +1255,42 @@ fn ticket_row(
let subtitle = ticket_subtitle(&entry); let subtitle = ticket_subtitle(&entry);
PanelRow { PanelRow {
key: PanelRowKey::Ticket(summary.id), key: PanelRowKey::Ticket(summary.id),
kind: derived.kind, kind,
title: summary.title, title: summary.title,
subtitle, subtitle,
status: state_display, status: projection.visible_state,
priority: derived.priority, priority,
next_action: derived.action, next_action,
ticket: Some(entry), ticket: Some(entry),
related_workers, related_workers,
disabled_reason: derived.disabled_reason, disabled_reason: projection.disabled_reason,
key_hint: derived.key_hint, key_hint: projection.key_hint,
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind {
struct DerivedTicketState { match kind {
kind: PanelRowKind, TicketWorkspaceRowKind::Planning => PanelRowKind::Planning,
priority: ActionPriority, TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket,
action: Option<NextUserAction>, TicketWorkspaceRowKind::Review => PanelRowKind::Review,
disabled_reason: Option<String>, TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork,
key_hint: Option<String>,
blocked_reason: Option<String>,
}
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
match state {
TicketWorkflowState::Planning => 0,
TicketWorkflowState::Ready => 1,
TicketWorkflowState::Queued => 2,
TicketWorkflowState::InProgress => 3,
TicketWorkflowState::Done => 4,
TicketWorkflowState::Closed => 5,
} }
} }
fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool { fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority {
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) match priority {
} TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue,
TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork,
fn ticket_state_display( TicketWorkspaceActionPriority::Background => ActionPriority::Background,
local: TicketWorkflowState,
overlay: Option<&TicketStateOverlay>,
) -> String {
match overlay {
Some(overlay) => format!(
"{}→{}",
compact_ticket_state_label(local),
compact_ticket_state_label(overlay.workflow_state)
),
None => local.as_str().to_string(),
} }
} }
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction {
match state { match action {
TicketWorkflowState::Planning => "plan", TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify,
TicketWorkflowState::Ready => "ready", TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue,
TicketWorkflowState::Queued => "q", TicketWorkspaceNextAction::Close => NextUserAction::Close,
TicketWorkflowState::InProgress => "prog", TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait,
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn apply_orchestration_overlay_to_derived(
derived: &mut DerivedTicketState,
local: TicketWorkflowState,
overlay: &TicketStateOverlay,
) {
derived.action = Some(NextUserAction::Wait);
let overlay_state = overlay.workflow_state.as_str();
match overlay.workflow_state {
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
derived.kind = PanelRowKind::Review;
derived.priority = ActionPriority::Background;
derived.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
overlay.source,
local.as_str()
));
derived.key_hint = Some(format!(
"Merge pending: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
derived.kind = PanelRowKind::ActiveWork;
derived.priority = ActionPriority::ActiveWork;
derived.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
overlay.source,
local.as_str()
));
derived.key_hint = Some(format!(
"Progress overlay: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
}
}
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
let shown_blockers = blockers.iter().take(3).count();
let mut formatted = blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
}
formatted
}
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn derive_ticket_state(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
) -> DerivedTicketState {
if !relation_blockers.is_empty() {
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
let blockers_to_report = if active_blockers.is_empty() {
relation_blockers.iter().collect::<Vec<_>>()
} else {
active_blockers
};
let blockers = format_relation_blockers(&blockers_to_report);
let waiting_reason = format!("waiting for {blockers}");
return DerivedTicketState {
kind: match summary.workflow_state {
TicketWorkflowState::Planning => PanelRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
PanelRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
};
}
let blockers = format_relation_blockers(
&relation_blockers
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
return DerivedTicketState {
kind: PanelRowKind::Ticket,
priority: ActionPriority::ReadyForQueue,
action: Some(NextUserAction::Queue),
disabled_reason: None,
key_hint: Some(format!(
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
)),
blocked_reason: None,
};
}
match summary.workflow_state {
TicketWorkflowState::Ready => DerivedTicketState {
kind: PanelRowKind::Ticket,
priority: ActionPriority::ReadyForQueue,
action: Some(NextUserAction::Queue),
disabled_reason: None,
key_hint: Some(
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
),
blocked_reason: None,
},
TicketWorkflowState::Queued => DerivedTicketState {
kind: PanelRowKind::ActiveWork,
priority: ActionPriority::ActiveWork,
action: Some(NextUserAction::Wait),
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
key_hint: None,
blocked_reason: None,
},
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: Some(NextUserAction::Close),
disabled_reason: Some(
"state is done; close if a resolution is still missing.".to_string(),
),
key_hint: None,
blocked_reason: None,
},
TicketWorkflowState::Planning => DerivedTicketState {
kind: PanelRowKind::Planning,
priority: ActionPriority::Background,
action: Some(NextUserAction::Clarify),
disabled_reason: Some(
"Ticket is still in planning; mark it ready before queueing.".to_string(),
),
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
blocked_reason: None,
},
TicketWorkflowState::Closed => DerivedTicketState {
kind: PanelRowKind::Review,
priority: ActionPriority::Background,
action: Some(NextUserAction::Wait),
disabled_reason: Some("Ticket is closed.".to_string()),
key_hint: None,
blocked_reason: None,
},
} }
} }
@ -1652,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod(
TicketLocalClaimStatus::Stale TicketLocalClaimStatus::Stale
} }
fn ticket_state_display(
local: TicketWorkflowState,
overlay: Option<&TicketStateOverlay>,
) -> String {
match overlay {
Some(overlay) => format!(
"{}→{}",
compact_ticket_state_label(local),
compact_ticket_state_label(overlay.workflow_state)
),
None => local.as_str().to_string(),
}
}
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
match state {
TicketWorkflowState::Planning => "plan",
TicketWorkflowState::Ready => "ready",
TicketWorkflowState::Queued => "q",
TicketWorkflowState::InProgress => "prog",
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> { fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
let mut parts = vec![format!( let mut parts = vec![format!(
"{} · {}", "{} · {}",