merge: explicit ticket workflow state

This commit is contained in:
Keisuke Hirata 2026-06-07 09:06:42 +09:00
commit 198708866a
No known key found for this signature in database
5 changed files with 1141 additions and 591 deletions

View File

@ -91,9 +91,9 @@ impl TicketIntakeHandoff {
out.push_str("\nPanel handoff:\n"); out.push_str("\nPanel handoff:\n");
push_bounded_bullet(out, "workspace", &self.workspace_label); push_bounded_bullet(out, "workspace", &self.workspace_label);
push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod); push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod);
out.push_str("- When Intake has clarified the request and created/updated the Ticket, notify/report readiness to this Orchestrator.\n"); out.push_str("- When Intake has clarified the request and created/updated the Ticket, use the typed Ticket tool surface to append `intake_summary` and set `workflow_state = ready` when the Ticket is ready to queue.\n");
out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, readiness, needs_preflight, risk_flags, user_go_required, intake_summary.\n"); out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, workflow_state, needs_preflight, risk_flags, intake_summary.\n");
out.push_str("- Do not start implementation automatically; wait for Orchestrator routing/preflight and human Go gates.\n"); out.push_str("- Do not start implementation automatically; the user queues a ready Ticket via panel (`ready -> queued`), and Orchestrator treats `queued` as schedulable before moving it to `inprogress` when starting.\n");
} }
} }
@ -849,8 +849,12 @@ workflow = "ticket-review-workflow"
assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo")); assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo"));
assert!(handoff_text.contains("workspace: Demo workspace")); assert!(handoff_text.contains("workspace: Demo workspace"));
assert!(handoff_text.contains("created_or_updated_ticket_id_or_slug")); assert!(handoff_text.contains("created_or_updated_ticket_id_or_slug"));
assert!(handoff_text.contains("Do not start implementation automatically")); assert!(handoff_text.contains("workflow_state"));
assert!(handoff_text.contains("human Go gates")); assert!(handoff_text.contains("Ticket tool surface"));
assert!(handoff_text.contains("ready -> queued"));
assert!(handoff_text.contains("queued` as schedulable"));
assert!(!handoff_text.contains("user_go_required"));
assert!(!handoff_text.contains("human Go gates"));
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator); let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
orchestrator.ticket = Some(TicketRef::slug("launcher")); orchestrator.ticket = Some(TicketRef::slug("launcher"));

View File

