merge: panel orchestration overlay
This commit is contained in:
commit
eeb6986f16
|
|
@ -3211,7 +3211,22 @@ fn derive_orchestrator_work_set(
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| {
|
.filter_map(|row| {
|
||||||
let ticket = row.ticket.as_ref()?;
|
let ticket = row.ticket.as_ref()?;
|
||||||
if ticket.workflow_state == TicketWorkflowState::InProgress {
|
if let Some(overlay) = ticket
|
||||||
|
.orchestration_overlay
|
||||||
|
.as_ref()
|
||||||
|
.filter(|overlay| overlay.workflow_state == TicketWorkflowState::InProgress)
|
||||||
|
{
|
||||||
|
Some(OrchestratorActiveWorkItem {
|
||||||
|
id: ticket.id.clone(),
|
||||||
|
title: ticket.title.clone(),
|
||||||
|
status: format!(
|
||||||
|
"local: {} · {}: {}",
|
||||||
|
ticket.workflow_state.as_str(),
|
||||||
|
overlay.source,
|
||||||
|
overlay.workflow_state.as_str()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} else if ticket.workflow_state == TicketWorkflowState::InProgress {
|
||||||
Some(OrchestratorActiveWorkItem {
|
Some(OrchestratorActiveWorkItem {
|
||||||
id: ticket.id.clone(),
|
id: ticket.id.clone(),
|
||||||
title: ticket.title.clone(),
|
title: ticket.title.clone(),
|
||||||
|
|
@ -3297,6 +3312,13 @@ fn queued_duplicate_guard(
|
||||||
guards.push(format!("related pod/worktree {pod}"));
|
guards.push(format!("related pod/worktree {pod}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(overlay) = ticket.orchestration_overlay.as_ref() {
|
||||||
|
guards.push(format!(
|
||||||
|
"{} worktree overlay shows state {}",
|
||||||
|
overlay.source,
|
||||||
|
overlay.workflow_state.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
if guards.is_empty() {
|
if guards.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -8893,6 +8915,7 @@ branch = "orchestration/custom-panel"
|
||||||
workflow_state: TicketWorkflowState::parse(state)
|
workflow_state: TicketWorkflowState::parse(state)
|
||||||
.unwrap_or(TicketWorkflowState::Planning),
|
.unwrap_or(TicketWorkflowState::Planning),
|
||||||
workflow_state_explicit: true,
|
workflow_state_explicit: true,
|
||||||
|
orchestration_overlay: None,
|
||||||
next_action: Some(next_action),
|
next_action: Some(next_action),
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
latest_event_kind: Some("implementation_report".to_string()),
|
latest_event_kind: Some("implementation_report".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use protocol::PodStatus;
|
use protocol::PodStatus;
|
||||||
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
use ticket::config::{
|
||||||
|
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||||
|
TicketOrchestrationConfig,
|
||||||
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||||
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||||
|
|
@ -242,6 +247,7 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) priority: String,
|
pub(crate) priority: String,
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
pub(crate) workflow_state: TicketWorkflowState,
|
||||||
pub(crate) workflow_state_explicit: bool,
|
pub(crate) workflow_state_explicit: bool,
|
||||||
|
pub(crate) orchestration_overlay: Option<TicketStateOverlay>,
|
||||||
pub(crate) next_action: Option<NextUserAction>,
|
pub(crate) next_action: Option<NextUserAction>,
|
||||||
pub(crate) updated_at: Option<String>,
|
pub(crate) updated_at: Option<String>,
|
||||||
pub(crate) latest_event_kind: Option<String>,
|
pub(crate) latest_event_kind: Option<String>,
|
||||||
|
|
@ -252,6 +258,12 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
|
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
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 {
|
||||||
pub(crate) ticket_id: String,
|
pub(crate) ticket_id: String,
|
||||||
|
|
@ -379,6 +391,27 @@ pub(crate) enum OrchestratorLifecyclePlan {
|
||||||
Unavailable(String),
|
Unavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct OrchestrationTicketOverlay {
|
||||||
|
states: BTreeMap<String, TicketStateOverlay>,
|
||||||
|
diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestrationTicketOverlay {
|
||||||
|
fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
states: BTreeMap::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct OrchestrationWorktreeLayout {
|
||||||
|
path: PathBuf,
|
||||||
|
branch: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String {
|
pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String {
|
||||||
let seed = workspace_root
|
let seed = workspace_root
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|
@ -543,6 +576,161 @@ pub(crate) fn bounded_panel_diagnostic(message: impl AsRef<str>) -> String {
|
||||||
excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string())
|
excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_orchestration_ticket_overlay(
|
||||||
|
workspace_root: &Path,
|
||||||
|
config: &TicketConfig,
|
||||||
|
) -> OrchestrationTicketOverlay {
|
||||||
|
let layout = orchestration_worktree_layout(workspace_root, &config.orchestration);
|
||||||
|
if !layout.path.exists() {
|
||||||
|
return OrchestrationTicketOverlay::empty();
|
||||||
|
}
|
||||||
|
match validate_orchestration_overlay_source(workspace_root, &layout) {
|
||||||
|
Ok(()) => {
|
||||||
|
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language())
|
||||||
|
.unwrap_or_else(|message| OrchestrationTicketOverlay {
|
||||||
|
states: BTreeMap::new(),
|
||||||
|
diagnostics: vec![bounded_panel_diagnostic(format!(
|
||||||
|
"Orchestration Ticket overlay unavailable: {message}"
|
||||||
|
))],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(message) => OrchestrationTicketOverlay {
|
||||||
|
states: BTreeMap::new(),
|
||||||
|
diagnostics: vec![bounded_panel_diagnostic(format!(
|
||||||
|
"Orchestration Ticket overlay unavailable: {message}"
|
||||||
|
))],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orchestration_worktree_layout(
|
||||||
|
workspace_root: &Path,
|
||||||
|
config: &TicketOrchestrationConfig,
|
||||||
|
) -> OrchestrationWorktreeLayout {
|
||||||
|
OrchestrationWorktreeLayout {
|
||||||
|
path: workspace_root
|
||||||
|
.join(config.worktree_dir())
|
||||||
|
.join(config.worktree_name()),
|
||||||
|
branch: config.effective_branch_name().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_orchestration_ticket_overlay_states(
|
||||||
|
worktree_root: &Path,
|
||||||
|
record_language: Option<&str>,
|
||||||
|
) -> Result<OrchestrationTicketOverlay, String> {
|
||||||
|
let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
|
||||||
|
if !ticket_root.is_dir() {
|
||||||
|
return Ok(OrchestrationTicketOverlay {
|
||||||
|
states: BTreeMap::new(),
|
||||||
|
diagnostics: vec![bounded_panel_diagnostic(format!(
|
||||||
|
"Orchestration worktree has no {} directory",
|
||||||
|
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||||
|
))],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language);
|
||||||
|
let partial = backend
|
||||||
|
.list_partial(TicketFilter::all())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut states = BTreeMap::new();
|
||||||
|
for summary in partial.tickets {
|
||||||
|
states.insert(
|
||||||
|
summary.id,
|
||||||
|
TicketStateOverlay {
|
||||||
|
source: "orchestration".to_string(),
|
||||||
|
workflow_state: summary.workflow_state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let diagnostics = invalid_ticket_diagnostics(partial.invalid_records.len());
|
||||||
|
Ok(OrchestrationTicketOverlay {
|
||||||
|
states,
|
||||||
|
diagnostics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_orchestration_overlay_source(
|
||||||
|
workspace_root: &Path,
|
||||||
|
layout: &OrchestrationWorktreeLayout,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !layout.path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"expected worktree {} does not exist",
|
||||||
|
layout.path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !layout.path.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"expected worktree {} is not a directory",
|
||||||
|
layout.path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expected_path = layout
|
||||||
|
.path
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("could not canonicalize expected worktree: {error}"))?;
|
||||||
|
let overlay_top = git_output(&layout.path, &["rev-parse", "--show-toplevel"])?;
|
||||||
|
let overlay_top = PathBuf::from(overlay_top);
|
||||||
|
let overlay_top = overlay_top
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("could not canonicalize overlay git top-level: {error}"))?;
|
||||||
|
if overlay_top != expected_path {
|
||||||
|
return Err(format!(
|
||||||
|
"overlay git top-level {} does not match expected path {}",
|
||||||
|
overlay_top.display(),
|
||||||
|
expected_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_common_dir = git_common_dir(workspace_root)?;
|
||||||
|
let overlay_common_dir = git_common_dir(&layout.path)?;
|
||||||
|
if current_common_dir != overlay_common_dir {
|
||||||
|
return Err("expected worktree is from a different git common-dir".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay_branch = git_output(&layout.path, &["branch", "--show-current"])?;
|
||||||
|
if overlay_branch != layout.branch {
|
||||||
|
return Err(format!(
|
||||||
|
"expected branch {} but worktree is on {}",
|
||||||
|
layout.branch, overlay_branch
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_common_dir(worktree_root: &Path) -> Result<PathBuf, String> {
|
||||||
|
let common_dir = git_output(worktree_root, &["rev-parse", "--git-common-dir"])?;
|
||||||
|
let common_dir_path = PathBuf::from(common_dir);
|
||||||
|
let absolute = if common_dir_path.is_absolute() {
|
||||||
|
common_dir_path
|
||||||
|
} else {
|
||||||
|
worktree_root.join(common_dir_path)
|
||||||
|
};
|
||||||
|
absolute
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("could not canonicalize git common-dir: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(worktree_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let message = if stderr.is_empty() {
|
||||||
|
format!("git {} exited with {}", args.join(" "), output.status)
|
||||||
|
} else {
|
||||||
|
format!("git {} failed: {stderr}", args.join(" "))
|
||||||
|
};
|
||||||
|
return Err(message);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_workspace_panel(
|
pub(crate) fn build_workspace_panel(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
|
|
@ -595,10 +783,17 @@ fn build_workspace_panel_with_registry_model(
|
||||||
model.header.ticket_root = config.backend_root().to_path_buf();
|
model.header.ticket_root = config.backend_root().to_path_buf();
|
||||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language());
|
||||||
match build_ticket_rows(&backend, pods, registry) {
|
let orchestration_overlay =
|
||||||
|
load_orchestration_ticket_overlay(workspace_root, &config);
|
||||||
|
match build_ticket_rows(&backend, pods, registry, &orchestration_overlay.states)
|
||||||
|
{
|
||||||
Ok(ticket_rows) => {
|
Ok(ticket_rows) => {
|
||||||
model.rows.extend(ticket_rows.rows);
|
model.rows.extend(ticket_rows.rows);
|
||||||
model.header.diagnostics.extend(ticket_rows.diagnostics);
|
model.header.diagnostics.extend(ticket_rows.diagnostics);
|
||||||
|
model
|
||||||
|
.header
|
||||||
|
.diagnostics
|
||||||
|
.extend(orchestration_overlay.diagnostics);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
model
|
model
|
||||||
|
|
@ -652,6 +847,7 @@ pub(crate) fn build_current_ticket_row(
|
||||||
&ticket.relations.blockers,
|
&ticket.relations.blockers,
|
||||||
pods,
|
pods,
|
||||||
®istry,
|
®istry,
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -683,6 +879,7 @@ fn build_ticket_rows(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
|
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
|
||||||
) -> ticket::Result<TicketRowsBuild> {
|
) -> ticket::Result<TicketRowsBuild> {
|
||||||
let partial = backend.list_partial(TicketFilter::all())?;
|
let partial = backend.list_partial(TicketFilter::all())?;
|
||||||
let mut ticket_rows = Vec::new();
|
let mut ticket_rows = Vec::new();
|
||||||
|
|
@ -701,12 +898,14 @@ fn build_ticket_rows(
|
||||||
if current_ticket_invalid {
|
if current_ticket_invalid {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let overlay = orchestration_overlay.get(&summary.id);
|
||||||
ticket_rows.push(ticket_row(
|
ticket_rows.push(ticket_row(
|
||||||
summary,
|
summary,
|
||||||
&ticket.ticket.events,
|
&ticket.ticket.events,
|
||||||
&ticket.ticket.relations.blockers,
|
&ticket.ticket.relations.blockers,
|
||||||
pods,
|
pods,
|
||||||
registry,
|
registry,
|
||||||
|
overlay,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => invalid_records.push(TicketInvalidRecord {
|
Err(_) => invalid_records.push(TicketInvalidRecord {
|
||||||
|
|
@ -802,6 +1001,7 @@ fn ticket_row(
|
||||||
relation_blockers: &[TicketRelationBlocker],
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
|
orchestration_overlay: Option<&TicketStateOverlay>,
|
||||||
) -> PanelRow {
|
) -> PanelRow {
|
||||||
let local_claim = local_claim_for_ticket(&summary, pods, registry);
|
let local_claim = local_claim_for_ticket(&summary, pods, registry);
|
||||||
let intake_pods =
|
let intake_pods =
|
||||||
|
|
@ -815,14 +1015,24 @@ fn ticket_row(
|
||||||
related_pods.push(pod_name);
|
related_pods.push(pod_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let derived = derive_ticket_state(&summary, relation_blockers);
|
let visible_overlay = orchestration_overlay
|
||||||
|
.filter(|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 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,
|
||||||
next_action: derived.action,
|
next_action: derived.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()),
|
||||||
|
|
@ -838,7 +1048,7 @@ fn ticket_row(
|
||||||
kind: derived.kind,
|
kind: derived.kind,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
subtitle,
|
subtitle,
|
||||||
status: summary.workflow_state.as_str().to_string(),
|
status: state_display,
|
||||||
priority: derived.priority,
|
priority: derived.priority,
|
||||||
next_action: derived.action,
|
next_action: derived.action,
|
||||||
ticket: Some(entry),
|
ticket: Some(entry),
|
||||||
|
|
@ -858,6 +1068,76 @@ struct DerivedTicketState {
|
||||||
blocked_reason: 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 {
|
||||||
|
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_state_display(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: Option<&TicketStateOverlay>,
|
||||||
|
) -> String {
|
||||||
|
match overlay {
|
||||||
|
Some(overlay) => format!(
|
||||||
|
"local: {} · {}: {}",
|
||||||
|
local.as_str(),
|
||||||
|
overlay.source,
|
||||||
|
overlay.workflow_state.as_str()
|
||||||
|
),
|
||||||
|
None => local.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 derive_ticket_state(
|
fn derive_ticket_state(
|
||||||
summary: &TicketSummary,
|
summary: &TicketSummary,
|
||||||
relation_blockers: &[TicketRelationBlocker],
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
|
@ -1099,7 +1379,11 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||||
let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())];
|
let mut parts = vec![format!(
|
||||||
|
"{} · {}",
|
||||||
|
entry.id,
|
||||||
|
ticket_state_display(entry.workflow_state, entry.orchestration_overlay.as_ref())
|
||||||
|
)];
|
||||||
if let Some(claim) = entry.local_claim.as_ref() {
|
if let Some(claim) = entry.local_claim.as_ref() {
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"claim: {} ({})",
|
"claim: {} ({})",
|
||||||
|
|
@ -1212,7 +1496,10 @@ mod tests {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState};
|
use ticket::{
|
||||||
|
NewTicket, NewTicketRelation, TicketIdOrSlug, TicketRelationKind, TicketStateChange,
|
||||||
|
TicketWorkflowState,
|
||||||
|
};
|
||||||
|
|
||||||
fn empty_pods() -> PodList {
|
fn empty_pods() -> PodList {
|
||||||
PodList::from_sources(
|
PodList::from_sources(
|
||||||
|
|
@ -1229,9 +1516,17 @@ mod tests {
|
||||||
title: &str,
|
title: &str,
|
||||||
configure: impl FnOnce(&mut NewTicket),
|
configure: impl FnOnce(&mut NewTicket),
|
||||||
) {
|
) {
|
||||||
|
create_ticket_with_id(backend, title, configure);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_ticket_with_id(
|
||||||
|
backend: &LocalTicketBackend,
|
||||||
|
title: &str,
|
||||||
|
configure: impl FnOnce(&mut NewTicket),
|
||||||
|
) -> String {
|
||||||
let mut input = NewTicket::new(title);
|
let mut input = NewTicket::new(title);
|
||||||
configure(&mut input);
|
configure(&mut input);
|
||||||
backend.create(input).unwrap();
|
backend.create(input).unwrap().id
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_ticket_config(workspace_root: &Path) {
|
fn write_ticket_config(workspace_root: &Path) {
|
||||||
|
|
@ -1244,6 +1539,111 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_git(workspace_root: &Path, args: &[&str]) {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(workspace_root)
|
||||||
|
.output()
|
||||||
|
.unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}"));
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_git_repo(workspace_root: &Path) {
|
||||||
|
run_git(workspace_root, &["init", "--initial-branch", "main"]);
|
||||||
|
run_git(workspace_root, &["config", "user.name", "Panel Test"]);
|
||||||
|
run_git(
|
||||||
|
workspace_root,
|
||||||
|
&["config", "user.email", "panel-test@example.invalid"],
|
||||||
|
);
|
||||||
|
fs::write(workspace_root.join("README.md"), "panel test\n").unwrap();
|
||||||
|
run_git(workspace_root, &["add", "README.md"]);
|
||||||
|
run_git(workspace_root, &["commit", "-m", "init"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_orchestration_worktree(workspace_root: &Path, branch: &str) -> PathBuf {
|
||||||
|
let orchestration_root = workspace_root.join(".worktree/orchestration");
|
||||||
|
fs::create_dir_all(orchestration_root.parent().unwrap()).unwrap();
|
||||||
|
run_git(
|
||||||
|
workspace_root,
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-b",
|
||||||
|
branch,
|
||||||
|
orchestration_root.to_str().unwrap(),
|
||||||
|
"HEAD",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
orchestration_root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) {
|
||||||
|
let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id);
|
||||||
|
let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id);
|
||||||
|
fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap();
|
||||||
|
fs::create_dir_all(&overlay_ticket_dir).unwrap();
|
||||||
|
fs::copy(
|
||||||
|
local_ticket_dir.join("item.md"),
|
||||||
|
overlay_ticket_dir.join("item.md"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let local_thread = local_ticket_dir.join("thread.md");
|
||||||
|
if local_thread.exists() {
|
||||||
|
fs::copy(local_thread, overlay_ticket_dir.join("thread.md")).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_ticket_state(backend: &LocalTicketBackend, id: &str, state: TicketWorkflowState) {
|
||||||
|
loop {
|
||||||
|
let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap();
|
||||||
|
if ticket.meta.workflow_state == state {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let next = match (ticket.meta.workflow_state, state) {
|
||||||
|
(TicketWorkflowState::Queued, TicketWorkflowState::InProgress)
|
||||||
|
| (TicketWorkflowState::Queued, TicketWorkflowState::Done) => {
|
||||||
|
TicketWorkflowState::InProgress
|
||||||
|
}
|
||||||
|
(TicketWorkflowState::InProgress, TicketWorkflowState::Done) => {
|
||||||
|
TicketWorkflowState::Done
|
||||||
|
}
|
||||||
|
(from, to) => panic!("unsupported test transition {from} -> {to}"),
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.set_workflow_state(
|
||||||
|
TicketIdOrSlug::Id(id.to_string()),
|
||||||
|
TicketStateChange::new(
|
||||||
|
ticket.meta.workflow_state.as_str(),
|
||||||
|
next.as_str(),
|
||||||
|
"test",
|
||||||
|
format!("test state -> {}", next.as_str()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_row_by_title<'a>(model: &'a WorkspacePanelViewModel, title: &str) -> &'a PanelRow {
|
||||||
|
model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.title == title)
|
||||||
|
.unwrap_or_else(|| panic!("missing row for {title}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_contains(row: &PanelRow, needle: &str) {
|
||||||
|
assert!(
|
||||||
|
row.status.contains(needle),
|
||||||
|
"status {:?} did not contain {:?}",
|
||||||
|
row.status,
|
||||||
|
needle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn live_pods(names: &[&str]) -> PodList {
|
fn live_pods(names: &[&str]) -> PodList {
|
||||||
PodList::from_sources(
|
PodList::from_sources(
|
||||||
crate::pod_list::PodVisibilitySource::ResumePicker,
|
crate::pod_list::PodVisibilitySource::ResumePicker,
|
||||||
|
|
@ -1308,6 +1708,151 @@ mod tests {
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_joins_orchestration_overlay_by_ticket_id() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(temp.path());
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let id = create_ticket_with_id(&backend, "Overlay Match", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
create_ticket(&backend, "Local Only", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
|
||||||
|
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
|
||||||
|
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
|
||||||
|
create_ticket(&overlay_backend, "Overlay Only", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Done);
|
||||||
|
});
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let matched = ticket_row_by_title(&model, "Overlay Match");
|
||||||
|
status_contains(matched, "local: queued");
|
||||||
|
status_contains(matched, "orchestration: inprogress");
|
||||||
|
assert_eq!(
|
||||||
|
matched.ticket.as_ref().unwrap().workflow_state,
|
||||||
|
TicketWorkflowState::Queued
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
matched
|
||||||
|
.ticket
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.orchestration_overlay
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.workflow_state,
|
||||||
|
TicketWorkflowState::InProgress
|
||||||
|
);
|
||||||
|
assert_eq!(ticket_row_by_title(&model, "Local Only").status, "queued");
|
||||||
|
assert!(model.rows.iter().all(|row| row.title != "Overlay Only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket()
|
||||||
|
{
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(temp.path());
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let id = create_ticket_with_id(&backend, "Overlay In Progress", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
|
||||||
|
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
|
||||||
|
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
|
||||||
|
let local_item = temp.path().join(".yoi/tickets").join(&id).join("item.md");
|
||||||
|
let before = fs::read_to_string(&local_item).unwrap();
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let row = ticket_row_by_title(&model, "Overlay In Progress");
|
||||||
|
status_contains(row, "local: queued");
|
||||||
|
status_contains(row, "orchestration: inprogress");
|
||||||
|
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||||
|
assert_eq!(row.kind, PanelRowKind::ActiveWork);
|
||||||
|
assert_eq!(fs::read_to_string(&local_item).unwrap(), before);
|
||||||
|
let local_ticket = backend.show(TicketIdOrSlug::Id(id)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
local_ticket.meta.workflow_state,
|
||||||
|
TicketWorkflowState::Queued
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_displays_queued_plus_orchestration_done_as_merge_pending_without_queue() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(temp.path());
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let id = create_ticket_with_id(&backend, "Overlay Done", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
|
||||||
|
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
|
||||||
|
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done);
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let row = ticket_row_by_title(&model, "Overlay Done");
|
||||||
|
status_contains(row, "local: queued");
|
||||||
|
status_contains(row, "orchestration: done");
|
||||||
|
assert_eq!(row.kind, PanelRowKind::Review);
|
||||||
|
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||||
|
assert_ne!(row.next_action, Some(NextUserAction::Queue));
|
||||||
|
assert!(
|
||||||
|
row.disabled_reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("merge/review/close authority")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_ignores_orchestration_overlay_on_branch_mismatch() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(temp.path());
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let orchestration_root = add_orchestration_worktree(temp.path(), "other-branch");
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
let id = create_ticket_with_id(&backend, "Branch Mismatch", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
|
||||||
|
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
|
||||||
|
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done);
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let row = ticket_row_by_title(&model, "Branch Mismatch");
|
||||||
|
assert_eq!(row.status, "queued");
|
||||||
|
assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none());
|
||||||
|
let diagnostics = model.header.diagnostics.join("\n");
|
||||||
|
assert!(diagnostics.contains("expected branch orchestration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_panel_falls_back_when_orchestration_worktree_is_missing() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_ticket_config(temp.path());
|
||||||
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
create_ticket(&backend, "Missing Overlay", |input| {
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
});
|
||||||
|
|
||||||
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
|
let row = ticket_row_by_title(&model, "Missing Overlay");
|
||||||
|
assert_eq!(row.status, "queued");
|
||||||
|
assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
|
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user