@ -153,6 +153,66 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TicketWorkflowState {
Intake,
Ready,
Queued,
InProgress,
Done,
}
impl TicketWorkflowState {
pub fn as_str(self) -> &'static str {
match self {
Self::Intake => "intake",
Self::Ready => "ready",
Self::Queued => "queued",
Self::InProgress => "inprogress",
Self::Done => "done",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"intake" => Some(Self::Intake),
"ready" => Some(Self::Ready),
"queued" => Some(Self::Queued),
"inprogress" => Some(Self::InProgress),
"done" => Some(Self::Done),
_ => None,
}
}
pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self {
match status {
ExtensibleTicketStatus::Closed => Self::Done,
_ => Self::Intake,
}
}
pub fn is_intake_ready_transition(from: Self, to: Self) -> bool {
from == Self::Intake && to == Self::Ready
}
pub fn is_queue_transition(from: Self, to: Self) -> bool {
from == Self::Ready && to == Self::Queued
}
pub fn is_role_transition(from: Self, to: Self) -> bool {
matches!(
(from, to),
(Self::Queued, Self::InProgress) | (Self::InProgress, Self::Done)
)
}
}
impl fmt::Display for TicketWorkflowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownText(pub String); pub struct MarkdownText(pub String);
@ -417,6 +477,10 @@ pub struct NewTicket {
pub needs_preflight: Option<bool>, pub needs_preflight: Option<bool>,
pub risk_flags: Vec<String>, pub risk_flags: Vec<String>,
pub action_required: Option<String>, pub action_required: Option<String>,
pub workflow_state: Option<TicketWorkflowState>,
pub attention_required: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
} }
impl NewTicket { impl NewTicket {
@ -437,6 +501,10 @@ impl NewTicket {
needs_preflight: None, needs_preflight: None,
risk_flags: Vec::new(), risk_flags: Vec::new(),
action_required: None, action_required: None,
workflow_state: None,
attention_required: None,
queued_by: None,
queued_at: None,
} }
} }
} }
@ -482,6 +550,11 @@ pub struct TicketMeta {
pub needs_preflight: Option<bool>, pub needs_preflight: Option<bool>,
pub risk_flags: Vec<String>, pub risk_flags: Vec<String>,
pub action_required: Option<String>, pub action_required: Option<String>,
pub workflow_state: TicketWorkflowState,
pub workflow_state_explicit: bool,
pub attention_required: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub raw: BTreeMap<String, String>, pub raw: BTreeMap<String, String>,
} }
@ -497,6 +570,11 @@ pub struct TicketSummary {
pub readiness: Option<String>, pub readiness: Option<String>,
pub needs_preflight: Option<bool>, pub needs_preflight: Option<bool>,
pub action_required: Option<String>, pub action_required: Option<String>,
pub workflow_state: TicketWorkflowState,
pub workflow_state_explicit: bool,
pub attention_required: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
@ -597,6 +675,14 @@ pub trait TicketBackend {
field: &str, field: &str,
change: TicketStateChange, change: TicketStateChange,
) -> Result<()>; ) -> Result<()>;
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
@ -729,6 +815,45 @@ impl LocalTicketBackend {
}) })
} }
fn ticket_workflow_state_from_item(&self, item: &Path) -> Result<TicketWorkflowState> {
let parsed = read_item_file(item)?;
let meta = ticket_meta(parsed.frontmatter);
Ok(meta.workflow_state)
}
fn apply_workflow_state_change(
&self,
dir: &Path,
expected_from: TicketWorkflowState,
to: TicketWorkflowState,
change: TicketStateChange,
extra_updates: &[(&str, &str)],
) -> Result<()> {
validate_state_change(&change)?;
if change.from.as_str() != expected_from.as_str() || change.to.as_str() != to.as_str() {
return Err(TicketError::Conflict(format!(
"workflow_state change payload mismatch: expected {} -> {}, got {} -> {}",
expected_from.as_str(),
to.as_str(),
change.from,
change.to
)));
}
let item = dir.join("item.md");
let current = self.ticket_workflow_state_from_item(&item)?;
if current != expected_from {
return Err(TicketError::Conflict(format!(
"workflow_state changed concurrently: expected `{}`, found `{}`",
expected_from.as_str(),
current.as_str()
)));
}
self.append_state_changed_event(dir, &change, Some("workflow_state"))?;
let mut updates = vec![("workflow_state", to.as_str())];
updates.extend_from_slice(extra_updates);
self.set_frontmatter_fields(&item, &updates)
}
fn append_thread_event( fn append_thread_event(
&self, &self,
dir: &Path, dir: &Path,
@ -836,6 +961,11 @@ impl TicketBackend for LocalTicketBackend {
readiness: meta.readiness, readiness: meta.readiness,
needs_preflight: meta.needs_preflight, needs_preflight: meta.needs_preflight,
action_required: meta.action_required, action_required: meta.action_required,
workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit,
attention_required: meta.attention_required,
queued_by: meta.queued_by,
queued_at: meta.queued_at,
updated_at: meta.updated_at, updated_at: meta.updated_at,
}); });
} }
@ -898,6 +1028,14 @@ impl TicketBackend for LocalTicketBackend {
fields.push(("kind".to_string(), input.kind)); fields.push(("kind".to_string(), input.kind));
fields.push(("priority".to_string(), input.priority)); fields.push(("priority".to_string(), input.priority));
fields.push(("labels".to_string(), labels_yaml(&input.labels))); fields.push(("labels".to_string(), labels_yaml(&input.labels)));
fields.push((
"workflow_state".to_string(),
input
.workflow_state
.unwrap_or(TicketWorkflowState::Intake)
.as_str()
.to_string(),
));
fields.push(("created_at".to_string(), created.clone())); fields.push(("created_at".to_string(), created.clone()));
fields.push(("updated_at".to_string(), created.clone())); fields.push(("updated_at".to_string(), created.clone()));
fields.push(( fields.push((
@ -920,6 +1058,15 @@ impl TicketBackend for LocalTicketBackend {
if let Some(action_required) = input.action_required { if let Some(action_required) = input.action_required {
fields.push(("action_required".to_string(), action_required)); fields.push(("action_required".to_string(), action_required));
} }
if let Some(attention_required) = input.attention_required {
fields.push(("attention_required".to_string(), attention_required));
}
if let Some(queued_by) = input.queued_by {
fields.push(("queued_by".to_string(), queued_by));
}
if let Some(queued_at) = input.queued_at {
fields.push(("queued_at".to_string(), queued_at));
}
let item = serialize_item(&fields, input.body.as_str()); let item = serialize_item(&fields, input.body.as_str());
atomic_write(&dir.join("item.md"), item.as_bytes())?; atomic_write(&dir.join("item.md"), item.as_bytes())?;
let thread = format!( let thread = format!(
@ -967,6 +1114,11 @@ impl TicketBackend for LocalTicketBackend {
change: TicketStateChange, change: TicketStateChange,
) -> Result<()> { ) -> Result<()> {
validate_state_field_name(field)?; validate_state_field_name(field)?;
if field == "workflow_state" {
return Err(TicketError::Conflict(
"workflow_state transitions must use dedicated workflow APIs".to_string(),
));
}
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md"); let item = dir.join("item.md");
@ -986,6 +1138,91 @@ impl TicketBackend for LocalTicketBackend {
self.set_frontmatter_fields(&item, &[(field, change.to.as_str())]) self.set_frontmatter_fields(&item, &[(field, change.to.as_str())])
} }
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition source: {}",
change.from
))
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition target: {}",
change.to
))
})?;
if !TicketWorkflowState::is_role_transition(from, to) {
return Err(TicketError::Conflict(format!(
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated intake-ready or queue APIs for gated transitions",
from.as_str(),
to.as_str()
)));
}
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
self.apply_workflow_state_change(&dir, from, to, change, &[])
}
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()> {
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition source: {}",
change.from
))
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition target: {}",
change.to
))
})?;
if !TicketWorkflowState::is_intake_ready_transition(from, to) {
return Err(TicketError::Conflict(format!(
"mark_intake_ready only allows workflow_state intake -> ready, got {} -> {}",
from.as_str(),
to.as_str()
)));
}
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let current = self.ticket_workflow_state_from_item(&dir.join("item.md"))?;
if current != from {
return Err(TicketError::Conflict(format!(
"workflow_state changed concurrently: expected `{}`, found `{}`",
from.as_str(),
current.as_str()
)));
}
self.append_intake_summary_event(&dir, &summary)?;
self.apply_workflow_state_change(&dir, from, to, change, &[])
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
validate_required_event_value("queued_by", queued_by)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let at = now_utc();
let mut change = TicketStateChange::new(
TicketWorkflowState::Ready.as_str(),
TicketWorkflowState::Queued.as_str(),
"queued",
"Ticket queued for Orchestrator routing.\n",
);
change.author = Some(queued_by.to_string());
self.apply_workflow_state_change(
&dir,
TicketWorkflowState::Ready,
TicketWorkflowState::Queued,
change,
&[("queued_by", queued_by), ("queued_at", at.as_str())],
)
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> { fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
@ -1060,9 +1297,25 @@ impl TicketBackend for LocalTicketBackend {
fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?; fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?;
} }
let at = now_utc(); let at = now_utc();
let current_workflow_state =
self.ticket_workflow_state_from_item(&closed_dir.join("item.md"))?;
if current_workflow_state != TicketWorkflowState::Done {
let mut change = TicketStateChange::new(
current_workflow_state.as_str(),
TicketWorkflowState::Done.as_str(),
"closed",
"Ticket closed; workflow_state set to done.\n",
);
change.author = Some(default_author());
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
}
self.set_frontmatter_fields( self.set_frontmatter_fields(
&closed_dir.join("item.md"), &closed_dir.join("item.md"),
&[("status", "closed"), ("updated_at", &at)], &[
("status", "closed"),
("workflow_state", TicketWorkflowState::Done.as_str()),
("updated_at", &at),
],
)?; )?;
atomic_write( atomic_write(
&closed_dir.join("resolution.md"), &closed_dir.join("resolution.md"),
@ -1178,6 +1431,28 @@ impl TicketBackend for LocalTicketBackend {
Some(item.clone()), Some(item.clone()),
); );
} }
match parsed.frontmatter.get("workflow_state").map(String::as_str) {
Some(value) if TicketWorkflowState::parse(value).is_none() => report
.push_error(
format!("invalid workflow_state '{value}': {}", item.display()),
Some(item.clone()),
),
_ => {}
}
if status == TicketStatus::Closed
&& parsed
.frontmatter
.get("workflow_state")
.is_none_or(|value| value != TicketWorkflowState::Done.as_str())
{
report.push_warning(
format!(
"closed ticket should have workflow_state: done: {}",
item.display()
),
Some(item.clone()),
);
}
if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() { if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() {
report.push_warning( report.push_warning(
format!("closed ticket missing resolution.md: {}", dir.display()), format!("closed ticket missing resolution.md: {}", dir.display()),
@ -1309,6 +1584,11 @@ fn ticket_meta(frontmatter: BTreeMap<String, String>) -> TicketMeta {
.or_else(|| frontmatter.get("risks")) .or_else(|| frontmatter.get("risks"))
.map(|value| parse_yaml_list(value)) .map(|value| parse_yaml_list(value))
.unwrap_or_default(); .unwrap_or_default();
let workflow_state_explicit = frontmatter.contains_key("workflow_state");
let workflow_state = frontmatter
.get("workflow_state")
.and_then(|value| TicketWorkflowState::parse(value))
.unwrap_or_else(|| TicketWorkflowState::default_for_status(&status));
TicketMeta { TicketMeta {
id, id,
slug, slug,
@ -1331,6 +1611,11 @@ fn ticket_meta(frontmatter: BTreeMap<String, String>) -> TicketMeta {
.and_then(|value| parse_bool(value)), .and_then(|value| parse_bool(value)),
risk_flags, risk_flags,
action_required: frontmatter.get("action_required").cloned(), action_required: frontmatter.get("action_required").cloned(),
workflow_state,
workflow_state_explicit,
attention_required: frontmatter.get("attention_required").cloned(),
queued_by: frontmatter.get("queued_by").cloned(),
queued_at: frontmatter.get("queued_at").cloned(),
raw: frontmatter, raw: frontmatter,
} }
} }
@ -1929,6 +2214,10 @@ readiness: implementation-ready
needs_preflight: false needs_preflight: false
risk_flags: [low, local] risk_flags: [low, local]
action_required: none action_required: none
workflow_state: ready
attention_required: none
queued_by: workspace-panel
queued_at: 2026-06-05T00:01:00Z
--- ---
## Body ## Body
@ -1941,6 +2230,11 @@ action_required: none
assert_eq!(meta.needs_preflight, Some(false)); assert_eq!(meta.needs_preflight, Some(false));
assert_eq!(meta.risk_flags, vec!["low", "local"]); assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.action_required.as_deref(), Some("none")); assert_eq!(meta.action_required.as_deref(), Some("none"));
assert_eq!(meta.workflow_state, TicketWorkflowState::Ready);
assert!(meta.workflow_state_explicit);
assert_eq!(meta.attention_required.as_deref(), Some("none"));
assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel"));
assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z"));
} }
#[test] #[test]
@ -1955,6 +2249,9 @@ action_required: none
assert!(dir.join("thread.md").exists()); assert!(dir.join("thread.md").exists());
assert!(dir.join("artifacts/.gitkeep").exists()); assert!(dir.join("artifacts/.gitkeep").exists());
assert_eq!(ticket.slug, "example-ticket"); assert_eq!(ticket.slug, "example-ticket");
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert!(record.meta.workflow_state_explicit);
let report = backend.doctor().unwrap(); let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics); assert!(report.is_ok(), "{:?}", report.diagnostics);
} }
@ -2167,6 +2464,171 @@ action_required: none
)); ));
} }
#[test]
fn workflow_state_defaults_and_queue_transition_round_trip() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut missing_frontmatter = BTreeMap::new();
missing_frontmatter.insert("status".to_string(), "open".to_string());
let missing_meta = ticket_meta(missing_frontmatter);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake);
assert!(!missing_meta.workflow_state_explicit);
let mut closed_frontmatter = BTreeMap::new();
closed_frontmatter.insert("status".to_string(), "closed".to_string());
let closed_meta = ticket_meta(closed_frontmatter);
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done);
assert!(!closed_meta.workflow_state_explicit);
let mut ready_input = NewTicket::new("Ready Workflow");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
.unwrap();
let queued = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap();
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
assert!(queued.meta.workflow_state_explicit);
assert_eq!(queued.meta.queued_by.as_deref(), Some("workspace-panel"));
assert!(queued.meta.queued_at.is_some());
let event = queued
.events
.iter()
.find(|event| event.kind == TicketEventKind::StateChanged)
.unwrap();
assert_eq!(event.state_field.as_deref(), Some("workflow_state"));
assert_eq!(event.from.as_deref(), Some("ready"));
assert_eq!(event.to.as_deref(), Some("queued"));
assert_eq!(event.reason.as_deref(), Some("queued"));
}
#[test]
fn workflow_queue_rejects_non_ready_ticket_without_mutation() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Intake Ticket")).unwrap();
assert!(matches!(
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
Err(TicketError::Conflict(_))
));
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert!(record.meta.queued_by.is_none());
assert!(
!record
.events
.iter()
.any(|event| event.kind == TicketEventKind::StateChanged)
);
}
#[test]
fn workflow_state_cannot_be_changed_through_generic_state_field_api() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend
.create(NewTicket::new("Generic Workflow Bypass"))
.unwrap();
let change = TicketStateChange::new(
"intake",
"done",
"bypass",
"Generic state field API must not mutate workflow_state.",
);
assert!(matches!(
backend.set_state_field(
TicketIdOrSlug::Id(ticket.id.clone()),
"workflow_state",
change
),
Err(TicketError::Conflict(_))
));
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
}
#[test]
fn mark_intake_ready_records_summary_and_state_change() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Intake Ready")).unwrap();
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
summary.author = Some("intake".to_string());
let mut change =
TicketStateChange::new("intake", "ready", "accepted", "Ticket is ready to queue.");
change.author = Some("intake".to_string());
backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(
record
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary)
);
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.from.as_deref() == Some("intake")
&& event.to.as_deref() == Some("ready")
}));
}
#[test]
fn close_sets_workflow_state_done() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut input = NewTicket::new("Close Workflow");
input.workflow_state = Some(TicketWorkflowState::Queued);
let ticket = backend.create(input).unwrap();
backend
.close(
TicketIdOrSlug::Id(ticket.id.clone()),
MarkdownText::new("Completed."),
)
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed);
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.to.as_deref() == Some("done")
}));
}
#[test]
fn doctor_reports_invalid_workflow_state() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
fs::write(
root.join("open/bad/item.md"),
"---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nworkflow_state: almost\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n",
)
.unwrap();
fs::write(root.join("open/bad/thread.md"), "").unwrap();
fs::create_dir_all(root.join("pending")).unwrap();
fs::create_dir_all(root.join("closed")).unwrap();
let report = LocalTicketBackend::new(&root).doctor().unwrap();
let messages = report
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(!report.is_ok());
assert!(messages.contains("invalid workflow_state"));
}
#[test] #[test]
fn doctor_validates_typed_thread_event_attributes() { fn doctor_validates_typed_thread_event_attributes() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@ -14,8 +14,8 @@ use serde_json::{Value, json};
use crate::{ use crate::{
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket, ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket,
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
TicketEventKind, TicketIdOrSlug, TicketRef, TicketReview, TicketReviewResult, TicketStatus, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
TicketSummary, TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 100; const DEFAULT_LIST_LIMIT: usize = 100;
@ -29,12 +29,14 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500; const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 8] = [ pub const TICKET_TOOL_NAMES: [&str; 10] = [
"TicketCreate", "TicketCreate",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus", "TicketStatus",
"TicketClose", "TicketClose",
"TicketDoctor", "TicketDoctor",
@ -54,6 +56,12 @@ const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` mu
configured Ticket backend root."; configured Ticket backend root.";
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root."; `request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket intake as ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `workflow_state`, and transitions workflow_state to `ready`.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \
Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \
status; use `TicketStatus` or `TicketClose` for local status changes.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \ const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \ Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `yoi ticket doctor`."; by `yoi ticket doctor`.";
@ -103,6 +111,40 @@ struct TicketCreateParams {
/// Optional action-required frontmatter value. /// Optional action-required frontmatter value.
#[serde(default)] #[serde(default)]
action_required: Option<String>, action_required: Option<String>,
/// Optional workflow_state frontmatter value. Defaults to `intake`.
#[serde(default)]
workflow_state: Option<TicketWorkflowStateParam>,
/// Optional attention_required overlay frontmatter value.
#[serde(default)]
attention_required: Option<String>,
/// Optional queued_by frontmatter value.
#[serde(default)]
queued_by: Option<String>,
/// Optional queued_at frontmatter value.
#[serde(default)]
queued_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketWorkflowStateParam {
Intake,
Ready,
Queued,
Inprogress,
Done,
}
impl TicketWorkflowStateParam {
fn into_state(self) -> TicketWorkflowState {
match self {
Self::Intake => TicketWorkflowState::Intake,
Self::Ready => TicketWorkflowState::Ready,
Self::Queued => TicketWorkflowState::Queued,
Self::Inprogress => TicketWorkflowState::InProgress,
Self::Done => TicketWorkflowState::Done,
}
}
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -212,6 +254,40 @@ struct TicketStatusParams {
status: TicketStatusParam, status: TicketStatusParam,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id or slug.
ticket: String,
/// Concise bounded intake summary to append as a typed intake_summary event.
intake_summary: String,
/// Optional author for both intake_summary and state_changed events.
#[serde(default)]
author: Option<String>,
/// Reason attached to the state_changed event. Defaults to `intake_ready`.
#[serde(default)]
reason: Option<String>,
/// Optional state_changed body. If omitted, a concise default is used.
#[serde(default)]
state_change_body: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams {
/// Ticket id or slug.
ticket: String,
/// Expected current workflow_state. The backend rejects stale transitions.
from: TicketWorkflowStateParam,
/// Target workflow_state.
to: TicketWorkflowStateParam,
/// Reason attached to the typed state_changed event.
reason: String,
/// Markdown body for the typed state_changed event.
body: String,
/// Optional thread author.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams { struct TicketCloseParams {
/// Ticket id or slug. /// Ticket id or slug.
@ -278,6 +354,16 @@ struct TicketReviewTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
} }
#[derive(Clone)]
struct TicketIntakeReadyTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketWorkflowStateTool {
backend: LocalTicketBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketStatusTool { struct TicketStatusTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
@ -316,6 +402,12 @@ impl Tool for TicketCreateTool {
input.needs_preflight = params.needs_preflight; input.needs_preflight = params.needs_preflight;
input.risk_flags = params.risk_flags; input.risk_flags = params.risk_flags;
input.action_required = params.action_required; input.action_required = params.action_required;
input.workflow_state = params
.workflow_state
.map(TicketWorkflowStateParam::into_state);
input.attention_required = params.attention_required;
input.queued_by = params.queued_by;
input.queued_at = params.queued_at;
let created = self let created = self
.backend .backend
@ -470,6 +562,76 @@ impl Tool for TicketReviewTool {
} }
} }
#[async_trait]
impl Tool for TicketIntakeReadyTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Intake;
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
format!(
"Ticket intake complete; workflow_state {} -> ready.\n",
from.as_str()
)
});
let mut summary = TicketIntakeSummary::new(params.intake_summary);
summary.author = params.author.clone();
let mut change = TicketStateChange::new(
from.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
body,
);
change.author = params.author;
self.backend
.mark_intake_ready(
TicketIdOrSlug::Query(params.ticket.clone()),
summary,
change,
)
.map_err(|error| backend_error("TicketIntakeReady", error))?;
Ok(json_output(
format!("Marked ticket {} workflow_state ready", params.ticket),
json!({ "ticket": params.ticket, "workflow_state": "ready", "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketWorkflowStateTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketWorkflowStateParams = parse_input("TicketWorkflowState", input_json)?;
let from = params.from.into_state();
let to = params.to.into_state();
if from == to {
return Err(ToolError::InvalidArgument(
"workflow_state transition must change state".to_string(),
));
}
let mut change =
TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body);
change.author = params.author;
self.backend
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
.map_err(|error| backend_error("TicketWorkflowState", error))?;
Ok(json_output(
format!(
"Transitioned ticket {} workflow_state {} -> {}",
params.ticket,
from.as_str(),
to.as_str()
),
json!({
"ticket": params.ticket,
"from": from.as_str(),
"to": to.as_str(),
"workflow_state": to.as_str(),
"ok": true
}),
))
}
}
#[async_trait] #[async_trait]
impl Tool for TicketStatusTool { impl Tool for TicketStatusTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> { async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@ -586,6 +748,11 @@ fn ticket_summary_json(ticket: TicketSummary) -> Value {
"readiness": ticket.readiness, "readiness": ticket.readiness,
"needs_preflight": ticket.needs_preflight, "needs_preflight": ticket.needs_preflight,
"action_required": ticket.action_required, "action_required": ticket.action_required,
"workflow_state": ticket.workflow_state.as_str(),
"workflow_state_explicit": ticket.workflow_state_explicit,
"attention_required": ticket.attention_required,
"queued_by": ticket.queued_by,
"queued_at": ticket.queued_at,
"updated_at": ticket.updated_at, "updated_at": ticket.updated_at,
}) })
} }
@ -641,6 +808,11 @@ fn ticket_json(
"needs_preflight": ticket.meta.needs_preflight, "needs_preflight": ticket.meta.needs_preflight,
"risk_flags": ticket.meta.risk_flags, "risk_flags": ticket.meta.risk_flags,
"action_required": ticket.meta.action_required, "action_required": ticket.meta.action_required,
"workflow_state": ticket.meta.workflow_state.as_str(),
"workflow_state_explicit": ticket.meta.workflow_state_explicit,
"attention_required": ticket.meta.attention_required,
"queued_by": ticket.meta.queued_by,
"queued_at": ticket.meta.queued_at,
}, },
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
"events": { "events": {
@ -736,6 +908,10 @@ fn input_schema(name: &str) -> Value {
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), "TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)), "TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)), "TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)), "TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)), "TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
@ -759,6 +935,8 @@ impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool); impl_from_backend!(TicketShowTool);
impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool); impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketStatusTool); impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketDoctorTool); impl_from_backend!(TicketDoctorTool);
@ -771,6 +949,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
tool_definition::<TicketShowTool>("TicketShow", SHOW_DESCRIPTION, backend.clone()), tool_definition::<TicketShowTool>("TicketShow", SHOW_DESCRIPTION, backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", COMMENT_DESCRIPTION, backend.clone()), tool_definition::<TicketCommentTool>("TicketComment", COMMENT_DESCRIPTION, backend.clone()),
tool_definition::<TicketReviewTool>("TicketReview", REVIEW_DESCRIPTION, backend.clone()), tool_definition::<TicketReviewTool>("TicketReview", REVIEW_DESCRIPTION, backend.clone()),
tool_definition::<TicketIntakeReadyTool>(
"TicketIntakeReady",
INTAKE_READY_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketWorkflowStateTool>(
"TicketWorkflowState",
WORKFLOW_STATE_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()), tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend), tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
@ -913,6 +1101,217 @@ mod tests {
); );
} }
#[tokio::test]
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
intake_ready
.execute(
&json!({
"ticket": created.slug,
"intake_summary": "Requirements accepted; implementation can be queued.",
"author": "intake-pod"
})
.to_string(),
)
.await
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(created.id.clone()), "panel")
.unwrap();
workflow
.execute(
&json!({
"ticket": created.slug,
"from": "queued",
"to": "inprogress",
"reason": "orchestrator_started",
"body": "Orchestrator started implementation.\n",
"author": "orchestrator"
})
.to_string(),
)
.await
.unwrap();
workflow
.execute(
&json!({
"ticket": created.slug,
"from": "inprogress",
"to": "done",
"reason": "implementation_complete",
"body": "Implementation finished and is ready for close.\n",
"author": "orchestrator"
})
.to_string(),
)
.await
.unwrap();
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(
record
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary)
);
let transitions = record
.events
.iter()
.filter(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
})
.map(|event| (event.from.as_deref(), event.to.as_deref()))
.collect::<Vec<_>>();
assert_eq!(
transitions,
vec![
(Some("intake"), Some("ready")),
(Some("ready"), Some("queued")),
(Some("queued"), Some("inprogress")),
(Some("inprogress"), Some("done"))
]
);
}
#[tokio::test]
async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend
.create(NewTicket::new("Stale Workflow Tool"))
.unwrap();
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
let error = workflow
.execute(
&json!({
"ticket": created.id,
"from": "queued",
"to": "inprogress",
"reason": "orchestrator_started",
"body": "Should not apply.\n"
})
.to_string(),
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("workflow_state changed concurrently")
);
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
}));
}
#[tokio::test]
async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
let mut ready_input = NewTicket::new("Ready Bypass");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let ready_error = workflow
.execute(
&json!({
"ticket": ready.id,
"from": "ready",
"to": "inprogress",
"reason": "bypass_queue",
"body": "Should not bypass Queue.\n"
})
.to_string(),
)
.await
.unwrap_err();
assert!(ready_error.to_string().contains("not allowed"));
let mut done_input = NewTicket::new("Backward Bypass");
done_input.workflow_state = Some(TicketWorkflowState::Done);
let done = backend.create(done_input).unwrap();
let backward_error = workflow
.execute(
&json!({
"ticket": done.id,
"from": "done",
"to": "intake",
"reason": "backwards",
"body": "Should not move backwards.\n"
})
.to_string(),
)
.await
.unwrap_err();
assert!(backward_error.to_string().contains("not allowed"));
let mut queued_input = NewTicket::new("Skip Bypass");
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
let queued = backend.create(queued_input).unwrap();
let skip_error = workflow
.execute(
&json!({
"ticket": queued.id,
"from": "queued",
"to": "done",
"reason": "skip_inprogress",
"body": "Should not skip inprogress.\n"
})
.to_string(),
)
.await
.unwrap_err();
assert!(skip_error.to_string().contains("not allowed"));
}
#[tokio::test]
async fn ticket_intake_ready_tool_rejects_non_intake_ticket() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Already Ready");
input.workflow_state = Some(TicketWorkflowState::Ready);
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
let error = intake_ready
.execute(
&json!({
"ticket": created.id,
"intake_summary": "Should not rewrite ready ticket."
})
.to_string(),
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("workflow_state changed concurrently")
);
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
}));
}
#[tokio::test] #[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() { async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -23,7 +23,7 @@ use session_store::FsStore;
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use ticket::{ use ticket::{
LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug, LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug,
TicketStatus, TicketStatus, TicketWorkflowState,
}; };
use tokio::net::UnixStream; use tokio::net::UnixStream;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@ -1297,13 +1297,24 @@ async fn dispatch_ticket_action(
} }
match request.action { match request.action {
NextUserAction::Go | NextUserAction::ApproveIntake => { NextUserAction::Queue => {
append_panel_decision(&backend, &request.ticket_id, panel_go_body(current_ticket))?; 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 = let notification =
notify_workspace_orchestrator(request.orchestrator, current_ticket).await; notify_workspace_orchestrator(request.orchestrator, current_ticket).await;
Ok(TicketActionOutcome { Ok(TicketActionOutcome {
notice: format!( notice: format!(
"Recorded Panel Go for Ticket {}; {}. No implementation was started.", "Queued Ticket {}; {}. No implementation was started.",
current_ticket.slug, current_ticket.slug,
notification.sentence() notification.sentence()
), ),
@ -1341,12 +1352,6 @@ async fn dispatch_ticket_action(
}; };
Ok(TicketActionOutcome { notice }) 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 { NextUserAction::Close => Ok(TicketActionOutcome {
notice: format!( notice: format!(
"Close for Ticket {} requires explicit resolution text; no close was recorded.", "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())) .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 { fn panel_defer_body(ticket: &crate::workspace_panel::TicketPanelEntry) -> String {
format!( 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 ticket.slug, ticket.id
) )
} }
@ -1403,7 +1401,7 @@ async fn notify_workspace_orchestrator(
); );
}; };
let message = format!( 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 ticket.slug, ticket.id
); );
match send_notify_only(&target.socket_path, message).await { 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 { } else {
format!(" {total} rows") 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( Line::from(Span::styled(
text, text,
Style::default() 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_STATE_COLUMN_WIDTH: usize = 10;
const TICKET_ACTION_COLUMN_WIDTH: usize = 7;
const TICKET_STATUS_COLUMN_WIDTH: usize = 24;
const TICKET_PHASE_COLUMN_WIDTH: usize = 12;
const TICKET_ID_COLUMN_WIDTH: usize = 32; const TICKET_ID_COLUMN_WIDTH: usize = 32;
const POD_STATUS_COLUMN_WIDTH: usize = 18; 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> { fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let marker = if selected { "" } else { " " }; let marker = if selected { "" } else { " " };
@ -1968,12 +1961,6 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
} else { } else {
Style::default().fg(Color::Magenta) 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 ticket_ref = panel_ticket_reference(row);
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut remaining = width as usize; let mut remaining = width as usize;
@ -1990,32 +1977,11 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
}, },
&mut remaining, &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( push_column_span(
&mut spans, &mut spans,
&row.status, &row.status,
TICKET_STATUS_COLUMN_WIDTH, TICKET_STATE_COLUMN_WIDTH,
Style::default().fg(Color::DarkGray), panel_priority_style(row.priority),
&mut remaining,
);
push_column_span(
&mut spans,
phase,
TICKET_PHASE_COLUMN_WIDTH,
Style::default().fg(Color::DarkGray),
&mut remaining, &mut remaining,
); );
push_column_span( push_column_span(
@ -2085,10 +2051,7 @@ fn padded_cell(value: &str, width: usize) -> String {
fn panel_priority_style(priority: ActionPriority) -> Style { fn panel_priority_style(priority: ActionPriority) -> Style {
match priority { match priority {
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
ActionPriority::ReadyForGo => Style::default().fg(Color::Green), ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
ActionPriority::Decision => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
ActionPriority::Blocked => Style::default().fg(Color::Red), ActionPriority::Blocked => Style::default().fg(Color::Red),
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
ActionPriority::Background => Style::default().fg(Color::DarkGray), 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) Style::default().fg(Color::Cyan)
}; };
let (status, status_style) = row_status_label(entry); 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 spans = Vec::new();
let mut remaining = width as usize; let mut remaining = width as usize;
@ -2161,20 +2117,6 @@ fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> {
status_style, status_style,
&mut remaining, &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); push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining);
Line::from(spans) 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) { 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() .selected_panel_row()
.filter(|row| row.is_ticket_action()) .filter(|row| row.is_ticket_action())
{ {
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
Line::from(vec![ Line::from(vec![
Span::styled("action ", Style::default().fg(Color::DarkGray)), Span::styled("composer ", Style::default().fg(Color::DarkGray)),
Span::styled( Span::styled(
row.title.clone(), app.composer_target().label(),
Style::default().add_modifier(Modifier::BOLD), 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( Span::styled(
format!("[{}]", row.priority.label()), app.composer_target().label(),
panel_priority_style(row.priority), Style::default()
), .fg(Color::Green)
Span::raw(" "), .add_modifier(Modifier::BOLD),
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),
), ),
Span::styled(" · pod ", Style::default().fg(Color::DarkGray)),
Span::styled(status.to_string(), status_style),
]) ])
} else { } else {
match app.selected_pod_entry() { Line::from(vec![
Some(entry) => { Span::styled("composer ", Style::default().fg(Color::DarkGray)),
let (status, status_style) = row_status_label(entry); Span::styled(
let send_text = if entry.actions.can_send_now { app.composer_target().label(),
"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",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)), ),
} Span::styled(" · no selection", Style::default().fg(Color::DarkGray)),
])
}; };
let mut prefix = vec![ frame.render_widget(Paragraph::new(target), area);
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);
} }
fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { 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 crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo};
use std::fs; use std::fs;
use tempfile::TempDir; 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) { fn ready_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -2385,9 +2298,13 @@ mod tests {
author: None, author: None,
assignee: None, assignee: None,
labels: Vec::new(), labels: Vec::new(),
readiness: Some("ready".to_string()), readiness: None,
action_required: 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(), risk_flags: Vec::new(),
legacy_ticket: None, legacy_ticket: None,
}) })
@ -2409,28 +2326,32 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn ticket_go_action_records_decision_without_starting_implementation() { async fn ticket_queue_action_transitions_ready_ticket_without_starting_implementation() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-go"); let (temp, ticket_id, backend) = ready_ticket_workspace("panel-queue");
let outcome = 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 .await
.unwrap(); .unwrap();
assert!(outcome.notice.contains("Recorded Panel Go")); assert!(outcome.notice.contains("Queued Ticket"));
assert!(outcome.notice.contains("No implementation was started")); assert!(outcome.notice.contains("No implementation was started"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open)); 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 .events
.iter() .iter()
.find(|event| { .find(|event| {
event.kind == TicketEventKind::Decision event.kind == TicketEventKind::StateChanged
&& event.body.as_str().contains("Panel Go recorded") && 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"); .expect("queue state_changed event is recorded");
assert_eq!(decision.author.as_deref(), Some("workspace-panel")); assert_eq!(state_change.author.as_deref(), Some("workspace-panel"));
assert!(decision.body.as_str().contains("does not enqueue or spawn"));
} }
#[tokio::test] #[tokio::test]
@ -2441,7 +2362,7 @@ mod tests {
.await .await
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("current action is Go")); assert!(error.to_string().contains("current action is Queue"));
} }
#[tokio::test] #[tokio::test]
@ -2450,15 +2371,17 @@ mod tests {
fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap(); fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap();
let error = 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 .await
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("Ticket config is absent")); assert!(error.to_string().contains("Ticket config is absent"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); 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| { assert!(!ticket.events.iter().any(|event| {
event.kind == TicketEventKind::Decision event.kind == TicketEventKind::StateChanged
&& event.body.as_str().contains("Panel Go recorded") && event.state_field.as_deref() == Some("workflow_state")
})); }));
} }
@ -2498,6 +2421,31 @@ mod tests {
TicketReview::approve("reviewed"), TicketReview::approve("reviewed"),
) )
.unwrap(); .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 = let outcome =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
@ -2520,19 +2468,12 @@ mod tests {
) )
.unwrap(); .unwrap();
let outcome = dispatch_ticket_action(request_for( let error =
&temp, dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait))
ticket_id.clone(), .await
NextUserAction::Review, .unwrap_err();
))
.await
.unwrap();
assert!( assert!(error.to_string().contains("current action is Queue"));
outcome
.notice
.contains("requires explicit approve/request-changes")
);
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert!( assert!(
!ticket !ticket
@ -2543,7 +2484,7 @@ mod tests {
} }
#[tokio::test] #[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 temp = TempDir::new().unwrap();
let socket_path = temp.path().join("orchestrator.sock"); let socket_path = temp.path().join("orchestrator.sock");
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
@ -2572,13 +2513,13 @@ mod tests {
reader.next::<Method>().await.unwrap().unwrap() 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 .await
.unwrap(); .unwrap();
let method = server.await.unwrap(); let method = server.await.unwrap();
assert!(matches!( assert!(matches!(
method, 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( let review_row = panel_test_ticket_row(
"workspace-panel-composer-targets", "workspace-panel-composer-targets",
"Workspace panel composer targets", "Workspace panel composer targets",
ActionPriority::Decision, ActionPriority::ActiveWork,
NextUserAction::Review, NextUserAction::Wait,
"implementation reported", "inprogress",
crate::workspace_panel::TicketPanelPhase::Reviewing,
); );
let ready_row = panel_test_ticket_row( let ready_row = panel_test_ticket_row(
"ticket-slug", "ticket-slug",
"Long Ticket title that should be rendered after short columns", "Long Ticket title that should be rendered after short columns",
ActionPriority::ReadyForGo, ActionPriority::ReadyForQueue,
NextUserAction::Go, NextUserAction::Queue,
"ready for Go", "ready",
crate::workspace_panel::TicketPanelPhase::Preflight,
); );
let review_line = plain_line(&panel_row_line(&review_row, true, 160)); 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 ready_line = plain_line(&panel_row_line(&ready_row, false, 160));
let action_start = 2 + TICKET_PRIORITY_COLUMN_WIDTH + 1; let state_start = 2;
let status_start = action_start + TICKET_ACTION_COLUMN_WIDTH + 1; let id_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
let phase_start = status_start + TICKET_STATUS_COLUMN_WIDTH + 1;
let id_start = phase_start + TICKET_PHASE_COLUMN_WIDTH + 1;
let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1; let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1;
assert!(!review_line.starts_with("▶ Workspace panel composer targets")); assert!(!review_line.starts_with("▶ Workspace panel composer targets"));
assert_eq!(display_column(&review_line, "Review"), action_start); assert_eq!(display_column(&review_line, "inprogress"), state_start);
assert_eq!(display_column(&ready_line, "Go"), action_start); assert_eq!(display_column(&ready_line, "ready"), state_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!( assert_eq!(
display_column(&review_line, "workspace-panel-composer-targets"), display_column(&review_line, "workspace-panel-composer-targets"),
id_start id_start
@ -2862,24 +2792,13 @@ mod tests {
let row = panel_test_ticket_row( let row = panel_test_ticket_row(
"ticket-slug", "ticket-slug",
"Very long Ticket title that should truncate only after the aligned short columns", "Very long Ticket title that should truncate only after the aligned short columns",
ActionPriority::ReadyForGo, ActionPriority::ReadyForQueue,
NextUserAction::Go, NextUserAction::Queue,
"ready for Go", "ready",
crate::workspace_panel::TicketPanelPhase::Preflight,
); );
let line = plain_line(&panel_row_line(&row, false, 112)); let line = plain_line(&panel_row_line(&row, false, 112));
let title_start = 2 let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1;
+ 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;
assert_eq!(line.width(), 112); assert_eq!(line.width(), 112);
assert_eq!( assert_eq!(
@ -2911,15 +2830,11 @@ mod tests {
let idle_line = plain_line(&row_line(idle, false, 120)); let idle_line = plain_line(&row_line(idle, false, 120));
let running_line = plain_line(&row_line(running, false, 120)); let running_line = plain_line(&row_line(running, false, 120));
let action_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; let name_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;
assert!(!running_line.starts_with(" very-long-background-worker-name")); assert!(!running_line.starts_with(" very-long-background-worker-name"));
assert_eq!(display_column(&idle_line, "send"), action_start); assert_eq!(display_column(&idle_line, "live idle"), 2);
assert_eq!(display_column(&running_line, "open"), action_start); assert_eq!(display_column(&running_line, "live running"), 2);
assert_eq!(display_column(&idle_line, "pod"), kind_start);
assert_eq!(display_column(&running_line, "pod"), kind_start);
assert_eq!(display_column(&idle_line, "companion"), name_start); assert_eq!(display_column(&idle_line, "companion"), name_start);
assert_eq!( assert_eq!(
display_column(&running_line, "very-long-background-worker-name"), display_column(&running_line, "very-long-background-worker-name"),
@ -2928,7 +2843,7 @@ mod tests {
} }
#[test] #[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( let app = test_app(vec![live_info(
"very-long-background-worker-name-that-keeps-going", "very-long-background-worker-name-that-keeps-going",
PodStatus::Running, PodStatus::Running,
@ -2936,23 +2851,10 @@ mod tests {
let entry = app.list.selected_entry().unwrap(); let entry = app.list.selected_entry().unwrap();
let line = plain_line(&row_line(entry, false, 58)); let line = plain_line(&row_line(entry, false, 58));
let name_start = 2 let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1;
+ POD_STATUS_COLUMN_WIDTH
+ 1
+ POD_ACTION_COLUMN_WIDTH
+ 1
+ POD_KIND_COLUMN_WIDTH
+ 1;
assert_eq!(line.width(), 58); assert_eq!(line.width(), 58);
assert_eq!( assert_eq!(display_column(&line, "live running"), 2);
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, "very-long"), name_start); assert_eq!(display_column(&line, "very-long"), name_start);
assert!(line.ends_with('…')); assert!(line.ends_with('…'));
} }
@ -3556,7 +3458,6 @@ mod tests {
priority: ActionPriority, priority: ActionPriority,
next_action: NextUserAction, next_action: NextUserAction,
status: &str, status: &str,
phase: crate::workspace_panel::TicketPanelPhase,
) -> PanelRow { ) -> PanelRow {
let ticket = crate::workspace_panel::TicketPanelEntry { let ticket = crate::workspace_panel::TicketPanelEntry {
id: format!("20260606-000000-{slug}"), id: format!("20260606-000000-{slug}"),
@ -3566,7 +3467,10 @@ mod tests {
kind: "task".to_string(), kind: "task".to_string(),
priority: "P2".to_string(), priority: "P2".to_string(),
labels: Vec::new(), 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), 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()),

View File

@ -4,8 +4,7 @@ use protocol::PodStatus;
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
use ticket::{ use ticket::{
ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent,
TicketEventKind, TicketFilter, TicketIdOrSlug, TicketMeta, TicketReviewResult, TicketStatus, TicketFilter, TicketIdOrSlug, TicketMeta, TicketStatus, TicketSummary, TicketWorkflowState,
TicketSummary,
}; };
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
@ -152,32 +151,16 @@ pub(crate) enum PanelRowKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ActionPriority { pub(crate) enum ActionPriority {
UserReply, UserReply,
ReadyForGo, ReadyForQueue,
Decision,
Blocked, Blocked,
ActiveWork, ActiveWork,
Background, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NextUserAction { pub(crate) enum NextUserAction {
Clarify, Clarify,
ApproveIntake, Queue,
Go,
Review,
Close, Close,
Defer, Defer,
Edit, Edit,
@ -190,9 +173,7 @@ impl NextUserAction {
pub(crate) fn label(self) -> &'static str { pub(crate) fn label(self) -> &'static str {
match self { match self {
Self::Clarify => "Clarify", Self::Clarify => "Clarify",
Self::ApproveIntake => "Approve", Self::Queue => "Queue",
Self::Go => "Go",
Self::Review => "Review",
Self::Close => "Close", Self::Close => "Close",
Self::Defer => "Defer", Self::Defer => "Defer",
Self::Edit => "Edit", 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketPanelEntry { pub(crate) struct TicketPanelEntry {
pub(crate) id: String, pub(crate) id: String,
@ -243,7 +193,9 @@ pub(crate) struct TicketPanelEntry {
pub(crate) kind: String, pub(crate) kind: String,
pub(crate) priority: String, pub(crate) priority: String,
pub(crate) labels: Vec<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) 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>,
@ -270,7 +222,6 @@ pub(crate) struct PanelRow {
impl PanelRow { impl PanelRow {
pub(crate) fn is_ticket_action(&self) -> bool { pub(crate) fn is_ticket_action(&self) -> bool {
!matches!(self.kind, PanelRowKind::Pod) !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(), readiness: meta.readiness.clone(),
needs_preflight: meta.needs_preflight, needs_preflight: meta.needs_preflight,
action_required: meta.action_required.clone(), 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(), updated_at: meta.updated_at.clone(),
} }
} }
@ -521,7 +477,7 @@ fn build_ticket_rows(
fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow { fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow {
let related_pods = related_pods_for_ticket(&summary, pods); 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 latest_event = events.last();
let entry = TicketPanelEntry { let entry = TicketPanelEntry {
id: summary.id.clone(), id: summary.id.clone(),
@ -531,7 +487,9 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) ->
kind: summary.kind.clone(), kind: summary.kind.clone(),
priority: summary.priority.clone(), priority: summary.priority.clone(),
labels: summary.labels.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, 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()),
@ -545,7 +503,7 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) ->
kind: derived.kind, kind: derived.kind,
title: summary.title, title: summary.title,
subtitle, subtitle,
status: derived.status, status: summary.workflow_state.as_str().to_string(),
priority: derived.priority, priority: derived.priority,
next_action: derived.action, next_action: derived.action,
ticket: Some(entry), ticket: Some(entry),
@ -558,8 +516,6 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) ->
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct DerivedTicketState { struct DerivedTicketState {
kind: PanelRowKind, kind: PanelRowKind,
phase: TicketPanelPhase,
status: String,
priority: ActionPriority, priority: ActionPriority,
action: Option<NextUserAction>, action: Option<NextUserAction>,
disabled_reason: Option<String>, disabled_reason: Option<String>,
@ -567,239 +523,91 @@ struct DerivedTicketState {
blocked_reason: Option<String>, blocked_reason: Option<String>,
} }
fn derive_ticket_state(summary: &TicketSummary, events: &[TicketEvent]) -> DerivedTicketState { fn derive_ticket_state(summary: &TicketSummary) -> 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,
};
}
if summary.status.as_local() == Some(TicketStatus::Pending) { if summary.status.as_local() == Some(TicketStatus::Pending) {
return DerivedTicketState { return DerivedTicketState {
kind: PanelRowKind::Blocked, kind: PanelRowKind::Blocked,
phase: TicketPanelPhase::Pending,
status: "pending/deferred".to_string(),
priority: ActionPriority::Blocked, priority: ActionPriority::Blocked,
action: Some(NextUserAction::Defer), action: Some(NextUserAction::Defer),
disabled_reason: Some( 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, blocked_reason: None,
}; };
} }
if intake { if let Some(reason) = summary
.attention_required
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return DerivedTicketState { return DerivedTicketState {
kind: PanelRowKind::Intake, kind: PanelRowKind::Blocked,
phase: TicketPanelPhase::Intake,
status: "intake draft".to_string(),
priority: ActionPriority::UserReply, priority: ActionPriority::UserReply,
action: Some(NextUserAction::ApproveIntake), action: Some(NextUserAction::Edit),
disabled_reason: None, disabled_reason: Some(
key_hint: Some("Approve/edit intake before routing".to_string()), "attention_required is set; resolve it before queueing or routing.".to_string(),
blocked_reason: None, ),
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) { match summary.workflow_state {
return DerivedTicketState { TicketWorkflowState::Ready => DerivedTicketState {
kind: PanelRowKind::Ticket, kind: PanelRowKind::Ticket,
phase: if summary.needs_preflight.unwrap_or(false) { priority: ActionPriority::ReadyForQueue,
TicketPanelPhase::Preflight action: Some(NextUserAction::Queue),
} else {
TicketPanelPhase::Open
},
status: "ready for Go".to_string(),
priority: ActionPriority::ReadyForGo,
action: Some(NextUserAction::Go),
disabled_reason: None, disabled_reason: None,
key_hint: Some( key_hint: Some(
"Go is an authorization affordance; routing/preflight gates still apply" "Queue transitions ready -> queued and may notify Orchestrator".to_string(),
.to_string(),
), ),
blocked_reason: None, blocked_reason: None,
}; },
} TicketWorkflowState::Queued => DerivedTicketState {
if spike && latest_plan.is_some() {
return DerivedTicketState {
kind: PanelRowKind::ActiveWork, kind: PanelRowKind::ActiveWork,
phase: TicketPanelPhase::Spike,
status: "spike running".to_string(),
priority: ActionPriority::ActiveWork, priority: ActionPriority::ActiveWork,
action: Some(NextUserAction::Wait), 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, key_hint: None,
blocked_reason: None, blocked_reason: None,
}; },
} TicketWorkflowState::InProgress => DerivedTicketState {
kind: PanelRowKind::ActiveWork,
if spike { priority: ActionPriority::ActiveWork,
return DerivedTicketState { action: Some(NextUserAction::Wait),
kind: PanelRowKind::Ticket, disabled_reason: Some("Ticket is already in progress.".to_string()),
phase: TicketPanelPhase::Spike, key_hint: None,
status: "spike needed".to_string(), blocked_reason: None,
},
TicketWorkflowState::Done => DerivedTicketState {
kind: PanelRowKind::Review,
priority: ActionPriority::Background, priority: ActionPriority::Background,
action: None, action: Some(NextUserAction::Close),
disabled_reason: Some( disabled_reason: Some(
"Spike candidate is shown as background until explicitly readied or planned." "workflow_state is done; close if a resolution is still missing.".to_string(),
.to_string(),
), ),
key_hint: None, key_hint: None,
blocked_reason: None, blocked_reason: None,
}; },
} TicketWorkflowState::Intake => DerivedTicketState {
kind: PanelRowKind::Intake,
if latest_plan.is_some() { priority: ActionPriority::Background,
return DerivedTicketState { action: Some(NextUserAction::Clarify),
kind: PanelRowKind::ActiveWork,
phase: TicketPanelPhase::Implementing,
status: "planned/active".to_string(),
priority: ActionPriority::ActiveWork,
action: Some(NextUserAction::Wait),
disabled_reason: Some( 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, 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> { 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> { fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
let mut parts = vec![format!( let mut parts = vec![format!(
"{} · {} · {}", "{} · {}",
entry.slug, entry.slug,
entry.phase.label(), entry.workflow_state.as_str()
entry.priority
)]; )];
if let Some(reason) = entry.attention_required.as_deref() {
parts.push(format!("attention: {reason}"));
}
if !entry.related_pods.is_empty() { if !entry.related_pods.is_empty() {
parts.push(format!("pods: {}", entry.related_pods.join(", "))); parts.push(format!("pods: {}", entry.related_pods.join(", ")));
} }
@ -941,7 +751,7 @@ 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::{MarkdownText, NewTicket, NewTicketEvent, TicketReview}; use ticket::{NewTicket, TicketWorkflowState};
fn empty_pods() -> PodList { fn empty_pods() -> PodList {
PodList::from_sources( PodList::from_sources(
@ -1021,16 +831,16 @@ mod tests {
} }
#[test] #[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(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Ready Ticket", "ready-ticket", |input| { 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| { create_ticket(&backend, "Needs User", "needs-user", |input| {
input.action_required = Some("answer clarification".to_string()); input.workflow_state = Some(TicketWorkflowState::Ready);
input.labels = vec!["intake".to_string()]; input.attention_required = Some("answer clarification".to_string());
}); });
let model = build_workspace_panel(temp.path(), &empty_pods()); let model = build_workspace_panel(temp.path(), &empty_pods());
@ -1041,128 +851,99 @@ mod tests {
let rows = model let rows = model
.rows .rows
.iter() .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<_>>(); .collect::<Vec<_>>();
assert_eq!(rows[0].0, "Needs User"); assert_eq!(rows[0].0, "Needs User");
assert_eq!(rows[0].1, ActionPriority::UserReply); assert_eq!(rows[0].1, "ready");
assert_eq!(rows[0].2, Some(NextUserAction::ApproveIntake)); 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].0, "Ready Ticket");
assert_eq!(rows[1].1, ActionPriority::ReadyForGo); assert_eq!(rows[1].1, "ready");
assert_eq!(rows[1].2, Some(NextUserAction::Go)); assert_eq!(rows[1].2, ActionPriority::ReadyForQueue);
assert_eq!(rows[1].3, Some(NextUserAction::Queue));
} }
#[test] #[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(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket( create_ticket(
&backend, &backend,
"Investigate Spike", "Readiness Heuristic",
"investigate-spike", "readiness-heuristic",
|input| { |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| { create_ticket(&backend, "Label Heuristic", "label-heuristic", |input| {
input.kind = "spike".to_string(); 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 model = build_workspace_panel(temp.path(), &empty_pods());
let needed = model let readiness = model
.rows .rows
.iter() .iter()
.find(|row| row.title == "Investigate Spike") .find(|row| row.title == "Readiness Heuristic")
.unwrap(); .unwrap();
let running = model let label = model
.rows .rows
.iter() .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(); .unwrap();
assert_eq!( assert_eq!(readiness.status, "intake");
needed.ticket.as_ref().unwrap().phase, assert_eq!(readiness.next_action, Some(NextUserAction::Clarify));
TicketPanelPhase::Spike assert_eq!(label.status, "intake");
); assert_eq!(label.next_action, Some(NextUserAction::Clarify));
assert_eq!(needed.priority, ActionPriority::Background); assert_eq!(queued.status, "queued");
assert_eq!(needed.next_action, None); assert_eq!(queued.next_action, Some(NextUserAction::Wait));
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));
} }
#[test] #[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(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {}); 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 model = build_workspace_panel(temp.path(), &empty_pods());
let row = model let backlog = model
.rows .rows
.iter() .iter()
.find(|row| row.title == "Plain Backlog") .find(|row| row.title == "Plain Backlog")
.unwrap(); .unwrap();
let done = model
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
.rows .rows
.iter() .iter()
.find(|row| row.title == "Needs Review") .find(|row| row.title == "Done Explicit")
.unwrap();
let close = model
.rows
.iter()
.find(|row| row.title == "Close Ready")
.unwrap(); .unwrap();
assert_eq!(review.priority, ActionPriority::Decision); assert_eq!(backlog.status, "intake");
assert_eq!(review.next_action, Some(NextUserAction::Review)); assert_eq!(backlog.next_action, Some(NextUserAction::Clarify));
assert_eq!(close.priority, ActionPriority::Decision); assert!(backlog.is_ticket_action());
assert_eq!(close.next_action, Some(NextUserAction::Close)); assert_eq!(done.status, "done");
assert_eq!(done.next_action, Some(NextUserAction::Close));
} }
#[test] #[test]