From 0a5270a2cca629e66b6bf7708bb69316def43ce5 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 20 Jul 2026 15:45:53 +0900 Subject: [PATCH 1/6] ticket: update list query semantics --- crates/ticket/src/lib.rs | 157 ++++++++++++++++---- crates/ticket/src/tool.rs | 147 +++++++++++++----- crates/tui/src/workspace_panel.rs | 13 +- crates/worker/src/feature/builtin/ticket.rs | 8 +- crates/workspace-server/src/records.rs | 4 +- crates/yoi/src/ticket_cli.rs | 95 ++++++++---- 6 files changed, 324 insertions(+), 100 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 36dcd375..a68dd4d4 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -510,18 +510,75 @@ impl NewTicket { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketFilter { - pub state: Option, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketStateSelector { + /// All non-closed workflow states: planning, ready, queued, inprogress, and done. + Active, + /// Every workflow state, including closed. + All, + /// An explicit set of workflow states. + States(BTreeSet), } -impl TicketFilter { +impl Default for TicketStateSelector { + fn default() -> Self { + Self::Active + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketListQuery { + pub state: TicketStateSelector, +} + +impl Default for TicketListQuery { + fn default() -> Self { + Self::active() + } +} + +impl TicketListQuery { + pub fn active() -> Self { + Self { + state: TicketStateSelector::Active, + } + } + pub fn all() -> Self { - Self { state: None } + Self { + state: TicketStateSelector::All, + } } pub fn state(state: TicketWorkflowState) -> Self { - Self { state: Some(state) } + Self::states([state]) + } + + pub fn states(states: impl IntoIterator) -> Self { + Self { + state: TicketStateSelector::States(states.into_iter().collect()), + } + } + + pub fn matches_state(&self, state: TicketWorkflowState) -> bool { + match &self.state { + TicketStateSelector::Active => state != TicketWorkflowState::Closed, + TicketStateSelector::All => true, + TicketStateSelector::States(states) => states.contains(&state), + } + } + + pub fn state_filter_label(&self) -> String { + match &self.state { + TicketStateSelector::Active => "active".to_string(), + TicketStateSelector::All => "all".to_string(), + TicketStateSelector::States(states) => states + .iter() + .map(|state| state.as_str()) + .collect::>() + .join(","), + } } } @@ -873,7 +930,7 @@ impl TicketDoctorReport { pub trait TicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String; - fn list(&self, filter: TicketFilter) -> Result>; + fn list(&self, filter: TicketListQuery) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; fn create(&self, input: NewTicket) -> Result; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; @@ -926,7 +983,7 @@ pub enum TicketBackendOperation { from: String, }, List { - filter: TicketFilter, + filter: TicketListQuery, }, Show { id: TicketIdOrSlug, @@ -1143,10 +1200,10 @@ impl LocalTicketBackend { } } - pub fn list_partial(&self, filter: TicketFilter) -> Result { + pub fn list_partial(&self, filter: TicketListQuery) -> Result { let mut output = TicketPartialList::default(); let mut invalid_seen = BTreeSet::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); if !item.exists() { continue; @@ -1155,10 +1212,7 @@ impl LocalTicketBackend { .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) { Ok(meta) => { - if filter - .state - .is_some_and(|state| meta.workflow_state != state) - { + if !filter.matches_state(meta.workflow_state) { continue; } output.tickets.push(ticket_summary_from_meta(meta)); @@ -1255,7 +1309,7 @@ impl LocalTicketBackend { } } - fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result> { + fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result> { let mut dirs = Vec::new(); if !self.root.exists() { return Ok(dirs); @@ -1275,10 +1329,10 @@ impl LocalTicketBackend { if !item.is_file() { continue; } - if let Some(state) = filter.state { + if !matches!(filter.state, TicketStateSelector::All) { let parsed = read_item_file(&item)?; let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?; - if meta.workflow_state != state { + if !filter.matches_state(meta.workflow_state) { continue; } } @@ -1495,7 +1549,7 @@ impl LocalTicketBackend { fn all_ticket_relation_records(&self) -> Result> { let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { relations.extend(self.read_ticket_relations_for_dir(&dir)?); } sort_ticket_relations(&mut relations); @@ -1508,7 +1562,7 @@ impl LocalTicketBackend { invalid_seen: &mut BTreeSet, ) -> Result> { let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { match self.read_ticket_relations_for_dir(&dir) { Ok(records) => relations.extend(records), Err(error) => { @@ -1539,7 +1593,7 @@ impl LocalTicketBackend { fn ticket_state_index(&self) -> Result> { let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; states.insert(meta.id, meta.workflow_state); @@ -1553,7 +1607,7 @@ impl LocalTicketBackend { invalid_seen: &mut BTreeSet, ) -> Result> { let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); match read_item_file(&item) .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) @@ -1589,7 +1643,7 @@ impl TicketBackend for LocalTicketBackend { self.default_intake_ready_state_change_body(from) } - fn list(&self, filter: TicketFilter) -> Result> { + fn list(&self, filter: TicketListQuery) -> Result> { let mut tickets = Vec::new(); for dir in self.iter_ticket_dirs(filter)? { let item = dir.join("item.md"); @@ -2088,7 +2142,7 @@ impl TicketBackend for LocalTicketBackend { let dir = self.find_ticket_dir(&ticket)?; records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); } else { - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); } } @@ -2117,7 +2171,7 @@ impl TicketBackend for LocalTicketBackend { } } - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let ticket_id = match ticket_id_from_dir(&dir) { Ok(id) => id, Err(err) => { @@ -3975,6 +4029,57 @@ state: planning assert!(err.contains("invalid YAML frontmatter"), "{err}"); } + #[test] + fn list_query_defaults_to_active_and_supports_all_or_explicit_states() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); + let mut ready_input = NewTicket::new("Ready Ticket"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let mut closed_input = NewTicket::new("Closed Ticket"); + closed_input.workflow_state = Some(TicketWorkflowState::Closed); + let closed = backend.create(closed_input).unwrap(); + + let active = backend.list(TicketListQuery::default()).unwrap(); + let active_ids = active + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(active_ids.contains(&planning.id.as_str())); + assert!(active_ids.contains(&ready.id.as_str())); + assert!(!active_ids.contains(&closed.id.as_str())); + + let all = backend.list(TicketListQuery::all()).unwrap(); + let all_ids = all + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(all_ids.contains(&planning.id.as_str())); + assert!(all_ids.contains(&ready.id.as_str())); + assert!(all_ids.contains(&closed.id.as_str())); + + let ready_only = backend + .list(TicketListQuery::state(TicketWorkflowState::Ready)) + .unwrap(); + assert_eq!(ready_only.len(), 1); + assert_eq!(ready_only[0].id, ready.id); + + let planning_or_closed = backend + .list(TicketListQuery::states([ + TicketWorkflowState::Planning, + TicketWorkflowState::Closed, + ])) + .unwrap(); + let explicit_ids = planning_or_closed + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(explicit_ids.contains(&planning.id.as_str())); + assert!(explicit_ids.contains(&closed.id.as_str())); + assert!(!explicit_ids.contains(&ready.id.as_str())); + } + #[test] fn create_writes_local_ticket_layout() { let tmp = TempDir::new().unwrap(); @@ -4036,9 +4141,9 @@ state: planning ) .unwrap(); - assert!(backend.list(TicketFilter::all()).is_err()); + assert!(backend.list(TicketListQuery::all()).is_err()); - let partial = backend.list_partial(TicketFilter::all()).unwrap(); + let partial = backend.list_partial(TicketListQuery::all()).unwrap(); assert_eq!(partial.tickets.len(), 1); assert_eq!(partial.tickets[0].id, valid.id); assert_eq!(partial.invalid_records.len(), 1); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index c25540f3..33dea587 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -97,8 +97,8 @@ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed T Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \ backend assigns the id and writes the local Ticket file layout under the configured backend root."; const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \ -lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \ -`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \ +lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \ +state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \ routing, closing, planning, or implementation decisions."; const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \ @@ -214,7 +214,7 @@ impl TicketBackend for TicketToolBackend { self.backend.default_intake_ready_state_change_body(from) } - fn list(&self, filter: crate::TicketFilter) -> TicketResult> { + fn list(&self, filter: crate::TicketListQuery) -> TicketResult> { self.backend.list(filter) } @@ -375,9 +375,10 @@ impl TicketWorkflowStateParam { } } -#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] enum TicketListStateParam { + Active, Planning, Ready, Queued, @@ -388,47 +389,62 @@ enum TicketListStateParam { } impl TicketListStateParam { - fn as_filter(self) -> (crate::TicketFilter, &'static str) { + fn as_state(self) -> Option { match self { - Self::Planning => ( - crate::TicketFilter::state(TicketWorkflowState::Planning), - "planning", - ), - Self::Ready => ( - crate::TicketFilter::state(TicketWorkflowState::Ready), - "ready", - ), - Self::Queued => ( - crate::TicketFilter::state(TicketWorkflowState::Queued), - "queued", - ), - Self::Inprogress => ( - crate::TicketFilter::state(TicketWorkflowState::InProgress), - "inprogress", - ), - Self::Done => ( - crate::TicketFilter::state(TicketWorkflowState::Done), - "done", - ), - Self::Closed => ( - crate::TicketFilter::state(TicketWorkflowState::Closed), - "closed", - ), - Self::All => (crate::TicketFilter::all(), "all"), + Self::Planning => Some(TicketWorkflowState::Planning), + Self::Ready => Some(TicketWorkflowState::Ready), + Self::Queued => Some(TicketWorkflowState::Queued), + Self::Inprogress => Some(TicketWorkflowState::InProgress), + Self::Done => Some(TicketWorkflowState::Done), + Self::Closed => Some(TicketWorkflowState::Closed), + Self::Active | Self::All => None, } } } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketListParams { - /// State filter. Defaults to all Tickets. + /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets. #[serde(default)] state: Option, + /// Explicit workflow-state filter list. Cannot be combined with `state`. + #[serde(default)] + states: Option>, /// Maximum number of summaries to return. Defaults to 50, max 100. #[serde(default)] limit: Option, } +impl TicketListParams { + fn into_query(self) -> Result<(crate::TicketListQuery, String, Option), TicketError> { + let query = if let Some(states) = self.states { + if self.state.is_some() { + return Err(TicketError::Conflict( + "TicketList accepts either `state` or `states`, not both".to_string(), + )); + } + if states.is_empty() { + return Err(TicketError::Conflict( + "TicketList `states` must include at least one workflow state".to_string(), + )); + } + crate::TicketListQuery::states(states.into_iter().map(|state| state.into_state())) + } else { + match self.state.unwrap_or(TicketListStateParam::Active) { + TicketListStateParam::Active => crate::TicketListQuery::active(), + TicketListStateParam::All => crate::TicketListQuery::all(), + state => crate::TicketListQuery::state( + state + .as_state() + .expect("workflow state list param maps to TicketWorkflowState"), + ), + } + }; + let label = query.state_filter_label(); + Ok((query, label, self.limit)) + } +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketShowParams { /// Ticket id. Exactly one of `id` or `query` must be provided. @@ -825,9 +841,10 @@ impl Tool for TicketListTool { _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let params: TicketListParams = parse_input("TicketList", input_json)?; - let state = params.state.unwrap_or(TicketListStateParam::All); - let (filter, state_filter) = state.as_filter(); - let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); + let (filter, state_filter, params_limit) = params + .into_query() + .map_err(|error| backend_error("TicketList", error))?; + let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); let tickets = self .backend .list(filter) @@ -1825,6 +1842,16 @@ mod tests { .unwrap(); } + let active = list + .execute(&json!({}).to_string(), Default::default()) + .await + .unwrap(); + let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap(); + assert_eq!(active_json["state_filter"], "active"); + assert_eq!(active_json["count"].as_u64(), Some(3)); + assert_eq!(active_json["returned"].as_u64(), Some(3)); + assert_eq!(active_json["truncated"].as_bool(), Some(false)); + let all = list .execute(&json!({ "state": "all" }).to_string(), Default::default()) .await @@ -1857,6 +1884,53 @@ mod tests { assert_eq!(closed_json["truncated"].as_bool(), Some(true)); } + #[tokio::test] + async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); + let mut ready_input = NewTicket::new("Ready Ticket"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let mut closed_input = NewTicket::new("Closed Ticket"); + closed_input.workflow_state = Some(TicketWorkflowState::Closed); + let closed = backend.create(closed_input).unwrap(); + + let listed = list + .execute( + &json!({ "states": ["planning", "closed"] }).to_string(), + Default::default(), + ) + .await + .unwrap(); + let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap(); + assert_eq!(listed_json["state_filter"], "planning,closed"); + assert_eq!(listed_json["count"].as_u64(), Some(2)); + let listed_ids = listed_json["tickets"] + .as_array() + .unwrap() + .iter() + .map(|ticket| ticket["id"].as_str().unwrap()) + .collect::>(); + assert!(listed_ids.contains(&planning.id.as_str())); + assert!(listed_ids.contains(&closed.id.as_str())); + assert!(!listed_ids.contains(&ready.id.as_str())); + + let mixed = list + .execute( + &json!({ "state": "active", "states": ["planning"] }).to_string(), + Default::default(), + ) + .await; + assert!(mixed.is_err()); + + let empty = list + .execute(&json!({ "states": [] }).to_string(), Default::default()) + .await; + assert!(empty.is_err()); + } + #[tokio::test] async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { let temp = TempDir::new().unwrap(); @@ -2408,7 +2482,10 @@ mod tests { assert!(!id.contains("escape")); assert!(!temp.path().join("escape").exists()); assert!(temp.path().join("tickets").join(id).is_dir()); - assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1); + assert_eq!( + backend.list(crate::TicketListQuery::all()).unwrap().len(), + 1 + ); } #[test] diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 670cd102..00eb6c5d 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -10,8 +10,9 @@ use ticket::config::{ WORKSPACE_SETTINGS_RELATIVE_PATH, }; use ticket::{ - LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, - TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, + LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, + TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, + TicketWorkflowState, }; use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; @@ -693,7 +694,7 @@ fn load_orchestration_ticket_overlay_states( let backend = LocalTicketBackend::new(ticket_root.to_path_buf()) .with_record_language(overlay_config.ticket_record_language()); let partial = backend - .list_partial(TicketFilter::all()) + .list_partial(TicketListQuery::all()) .map_err(|error| error.to_string())?; let mut states = BTreeMap::new(); for summary in partial.tickets { @@ -1092,7 +1093,7 @@ fn build_ticket_rows( registry: &PanelRegistrySnapshot, orchestration_overlay: &BTreeMap, ) -> ticket::Result { - let partial = backend.list_partial(TicketFilter::all())?; + let partial = backend.list_partial(TicketListQuery::all())?; let mut ticket_rows = Vec::new(); let mut invalid_records = partial.invalid_records; for summary in partial.tickets { @@ -2522,7 +2523,7 @@ mod tests { input.workflow_state = Some(TicketWorkflowState::Ready); }); let ticket_id = backend - .list(TicketFilter::all()) + .list(TicketListQuery::all()) .unwrap() .into_iter() .find(|ticket| ticket.title == "Ticket With Intake") @@ -2619,7 +2620,7 @@ mod tests { write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket(&backend, "Claimed Planning", |_| {}); - let summary = backend.list(TicketFilter::all()).unwrap().remove(0); + let summary = backend.list(TicketListQuery::all()).unwrap().remove(0); let store = PanelRegistryStore::from_root(temp.path().join("local-registry")); store .claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake") diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 6fd4292e..168b640b 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -10,9 +10,9 @@ use ticket::{ LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation, - TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug, - TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, - TicketReview, TicketStateChange, TicketSummary, + TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug, + TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind, + TicketRelationView, TicketReview, TicketStateChange, TicketSummary, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, tool::{ TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, @@ -400,7 +400,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } - fn list(&self, filter: TicketFilter) -> TicketResult> { + fn list(&self, filter: TicketListQuery) -> TicketResult> { expect_ticket_result!( self.invoke(TicketBackendOperation::List { filter }), TicketBackendOperationResult::Tickets diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 204c8f08..800bc7e6 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; use ticket::config::TicketConfig; -use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; +use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery}; use crate::{Error, Result}; @@ -35,7 +35,7 @@ impl LocalProjectRecordReader { } pub fn list_tickets(&self, limit: usize) -> Result> { - let partial = self.ticket_backend.list_partial(TicketFilter::all())?; + let partial = self.ticket_backend.list_partial(TicketListQuery::all())?; let mut items = partial .tickets .into_iter() diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b345c30b..f3a26475 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -11,7 +11,7 @@ use ticket::config::{ }; use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, - TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, + TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState, }; @@ -45,15 +45,11 @@ pub struct CreateOptions { pub title: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ListState { - Planning, - Ready, - Queued, - InProgress, - Done, - Closed, + Active, All, + States(Vec), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -381,13 +377,9 @@ fn list( options: ListOptions, ) -> Result { let filter = match options.state { - ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning), - ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready), - ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued), - ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress), - ListState::Done => TicketFilter::state(TicketWorkflowState::Done), - ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed), - ListState::All => TicketFilter::all(), + ListState::Active => TicketListQuery::active(), + ListState::All => TicketListQuery::all(), + ListState::States(states) => TicketListQuery::states(states), }; let tickets = backend.list(filter)?; let count = tickets.len(); @@ -750,7 +742,7 @@ fn parse_create(args: &[String]) -> Result { } fn parse_list(args: &[String]) -> Result { - let mut state = ListState::All; + let mut state = ListState::Active; let mut limit = None; let mut i = 0; while i < args.len() { @@ -1042,17 +1034,42 @@ fn option_with_value( Ok(None) } -fn parse_list_state(value: &str) -> Result { - match value { - "planning" => Ok(ListState::Planning), - "ready" => Ok(ListState::Ready), - "queued" => Ok(ListState::Queued), - "inprogress" => Ok(ListState::InProgress), - "done" => Ok(ListState::Done), - "closed" => Ok(ListState::Closed), - "all" => Ok(ListState::All), - _ => Err(TicketCliError::new(format!("invalid state: {value}"))), +fn parse_list_state(raw: &str) -> Result { + let tokens = raw + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + .collect::>(); + if tokens.is_empty() { + return Err(TicketCliError::new("--state must not be empty")); } + if tokens.len() == 1 { + match tokens[0] { + "active" => return Ok(ListState::Active), + "all" => return Ok(ListState::All), + _ => {} + } + } else if tokens + .iter() + .any(|token| *token == "active" || *token == "all") + { + return Err(TicketCliError::new( + "--state active/all cannot be mixed with workflow states", + )); + } + + let mut states = Vec::new(); + for token in tokens { + let state = TicketWorkflowState::parse(token).ok_or_else(|| { + TicketCliError::new(format!( + "invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed" + )) + })?; + if !states.contains(&state) { + states.push(state); + } + } + Ok(ListState::States(states)) } fn parse_list_limit(value: &str) -> Result { @@ -1148,7 +1165,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title \n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n" } #[cfg(test)] @@ -1533,6 +1550,30 @@ mod tests { assert!(err.to_string().contains("use `yoi ticket close")); } + #[test] + fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() { + let default = parse_ticket_args(&args(&["list"])).unwrap(); + match default { + TicketCommand::List(options) => assert_eq!(options.state, ListState::Active), + other => panic!("unexpected command: {other:?}"), + } + + let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap(); + match explicit { + TicketCommand::List(options) => assert_eq!( + options.state, + ListState::States(vec![ + TicketWorkflowState::Planning, + TicketWorkflowState::Closed + ]) + ), + other => panic!("unexpected command: {other:?}"), + } + + let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err(); + assert!(mixed.to_string().contains("cannot be mixed")); + } + #[test] fn ticket_cli_help_lists_required_commands() { let help = parse_ticket_args(&args(&["--help"])).unwrap(); From 556adb429c82fb6c03e1c78ea7209668d87253da Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 20 Jul 2026 17:16:40 +0900 Subject: [PATCH 2/6] ticket: use list state tokens in query --- crates/ticket/src/lib.rs | 65 +++++++++++++++++++++++++++++++----- crates/ticket/src/tool.rs | 35 ++++++++++++------- crates/yoi/src/ticket_cli.rs | 18 +++++----- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index a68dd4d4..49ffcab5 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -510,6 +510,53 @@ impl NewTicket { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TicketListState { + Planning, + Ready, + Queued, + InProgress, + Done, + Closed, +} + +impl TicketListState { + pub fn parse(value: &str) -> Option<Self> { + match value { + "planning" => Some(Self::Planning), + "ready" => Some(Self::Ready), + "queued" => Some(Self::Queued), + "inprogress" => Some(Self::InProgress), + "done" => Some(Self::Done), + "closed" => Some(Self::Closed), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Planning => "planning", + Self::Ready => "ready", + Self::Queued => "queued", + Self::InProgress => "inprogress", + Self::Done => "done", + Self::Closed => "closed", + } + } + + fn matches_workflow_state(self, state: TicketWorkflowState) -> bool { + match self { + Self::Planning => state == TicketWorkflowState::Planning, + Self::Ready => state == TicketWorkflowState::Ready, + Self::Queued => state == TicketWorkflowState::Queued, + Self::InProgress => state == TicketWorkflowState::InProgress, + Self::Done => state == TicketWorkflowState::Done, + Self::Closed => state == TicketWorkflowState::Closed, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TicketStateSelector { @@ -517,8 +564,8 @@ pub enum TicketStateSelector { Active, /// Every workflow state, including closed. All, - /// An explicit set of workflow states. - States(BTreeSet<TicketWorkflowState>), + /// An explicit set of list-query state tokens. + States(BTreeSet<TicketListState>), } impl Default for TicketStateSelector { @@ -551,11 +598,11 @@ impl TicketListQuery { } } - pub fn state(state: TicketWorkflowState) -> Self { + pub fn state(state: TicketListState) -> Self { Self::states([state]) } - pub fn states(states: impl IntoIterator<Item = TicketWorkflowState>) -> Self { + pub fn states(states: impl IntoIterator<Item = TicketListState>) -> Self { Self { state: TicketStateSelector::States(states.into_iter().collect()), } @@ -565,7 +612,9 @@ impl TicketListQuery { match &self.state { TicketStateSelector::Active => state != TicketWorkflowState::Closed, TicketStateSelector::All => true, - TicketStateSelector::States(states) => states.contains(&state), + TicketStateSelector::States(states) => states + .iter() + .any(|query_state| query_state.matches_workflow_state(state)), } } @@ -4060,15 +4109,15 @@ state: planning assert!(all_ids.contains(&closed.id.as_str())); let ready_only = backend - .list(TicketListQuery::state(TicketWorkflowState::Ready)) + .list(TicketListQuery::state(TicketListState::Ready)) .unwrap(); assert_eq!(ready_only.len(), 1); assert_eq!(ready_only[0].id, ready.id); let planning_or_closed = backend .list(TicketListQuery::states([ - TicketWorkflowState::Planning, - TicketWorkflowState::Closed, + TicketListState::Planning, + TicketListState::Closed, ])) .unwrap(); let explicit_ids = planning_or_closed diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 33dea587..0d4f2a8d 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -16,8 +16,8 @@ use crate::{ NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, - TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, - TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState, + TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, + TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -373,6 +373,17 @@ impl TicketWorkflowStateParam { Self::Closed => TicketWorkflowState::Closed, } } + + fn into_list_state(self) -> TicketListState { + match self { + Self::Planning => TicketListState::Planning, + Self::Ready => TicketListState::Ready, + Self::Queued => TicketListState::Queued, + Self::Inprogress => TicketListState::InProgress, + Self::Done => TicketListState::Done, + Self::Closed => TicketListState::Closed, + } + } } #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] @@ -389,14 +400,14 @@ enum TicketListStateParam { } impl TicketListStateParam { - fn as_state(self) -> Option<TicketWorkflowState> { + fn as_list_state(self) -> Option<TicketListState> { match self { - Self::Planning => Some(TicketWorkflowState::Planning), - Self::Ready => Some(TicketWorkflowState::Ready), - Self::Queued => Some(TicketWorkflowState::Queued), - Self::Inprogress => Some(TicketWorkflowState::InProgress), - Self::Done => Some(TicketWorkflowState::Done), - Self::Closed => Some(TicketWorkflowState::Closed), + Self::Planning => Some(TicketListState::Planning), + Self::Ready => Some(TicketListState::Ready), + Self::Queued => Some(TicketListState::Queued), + Self::Inprogress => Some(TicketListState::InProgress), + Self::Done => Some(TicketListState::Done), + Self::Closed => Some(TicketListState::Closed), Self::Active | Self::All => None, } } @@ -428,15 +439,15 @@ impl TicketListParams { "TicketList `states` must include at least one workflow state".to_string(), )); } - crate::TicketListQuery::states(states.into_iter().map(|state| state.into_state())) + crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state())) } else { match self.state.unwrap_or(TicketListStateParam::Active) { TicketListStateParam::Active => crate::TicketListQuery::active(), TicketListStateParam::All => crate::TicketListQuery::all(), state => crate::TicketListQuery::state( state - .as_state() - .expect("workflow state list param maps to TicketWorkflowState"), + .as_list_state() + .expect("workflow state list param maps to TicketListState"), ), } }; diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index f3a26475..3d8312df 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -12,7 +12,8 @@ use ticket::config::{ use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, - TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState, + TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, + TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -49,7 +50,7 @@ pub struct CreateOptions { pub enum ListState { Active, All, - States(Vec<TicketWorkflowState>), + States(Vec<TicketListState>), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1060,7 +1061,7 @@ fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> { let mut states = Vec::new(); for token in tokens { - let state = TicketWorkflowState::parse(token).ok_or_else(|| { + let state = TicketListState::parse(token).ok_or_else(|| { TicketCliError::new(format!( "invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed" )) @@ -1554,18 +1555,17 @@ mod tests { fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() { let default = parse_ticket_args(&args(&["list"])).unwrap(); match default { - TicketCommand::List(options) => assert_eq!(options.state, ListState::Active), + TicketCli::Command(TicketCommand::List(options)) => { + assert_eq!(options.state, ListState::Active) + } other => panic!("unexpected command: {other:?}"), } let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap(); match explicit { - TicketCommand::List(options) => assert_eq!( + TicketCli::Command(TicketCommand::List(options)) => assert_eq!( options.state, - ListState::States(vec![ - TicketWorkflowState::Planning, - TicketWorkflowState::Closed - ]) + ListState::States(vec![TicketListState::Planning, TicketListState::Closed]) ), other => panic!("unexpected command: {other:?}"), } From a40d9c2027db1c9a59aef92b4bcd38ff6a4d2e78 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 20 Jul 2026 17:42:20 +0900 Subject: [PATCH 3/6] ticket: share workspace actionable projection --- crates/ticket/src/lib.rs | 511 ++++++++++++++++++++++++++++++ crates/tui/src/workspace_panel.rs | 310 ++++-------------- 2 files changed, 570 insertions(+), 251 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 49ffcab5..4b89e7e1 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -739,6 +739,415 @@ pub struct TicketRelationView { pub notices: Vec<TicketRelationNotice>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceActionPriority { + ReadyForQueue, + ActiveWork, + Background, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceNextAction { + Clarify, + QueueForOrchestrator, + Close, + WaitForOrchestrator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceRowKind { + Planning, + Ticket, + Review, + ActiveWork, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketWorkspaceStateOverlay { + pub source: String, + pub workflow_state: TicketWorkflowState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketQueueGuard { + pub can_queue_for_orchestrator: bool, + pub reason: Option<String>, + pub blocked_reason: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketWorkspaceProjection { + pub kind: TicketWorkspaceRowKind, + pub priority: TicketWorkspaceActionPriority, + pub next_action: Option<TicketWorkspaceNextAction>, + pub visible_state: String, + pub visible_overlay: Option<TicketWorkspaceStateOverlay>, + pub disabled_reason: Option<String>, + pub key_hint: Option<String>, + pub blocked_reason: Option<String>, + pub queue_guard: TicketQueueGuard, +} + +pub fn project_ticket_workspace_item( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], + orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, +) -> TicketWorkspaceProjection { + let visible_overlay = orchestration_overlay + .filter(|overlay| { + ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + }) + .cloned(); + let mut projection = derive_ticket_workspace_projection(summary, relation_blockers); + if let Some(overlay) = visible_overlay.as_ref() { + apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay); + } + projection.visible_state = + ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref()); + projection.visible_overlay = visible_overlay; + projection.queue_guard = ticket_queue_guard( + summary, + relation_blockers, + projection.visible_overlay.as_ref(), + ); + projection +} + +pub fn ticket_queue_guard( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], + orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, +) -> TicketQueueGuard { + if orchestration_overlay.is_some() { + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some( + "orchestration overlay already shows progress; duplicate queue is suppressed" + .to_string(), + ), + blocked_reason: None, + }; + } + if summary.workflow_state != TicketWorkflowState::Ready { + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(format!( + "Ticket state is {}; only ready Tickets can be queued for Orchestrator", + summary.workflow_state.as_str() + )), + blocked_reason: None, + }; + } + let active_blockers = relation_blockers + .iter() + .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) + .collect::<Vec<_>>(); + if !active_blockers.is_empty() { + let blockers = format_workspace_relation_blockers(&active_blockers); + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(format!("waiting for {blockers}")), + blocked_reason: Some(blockers), + }; + } + TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + } +} + +fn derive_ticket_workspace_projection( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], +) -> TicketWorkspaceProjection { + if !relation_blockers.is_empty() { + let active_blockers = relation_blockers + .iter() + .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) + .collect::<Vec<_>>(); + if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready { + let blockers_to_report = if active_blockers.is_empty() { + relation_blockers.iter().collect::<Vec<_>>() + } else { + active_blockers + }; + let blockers = format_workspace_relation_blockers(&blockers_to_report); + let waiting_reason = format!("waiting for {blockers}"); + return TicketWorkspaceProjection { + kind: workspace_row_kind_for_state(summary.workflow_state), + priority: match summary.workflow_state { + TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { + TicketWorkspaceActionPriority::ActiveWork + } + _ => TicketWorkspaceActionPriority::Background, + }, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some(format!( + "Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued." + )), + key_hint: Some(format!("Gate: {waiting_reason}")), + blocked_reason: Some(blockers), + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(waiting_reason), + blocked_reason: None, + }, + }; + } + + let blockers = format_workspace_relation_blockers( + &relation_blockers + .iter() + .collect::<Vec<&TicketRelationBlocker>>(), + ); + return TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Ticket, + priority: TicketWorkspaceActionPriority::ReadyForQueue, + next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: None, + key_hint: Some(format!( + "Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})." + )), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + }, + }; + } + + match summary.workflow_state { + TicketWorkflowState::Ready => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Ticket, + priority: TicketWorkspaceActionPriority::ReadyForQueue, + next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: None, + key_hint: Some( + "Queue transitions ready -> queued and may notify Orchestrator".to_string(), + ), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + }, + }, + TicketWorkflowState::Queued => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::ActiveWork, + priority: TicketWorkspaceActionPriority::ActiveWork, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is already queued for Orchestrator routing".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::InProgress => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::ActiveWork, + priority: TicketWorkspaceActionPriority::ActiveWork, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is already in progress.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is already in progress".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Done => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Review, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::Close), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some( + "state is done; close if a resolution is still missing.".to_string(), + ), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is done; close or review instead of queueing".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Planning => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Planning, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::Clarify), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some( + "Ticket is still in planning; mark it ready before queueing.".to_string(), + ), + key_hint: Some("Planning/Intake helpers can set state = ready".to_string()), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is still in planning".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Closed => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Review, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is closed.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is closed".to_string()), + blocked_reason: None, + }, + }, + } +} + +fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind { + match state { + TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning, + TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { + TicketWorkspaceRowKind::ActiveWork + } + TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review, + TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket, + } +} + +fn apply_workspace_overlay_to_projection( + projection: &mut TicketWorkspaceProjection, + local: TicketWorkflowState, + overlay: &TicketWorkspaceStateOverlay, +) { + projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator); + let overlay_state = overlay.workflow_state.as_str(); + match overlay.workflow_state { + TicketWorkflowState::Done | TicketWorkflowState::Closed => { + projection.kind = TicketWorkspaceRowKind::Review; + projection.priority = TicketWorkspaceActionPriority::Background; + projection.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", + overlay.source, + local.as_str() + )); + projection.key_hint = Some(format!( + "Merge pending: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { + projection.kind = TicketWorkspaceRowKind::ActiveWork; + projection.priority = TicketWorkspaceActionPriority::ActiveWork; + projection.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", + overlay.source, + local.as_str() + )); + projection.key_hint = Some(format!( + "Progress overlay: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} + } +} + +fn ticket_workspace_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketWorkspaceStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "{}→{}", + compact_ticket_state_label(local), + compact_ticket_state_label(overlay.workflow_state) + ), + None => local.as_str().to_string(), + } +} + +fn ticket_overlay_state_has_progressed( + local: TicketWorkflowState, + overlay: TicketWorkflowState, +) -> bool { + workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +} + +fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { + match state { + TicketWorkflowState::Planning => 0, + TicketWorkflowState::Ready => 1, + TicketWorkflowState::Queued => 2, + TicketWorkflowState::InProgress => 3, + TicketWorkflowState::Done => 4, + TicketWorkflowState::Closed => 5, + } +} + +fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { + match state { + TicketWorkflowState::Planning => "plan", + TicketWorkflowState::Ready => "ready", + TicketWorkflowState::Queued => "q", + TicketWorkflowState::InProgress => "prog", + TicketWorkflowState::Done => "done", + TicketWorkflowState::Closed => "cls", + } +} + +fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool { + matches!( + blocker.blocking_state, + TicketWorkflowState::Queued | TicketWorkflowState::InProgress + ) +} + +fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String { + let shown_blockers = blockers.iter().take(3).count(); + let mut formatted = blockers + .iter() + .take(3) + .map(|blocker| { + format!( + "{} via {} (state: {})", + blocker.blocking_ticket, + blocker.reason_kind, + blocker.blocking_state.as_str() + ) + }) + .collect::<Vec<_>>() + .join(", "); + let remaining_blockers = blockers.len().saturating_sub(shown_blockers); + if remaining_blockers > 0 { + formatted.push_str(&format!(" (+{remaining_blockers} more)")); + } + formatted +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrchestrationPlanKind { @@ -3962,6 +4371,108 @@ mod tests { LocalTicketBackend::new(dir.path().join("tickets")) } + fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { + TicketSummary { + id: "000TEST".to_string(), + slug: "000TEST".to_string(), + title: "Test Ticket".to_string(), + status: ExtensibleTicketStatus::Open, + kind: "ticket".to_string(), + priority: "P2".to_string(), + labels: Vec::new(), + readiness: None, + workflow_state: state, + workflow_state_explicit: true, + queued_by: None, + queued_at: None, + updated_at: Some("2026-07-20T00:00:00Z".to_string()), + } + } + + fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker { + TicketRelationBlocker { + blocking_ticket: "000BLOCK".to_string(), + reason_kind: "depends_on".to_string(), + relation_kind: TicketRelationKind::DependsOn, + note: None, + blocking_state: state, + } + } + + #[test] + fn workspace_projection_queues_ready_ticket_for_orchestrator() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let projection = project_ticket_workspace_item(&summary, &[], None); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!( + projection.priority, + TicketWorkspaceActionPriority::ReadyForQueue + ); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::QueueForOrchestrator) + ); + assert!(projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.disabled_reason.is_none()); + } + + #[test] + fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let blockers = [blocker_with_state(TicketWorkflowState::Planning)]; + let projection = project_ticket_workspace_item(&summary, &blockers, None); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::WaitForOrchestrator) + ); + assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.blocked_reason.is_some()); + assert!(projection.disabled_reason.is_some()); + } + + #[test] + fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let blockers = [blocker_with_state(TicketWorkflowState::Queued)]; + let projection = project_ticket_workspace_item(&summary, &blockers, None); + + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::QueueForOrchestrator) + ); + assert!(projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.blocked_reason.is_none()); + assert!( + projection + .key_hint + .as_deref() + .unwrap_or_default() + .contains("Orchestrator will preserve order") + ); + } + + #[test] + fn workspace_projection_overlay_suppresses_duplicate_queue() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let overlay = TicketWorkspaceStateOverlay { + source: "orchestration".to_string(), + workflow_state: TicketWorkflowState::InProgress, + }; + let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay)); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::WaitForOrchestrator) + ); + assert_eq!(projection.visible_state, "ready→prog"); + assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.visible_overlay.is_some()); + } + #[test] fn workflow_state_rejects_legacy_intake_alias() { assert_eq!( diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 00eb6c5d..a43a15fd 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -12,7 +12,8 @@ use ticket::config::{ use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, - TicketWorkflowState, + TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction, + TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item, }; use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; @@ -267,11 +268,7 @@ pub(crate) struct TicketPanelEntry { pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketStateOverlay { - pub(crate) source: String, - pub(crate) workflow_state: TicketWorkflowState, -} +pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketAssociatedIntakeEntry { @@ -1233,29 +1230,24 @@ fn ticket_row( related_workers.push(worker_name); } } - let visible_overlay = orchestration_overlay - .filter(|overlay| { - overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) - }) - .cloned(); - let mut derived = derive_ticket_state(&summary, relation_blockers); - if let Some(overlay) = visible_overlay.as_ref() { - apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay); - } + let projection = + project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay); let latest_event = events.last(); - let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref()); + let kind = panel_row_kind_from_workspace(projection.kind); + let priority = action_priority_from_workspace(projection.priority); + let next_action = projection.next_action.map(next_user_action_from_workspace); let entry = TicketPanelEntry { id: summary.id.clone(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, workflow_state_explicit: summary.workflow_state_explicit, - orchestration_overlay: visible_overlay, - next_action: derived.action, + orchestration_overlay: projection.visible_overlay.clone(), + next_action, updated_at: summary.updated_at.clone(), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)), - blocked_reason: derived.blocked_reason.clone(), + blocked_reason: projection.blocked_reason.clone(), related_workers: related_workers.clone(), local_claim, intake_workers, @@ -1263,251 +1255,42 @@ fn ticket_row( let subtitle = ticket_subtitle(&entry); PanelRow { key: PanelRowKey::Ticket(summary.id), - kind: derived.kind, + kind, title: summary.title, subtitle, - status: state_display, - priority: derived.priority, - next_action: derived.action, + status: projection.visible_state, + priority, + next_action, ticket: Some(entry), related_workers, - disabled_reason: derived.disabled_reason, - key_hint: derived.key_hint, + disabled_reason: projection.disabled_reason, + key_hint: projection.key_hint, } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct DerivedTicketState { - kind: PanelRowKind, - priority: ActionPriority, - action: Option<NextUserAction>, - disabled_reason: Option<String>, - key_hint: Option<String>, - blocked_reason: Option<String>, -} - -fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { - match state { - TicketWorkflowState::Planning => 0, - TicketWorkflowState::Ready => 1, - TicketWorkflowState::Queued => 2, - TicketWorkflowState::InProgress => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, +fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind { + match kind { + TicketWorkspaceRowKind::Planning => PanelRowKind::Planning, + TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket, + TicketWorkspaceRowKind::Review => PanelRowKind::Review, + TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork, } } -fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool { - workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) -} - -fn ticket_state_display( - local: TicketWorkflowState, - overlay: Option<&TicketStateOverlay>, -) -> String { - match overlay { - Some(overlay) => format!( - "{}→{}", - compact_ticket_state_label(local), - compact_ticket_state_label(overlay.workflow_state) - ), - None => local.as_str().to_string(), +fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority { + match priority { + TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue, + TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork, + TicketWorkspaceActionPriority::Background => ActionPriority::Background, } } -fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { - match state { - TicketWorkflowState::Planning => "plan", - TicketWorkflowState::Ready => "ready", - TicketWorkflowState::Queued => "q", - TicketWorkflowState::InProgress => "prog", - TicketWorkflowState::Done => "done", - TicketWorkflowState::Closed => "cls", - } -} - -fn apply_orchestration_overlay_to_derived( - derived: &mut DerivedTicketState, - local: TicketWorkflowState, - overlay: &TicketStateOverlay, -) { - derived.action = Some(NextUserAction::Wait); - let overlay_state = overlay.workflow_state.as_str(); - match overlay.workflow_state { - TicketWorkflowState::Done | TicketWorkflowState::Closed => { - derived.kind = PanelRowKind::Review; - derived.priority = ActionPriority::Background; - derived.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", - overlay.source, - local.as_str() - )); - derived.key_hint = Some(format!( - "Merge pending: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { - derived.kind = PanelRowKind::ActiveWork; - derived.priority = ActionPriority::ActiveWork; - derived.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", - overlay.source, - local.as_str() - )); - derived.key_hint = Some(format!( - "Progress overlay: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} - } -} - -fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String { - let shown_blockers = blockers.iter().take(3).count(); - let mut formatted = blockers - .iter() - .take(3) - .map(|blocker| { - format!( - "{} via {} (state: {})", - blocker.blocking_ticket, - blocker.reason_kind, - blocker.blocking_state.as_str() - ) - }) - .collect::<Vec<_>>() - .join(", "); - let remaining_blockers = blockers.len().saturating_sub(shown_blockers); - if remaining_blockers > 0 { - formatted.push_str(&format!(" (+{remaining_blockers} more)")); - } - formatted -} - -fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool { - matches!( - blocker.blocking_state, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress - ) -} - -fn derive_ticket_state( - summary: &TicketSummary, - relation_blockers: &[TicketRelationBlocker], -) -> DerivedTicketState { - if !relation_blockers.is_empty() { - let active_blockers = relation_blockers - .iter() - .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) - .collect::<Vec<_>>(); - if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready { - let blockers_to_report = if active_blockers.is_empty() { - relation_blockers.iter().collect::<Vec<_>>() - } else { - active_blockers - }; - let blockers = format_relation_blockers(&blockers_to_report); - let waiting_reason = format!("waiting for {blockers}"); - return DerivedTicketState { - kind: match summary.workflow_state { - TicketWorkflowState::Planning => PanelRowKind::Planning, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { - PanelRowKind::ActiveWork - } - TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review, - TicketWorkflowState::Ready => PanelRowKind::Ticket, - }, - priority: match summary.workflow_state { - TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { - ActionPriority::ActiveWork - } - _ => ActionPriority::Background, - }, - action: Some(NextUserAction::Wait), - disabled_reason: Some(format!( - "Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued." - )), - key_hint: Some(format!("Gate: {waiting_reason}")), - blocked_reason: Some(blockers), - }; - } - - let blockers = format_relation_blockers( - &relation_blockers - .iter() - .collect::<Vec<&TicketRelationBlocker>>(), - ); - return DerivedTicketState { - kind: PanelRowKind::Ticket, - priority: ActionPriority::ReadyForQueue, - action: Some(NextUserAction::Queue), - disabled_reason: None, - key_hint: Some(format!( - "Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})." - )), - blocked_reason: None, - }; - } - - match summary.workflow_state { - TicketWorkflowState::Ready => DerivedTicketState { - kind: PanelRowKind::Ticket, - priority: ActionPriority::ReadyForQueue, - action: Some(NextUserAction::Queue), - disabled_reason: None, - key_hint: Some( - "Queue transitions ready -> queued and may notify Orchestrator".to_string(), - ), - blocked_reason: None, - }, - TicketWorkflowState::Queued => DerivedTicketState { - kind: PanelRowKind::ActiveWork, - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::InProgress => DerivedTicketState { - kind: PanelRowKind::ActiveWork, - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is already in progress.".to_string()), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::Done => DerivedTicketState { - kind: PanelRowKind::Review, - priority: ActionPriority::Background, - action: Some(NextUserAction::Close), - disabled_reason: Some( - "state is done; close if a resolution is still missing.".to_string(), - ), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::Planning => DerivedTicketState { - kind: PanelRowKind::Planning, - priority: ActionPriority::Background, - action: Some(NextUserAction::Clarify), - disabled_reason: Some( - "Ticket is still in planning; mark it ready before queueing.".to_string(), - ), - key_hint: Some("Planning/Intake helpers can set state = ready".to_string()), - blocked_reason: None, - }, - TicketWorkflowState::Closed => DerivedTicketState { - kind: PanelRowKind::Review, - priority: ActionPriority::Background, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is closed.".to_string()), - key_hint: None, - blocked_reason: None, - }, +fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction { + match action { + TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify, + TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue, + TicketWorkspaceNextAction::Close => NextUserAction::Close, + TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait, } } @@ -1652,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod( TicketLocalClaimStatus::Stale } +fn ticket_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "{}→{}", + compact_ticket_state_label(local), + compact_ticket_state_label(overlay.workflow_state) + ), + None => local.as_str().to_string(), + } +} + +fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { + match state { + TicketWorkflowState::Planning => "plan", + TicketWorkflowState::Ready => "ready", + TicketWorkflowState::Queued => "q", + TicketWorkflowState::InProgress => "prog", + TicketWorkflowState::Done => "done", + TicketWorkflowState::Closed => "cls", + } +} + fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> { let mut parts = vec![format!( "{} · {}", From 2c01e7672b1ac0ae74241666350ce723535e0ec3 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 20 Jul 2026 19:05:55 +0900 Subject: [PATCH 4/6] ticket: split ticket feature access presets --- crates/manifest/src/config.rs | 8 +- crates/manifest/src/lib.rs | 6 + crates/manifest/src/profile.rs | 36 +++- crates/ticket/src/lib.rs | 130 ++++++++++++++ crates/ticket/src/tool.rs | 189 ++++++++++++++++++-- crates/worker/src/controller.rs | 20 ++- crates/worker/src/feature/builtin/ticket.rs | 171 ++++++++++++++++-- 7 files changed, 533 insertions(+), 27 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 62692195..c3003053 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -128,8 +128,12 @@ impl FeatureFlagConfigPartial { pub struct TicketFeatureConfigPartial { #[serde(default)] pub enabled: Option<bool>, + /// Legacy access field. Prefer `preset` for new profile/DCDL authoring. #[serde(default)] pub access: Option<TicketFeatureAccessConfig>, + /// Semantic Ticket access preset for profile/DCDL authoring. + #[serde(default)] + pub preset: Option<TicketFeatureAccessConfig>, } impl TicketFeatureConfigPartial { @@ -137,6 +141,7 @@ impl TicketFeatureConfigPartial { Self { enabled: other.enabled.or(self.enabled), access: other.access.or(self.access), + preset: other.preset.or(self.preset), } } } @@ -190,7 +195,7 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), - access: value.access.unwrap_or_default(), + access: value.preset.or(value.access).unwrap_or_default(), } } } @@ -200,6 +205,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial { Self { enabled: Some(value.enabled), access: Some(value.access), + preset: None, } } } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 4c9989a1..7891cd49 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -180,6 +180,12 @@ impl Default for TicketFeatureConfig { #[serde(rename_all = "snake_case")] pub enum TicketFeatureAccessConfig { ReadOnly, + WorkspaceAuthoring, + Intake, + OrchestrationControl, + WorkReport, + Review, + /// Legacy broad mutation preset retained as a migration shim. Lifecycle, } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index bd2adb68..acd8026e 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -16,8 +16,8 @@ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, - WorkerMetaConfig, paths, + ScopeConfig, ScopeRule, SkillsConfig, TicketFeatureAccessConfig, WebConfig, WorkerManifest, + WorkerManifestConfig, WorkerMetaConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; @@ -1012,6 +1012,18 @@ fn apply_role_profile( value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); value["feature"]["web"] = serde_json::json!({ "enabled": web }); value["feature"]["workers"] = serde_json::json!({ "enabled": workers }); + let ticket_access = match slug { + "companion" => TicketFeatureAccessConfig::WorkspaceAuthoring, + "intake" => TicketFeatureAccessConfig::Intake, + "orchestrator" => TicketFeatureAccessConfig::OrchestrationControl, + "coder" => TicketFeatureAccessConfig::WorkReport, + "reviewer" => TicketFeatureAccessConfig::Review, + _ => TicketFeatureAccessConfig::Lifecycle, + }; + value["feature"]["ticket"] = serde_json::json!({ + "enabled": true, + "preset": ticket_access, + }); value["feature"]["ticket_orchestration"] = serde_json::json!({ "enabled": ticket_orchestration }); } @@ -1439,6 +1451,10 @@ mod tests { assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(companion.web.is_some()); assert!(companion.feature.ticket.enabled); + assert_eq!( + companion.feature.ticket.access, + TicketFeatureAccessConfig::WorkspaceAuthoring + ); assert!(!companion.feature.ticket_orchestration.enabled); assert_eq!( companion.compaction.as_ref().unwrap().threshold, @@ -1461,6 +1477,10 @@ mod tests { assert!(intake.feature.task.enabled); assert!(!intake.feature.workers.enabled); assert!(intake.feature.ticket.enabled); + assert_eq!( + intake.feature.ticket.access, + TicketFeatureAccessConfig::Intake + ); assert!(intake.scope.allow.is_empty()); assert!(intake.delegation_scope.allow.is_empty()); assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1472,6 +1492,10 @@ mod tests { assert!(orchestrator.feature.task.enabled); assert!(orchestrator.feature.workers.enabled); assert!(orchestrator.feature.ticket.enabled); + assert_eq!( + orchestrator.feature.ticket.access, + TicketFeatureAccessConfig::OrchestrationControl + ); assert!(orchestrator.feature.ticket_orchestration.enabled); assert!(orchestrator.scope.allow.is_empty()); assert!(orchestrator.delegation_scope.allow.is_empty()); @@ -1491,12 +1515,20 @@ mod tests { assert!(coder.web.is_some()); assert!(coder.compaction.is_some()); assert!(coder.feature.ticket.enabled); + assert_eq!( + coder.feature.ticket.access, + TicketFeatureAccessConfig::WorkReport + ); assert!(!coder.feature.ticket_orchestration.enabled); let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.workers.enabled); assert!(reviewer.feature.ticket.enabled); + assert_eq!( + reviewer.feature.ticket.access, + TicketFeatureAccessConfig::Review + ); assert!(!reviewer.feature.ticket_orchestration.enabled); assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty()); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 4b89e7e1..b3c04dcb 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -510,6 +510,21 @@ impl NewTicket { } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketItemEdit { + pub title: Option<String>, + pub body: Option<MarkdownText>, + pub author: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketDependencyCheck { + pub ticket: TicketSummary, + pub blockers: Vec<TicketRelationBlocker>, + pub queue_guard: TicketQueueGuard, + pub recommended_action: TicketWorkspaceNextAction, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TicketListState { @@ -1391,6 +1406,8 @@ pub trait TicketBackend { fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>; fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>; fn create(&self, input: NewTicket) -> Result<TicketRef>; + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket>; + fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck>; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; @@ -1449,6 +1466,13 @@ pub enum TicketBackendOperation { Create { input: NewTicket, }, + EditItem { + id: TicketIdOrSlug, + edit: TicketItemEdit, + }, + DependencyCheck { + id: TicketIdOrSlug, + }, AddEvent { id: TicketIdOrSlug, event: NewTicketEvent, @@ -1517,6 +1541,7 @@ pub enum TicketBackendOperationResult { Tickets(Vec<TicketSummary>), Ticket(Ticket), TicketRef(TicketRef), + DependencyCheck(TicketDependencyCheck), Relation(TicketRelation), Relations(Vec<TicketRelation>), RelationView(TicketRelationView), @@ -1547,6 +1572,12 @@ where TicketBackendOperation::Create { input } => { TicketBackendOperationResult::TicketRef(backend.create(input)?) } + TicketBackendOperation::EditItem { id, edit } => { + TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?) + } + TicketBackendOperation::DependencyCheck { id } => { + TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?) + } TicketBackendOperation::AddEvent { id, event } => { backend.add_event(id, event)?; TicketBackendOperationResult::Unit @@ -2218,6 +2249,79 @@ impl TicketBackend for LocalTicketBackend { }) } + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket> { + if edit.title.is_none() && edit.body.is_none() { + return Err(TicketError::Conflict( + "TicketEditItem requires at least one of title or body".to_string(), + )); + } + if let Some(title) = edit.title.as_deref() { + validate_required_event_value("title", title)?; + } + if let Some(author) = edit.author.as_deref() { + validate_required_event_value("author", author)?; + } + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?; + let mut updates = Vec::new(); + if let Some(title) = edit.title.as_deref() { + updates.push(("title", title)); + } + if !updates.is_empty() { + content = replace_frontmatter_fields(&content, &updates).map_err(|message| { + TicketError::Parse { + path: item.clone(), + message, + } + })?; + } + if let Some(body) = edit.body.as_ref() { + content = replace_item_body(&content, body.as_str()).map_err(|message| { + TicketError::Parse { + path: item.clone(), + message, + } + })?; + } + atomic_write(&item, content.as_bytes())?; + + let author = edit.author.unwrap_or_else(default_author); + let mut changes = Vec::new(); + if edit.title.is_some() { + changes.push("title"); + } + if edit.body.is_some() { + changes.push("body"); + } + let body = MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", "))); + self.append_thread_event( + &dir, + "item_edit", + self.generated_heading("Item updated", "項目更新"), + &author, + None, + &[], + &body, + )?; + self.ticket_from_dir(&dir) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> { + let ticket = self.show(id)?; + let summary = ticket_summary_from_meta(ticket.meta.clone()); + let projection = project_ticket_workspace_item(&summary, &ticket.relations.blockers, None); + Ok(TicketDependencyCheck { + ticket: summary, + blockers: ticket.relations.blockers, + queue_guard: projection.queue_guard, + recommended_action: projection + .next_action + .unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator), + }) + } + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> { let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; @@ -3737,6 +3841,32 @@ fn replace_frontmatter_fields( Ok(out) } +fn replace_item_body(content: &str, body: &str) -> std::result::Result<String, String> { + let mut lines = content.lines(); + if lines.next() != Some("---") { + return Err("item.md missing frontmatter opener".to_string()); + } + let mut frontmatter = vec!["---".to_string()]; + let mut found_close = false; + for line in lines.by_ref() { + frontmatter.push(line.to_string()); + if line == "---" { + found_close = true; + break; + } + } + if !found_close { + return Err("item.md missing frontmatter closer".to_string()); + } + let mut out = frontmatter.join("\n"); + out.push_str("\n"); + out.push_str(body); + if !out.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> { let mut out = String::from("<!--"); for (key, value) in attrs { diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 0d4f2a8d..c4d07499 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -18,6 +18,7 @@ use crate::{ TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState, + default_author, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -33,20 +34,27 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; -pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [ +pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [ "TicketCreate", + "TicketEditItem", "TicketList", "TicketShow", "TicketComment", "TicketReview", "TicketIntakeReady", + "TicketQueue", "TicketWorkflowState", "TicketClose", + "TicketDependencyCheck", "TicketDoctor", ]; -pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] = - ["TicketList", "TicketShow", "TicketDoctor"]; +pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [ + "TicketList", + "TicketShow", + "TicketDependencyCheck", + "TicketDoctor", +]; pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ "TicketRelationRecord", @@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 13] = [ +pub const TICKET_TOOL_NAMES: [&str; 16] = [ "TicketCreate", + "TicketEditItem", "TicketList", "TicketShow", "TicketComment", "TicketReview", "TicketIntakeReady", + "TicketQueue", "TicketWorkflowState", "TicketClose", + "TicketDependencyCheck", + "TicketDoctor", "TicketRelationRecord", "TicketRelationQuery", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", - "TicketDoctor", ]; -pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [ +pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketList", "TicketShow", + "TicketDependencyCheck", + "TicketDoctor", "TicketRelationQuery", "TicketOrchestrationPlanQuery", - "TicketDoctor", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [ "TicketCreate", + "TicketEditItem", "TicketComment", "TicketReview", "TicketIntakeReady", + "TicketQueue", "TicketWorkflowState", "TicketClose", "TicketRelationRecord", @@ -96,6 +110,9 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \ backend assigns the id and writes the local Ticket file layout under the configured backend root."; +const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \ +This updates the current item title/body and appends an audited item_edit thread event. Intended for \ +User/Companion authoring surfaces, not Orchestrator implementation control."; const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \ lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \ state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \ @@ -111,6 +128,9 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \ Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \ for `state`, and transitions state to `ready`."; +const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ +Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \ +and rejects unresolved blocking relations."; const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \ as the implementation acceptance step: implementation side effects should happen only after that \ @@ -131,21 +151,26 @@ Ticket id and/or relation kind. This is read-only planning context; Orchestrator explicit state decisions."; const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \ diagnostics through the typed backend without shelling out to external commands."; +const DEPENDENCY_CHECK_DESCRIPTION: &str = "Return a structured Ticket dependency / queue readiness \ +check through the typed Ticket backend. This read-only guard does not queue or transition the Ticket."; fn base_tool_description(name: &str) -> &'static str { match name { "TicketCreate" => CREATE_DESCRIPTION, + "TicketEditItem" => EDIT_ITEM_DESCRIPTION, "TicketList" => LIST_DESCRIPTION, "TicketShow" => SHOW_DESCRIPTION, "TicketComment" => COMMENT_DESCRIPTION, "TicketReview" => REVIEW_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION, + "TicketQueue" => QUEUE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketClose" => CLOSE_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, "TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION, "TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION, + "TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION, "TicketDoctor" => DOCTOR_DESCRIPTION, _ => "Ticket backend tool.", } @@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend { self.backend.create(input) } + fn edit_item(&self, id: TicketIdOrSlug, edit: crate::TicketItemEdit) -> TicketResult<Ticket> { + self.backend.edit_item(id, edit) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketDependencyCheck> { + self.backend.dependency_check(id) + } + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> { self.backend.add_event(id, event) } @@ -351,6 +384,21 @@ struct TicketCreateParams { queued_at: Option<String>, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketEditItemParams { + /// Ticket id. + ticket: String, + /// Optional replacement title. + #[serde(default)] + title: Option<String>, + /// Optional replacement Markdown body. + #[serde(default)] + body: Option<String>, + /// Optional thread author for the audited item_edit event. + #[serde(default)] + author: Option<String>, +} + #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] enum TicketWorkflowStateParam { @@ -534,6 +582,15 @@ struct TicketIntakeReadyParams { state_change_body: Option<String>, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketQueueParams { + /// Ticket id. + ticket: String, + /// Optional queued_by frontmatter value. Defaults to the backend/user default. + #[serde(default)] + queued_by: Option<String>, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketWorkflowStateParams { /// Ticket id. @@ -559,6 +616,12 @@ struct TicketCloseParams { resolution: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketDependencyCheckParams { + /// Ticket id. + ticket: String, +} + #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] enum TicketRelationKindParam { @@ -750,6 +813,11 @@ struct TicketCreateTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketEditItemTool { + backend: TicketToolBackend, +} + #[derive(Clone)] struct TicketListTool { backend: TicketToolBackend, @@ -775,6 +843,11 @@ struct TicketIntakeReadyTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketQueueTool { + backend: TicketToolBackend, +} + #[derive(Clone)] struct TicketWorkflowStateTool { backend: TicketToolBackend, @@ -810,6 +883,11 @@ struct TicketDoctorTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketDependencyCheckTool { + backend: TicketToolBackend, +} + #[async_trait] impl Tool for TicketCreateTool { async fn execute( @@ -844,6 +922,35 @@ impl Tool for TicketCreateTool { } } +#[async_trait] +impl Tool for TicketEditItemTool { + async fn execute( + &self, + input_json: &str, + _ctx: llm_engine::tool::ToolExecutionContext, + ) -> Result<ToolOutput, ToolError> { + let params: TicketEditItemParams = parse_input("TicketEditItem", input_json)?; + let edit = crate::TicketItemEdit { + title: params.title, + body: params.body.map(MarkdownText::new), + author: params.author, + }; + let ticket = self + .backend + .edit_item(TicketIdOrSlug::from(params.ticket), edit) + .map_err(|error| backend_error("TicketEditItem", error))?; + Ok(json_output( + format!("Edited ticket {}", ticket.meta.id), + ticket_json( + &ticket, + DEFAULT_EVENT_LIMIT, + DEFAULT_ARTIFACT_LIMIT, + 16 * 1024, + ), + )) + } +} + #[async_trait] impl Tool for TicketListTool { async fn execute( @@ -1015,6 +1122,25 @@ impl Tool for TicketIntakeReadyTool { } } +#[async_trait] +impl Tool for TicketQueueTool { + async fn execute( + &self, + input_json: &str, + _ctx: llm_engine::tool::ToolExecutionContext, + ) -> Result<ToolOutput, ToolError> { + let params: TicketQueueParams = parse_input("TicketQueue", input_json)?; + let queued_by = params.queued_by.unwrap_or_else(default_author); + self.backend + .queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by) + .map_err(|error| backend_error("TicketQueue", error))?; + Ok(json_output( + format!("Queued ticket {} for Orchestrator", params.ticket), + json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }), + )) + } +} + #[async_trait] impl Tool for TicketWorkflowStateTool { async fn execute( @@ -1244,6 +1370,33 @@ impl Tool for TicketDoctorTool { } } +#[async_trait] +impl Tool for TicketDependencyCheckTool { + async fn execute( + &self, + input_json: &str, + _ctx: llm_engine::tool::ToolExecutionContext, + ) -> Result<ToolOutput, ToolError> { + let params: TicketDependencyCheckParams = parse_input("TicketDependencyCheck", input_json)?; + let check = self + .backend + .dependency_check(TicketIdOrSlug::Query(params.ticket.clone())) + .map_err(|error| backend_error("TicketDependencyCheck", error))?; + Ok(json_output( + format!( + "Ticket {} dependency check: {}", + params.ticket, + if check.queue_guard.can_queue_for_orchestrator { + "queueable" + } else { + "not queueable" + } + ), + check, + )) + } +} + fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> { serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) @@ -1509,15 +1662,20 @@ where fn input_schema(name: &str) -> Value { match name { "TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)), + "TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)), "TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)), "TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), "TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)), "TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), + "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), "TicketWorkflowState" => { serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) } "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), + "TicketDependencyCheck" => { + serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams)) + } "TicketRelationRecord" => { serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams)) } @@ -1547,11 +1705,13 @@ macro_rules! impl_from_backend { } impl_from_backend!(TicketCreateTool); +impl_from_backend!(TicketEditItemTool); impl_from_backend!(TicketListTool); impl_from_backend!(TicketShowTool); impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketReviewTool); impl_from_backend!(TicketIntakeReadyTool); +impl_from_backend!(TicketQueueTool); impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketRelationRecordTool); @@ -1559,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool); impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketDoctorTool); +impl_from_backend!(TicketDependencyCheckTool); /// Build all MVP Ticket tool definitions over the supplied backend. pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> { let backend = backend.into(); vec![ tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()), + tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()), tool_definition::<TicketListTool>("TicketList", backend.clone()), tool_definition::<TicketShowTool>("TicketShow", backend.clone()), tool_definition::<TicketCommentTool>("TicketComment", backend.clone()), tool_definition::<TicketReviewTool>("TicketReview", backend.clone()), tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()), + tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()), tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()), tool_definition::<TicketCloseTool>("TicketClose", backend.clone()), + tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()), + tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()), tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()), tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()), tool_definition::<TicketOrchestrationPlanRecordTool>( @@ -1582,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition "TicketOrchestrationPlanQuery", backend.clone(), ), - tool_definition::<TicketDoctorTool>("TicketDoctor", backend), ] } @@ -1627,18 +1791,21 @@ mod tests { [ "TicketList", "TicketShow", + "TicketDependencyCheck", + "TicketDoctor", "TicketRelationQuery", - "TicketOrchestrationPlanQuery", - "TicketDoctor" + "TicketOrchestrationPlanQuery" ] ); assert_eq!( TICKET_MUTATING_TOOL_NAMES, [ "TicketCreate", + "TicketEditItem", "TicketComment", "TicketReview", "TicketIntakeReady", + "TicketQueue", "TicketWorkflowState", "TicketClose", "TicketRelationRecord", diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index fb8bf7dc..58e25e20 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -548,7 +548,10 @@ fn install_ticket_event_companion_notify_hook<C, St>( let ticket_feature = &worker.manifest().feature.ticket; if !ticket_feature.enabled - || !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle) + || !matches!( + ticket_feature.access, + TicketFeatureAccessConfig::OrchestrationControl | TicketFeatureAccessConfig::Lifecycle + ) { return; } @@ -679,6 +682,21 @@ where TicketFeatureAccessConfig::ReadOnly => { crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly } + TicketFeatureAccessConfig::WorkspaceAuthoring => { + crate::feature::builtin::ticket::TicketFeatureAccess::WorkspaceAuthoring + } + TicketFeatureAccessConfig::Intake => { + crate::feature::builtin::ticket::TicketFeatureAccess::Intake + } + TicketFeatureAccessConfig::OrchestrationControl => { + crate::feature::builtin::ticket::TicketFeatureAccess::OrchestrationControl + } + TicketFeatureAccessConfig::WorkReport => { + crate::feature::builtin::ticket::TicketFeatureAccess::WorkReport + } + TicketFeatureAccessConfig::Review => { + crate::feature::builtin::ticket::TicketFeatureAccess::Review + } TicketFeatureAccessConfig::Lifecycle => { crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 168b640b..65c69abf 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -17,8 +17,7 @@ use ticket::{ tool::{ TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, - TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, - ticket_tools, + TicketToolBackend, ticket_tool_description, ticket_tools, }, }; @@ -34,24 +33,96 @@ The tools operate through the ticket crate backend and do not grant generic file #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TicketFeatureAccess { - /// Status/diagnostic access for views such as Companion that must not mutate Tickets. + /// Status/diagnostic access for views that must not mutate Tickets. ReadOnly, - /// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools. + /// User/Companion authoring access for shaping current Ticket specs and queueing work. + WorkspaceAuthoring, + /// Intake access for creating/refining planning Tickets and marking them ready. + Intake, + /// Orchestrator control access for state/thread/relation/orchestration operations. + OrchestrationControl, + /// Coder-style access for reading Tickets and writing implementation reports/comments. + WorkReport, + /// Reviewer-style access for reading Tickets and writing review/comment thread events. + Review, + /// Legacy full lifecycle access retained as a migration shim. Lifecycle, } +const WORKSPACE_AUTHORING_BASE_TOOL_NAMES: [&str; 10] = [ + "TicketCreate", + "TicketEditItem", + "TicketList", + "TicketShow", + "TicketComment", + "TicketReview", + "TicketQueue", + "TicketClose", + "TicketDependencyCheck", + "TicketDoctor", +]; + +const INTAKE_BASE_TOOL_NAMES: [&str; 8] = [ + "TicketCreate", + "TicketEditItem", + "TicketList", + "TicketShow", + "TicketComment", + "TicketIntakeReady", + "TicketDependencyCheck", + "TicketDoctor", +]; + +const ORCHESTRATION_CONTROL_BASE_TOOL_NAMES: [&str; 8] = [ + "TicketList", + "TicketShow", + "TicketComment", + "TicketReview", + "TicketWorkflowState", + "TicketClose", + "TicketDependencyCheck", + "TicketDoctor", +]; + +const WORK_REPORT_BASE_TOOL_NAMES: [&str; 5] = [ + "TicketList", + "TicketShow", + "TicketComment", + "TicketDependencyCheck", + "TicketDoctor", +]; + +const REVIEW_BASE_TOOL_NAMES: [&str; 6] = [ + "TicketList", + "TicketShow", + "TicketComment", + "TicketReview", + "TicketDependencyCheck", + "TicketDoctor", +]; + +const RELATION_WRITE_TOOL_NAMES: [&str; 2] = ["TicketRelationRecord", "TicketRelationQuery"]; + impl TicketFeatureAccess { pub fn base_tool_names(self) -> &'static [&'static str] { match self { Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES, + Self::WorkspaceAuthoring => &WORKSPACE_AUTHORING_BASE_TOOL_NAMES, + Self::Intake => &INTAKE_BASE_TOOL_NAMES, + Self::OrchestrationControl => &ORCHESTRATION_CONTROL_BASE_TOOL_NAMES, + Self::WorkReport => &WORK_REPORT_BASE_TOOL_NAMES, + Self::Review => &REVIEW_BASE_TOOL_NAMES, Self::Lifecycle => &TICKET_BASE_TOOL_NAMES, } } pub fn orchestration_tool_names(self) -> &'static [&'static str] { match self { - Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, - Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES, + Self::ReadOnly | Self::WorkReport | Self::Review => { + &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES + } + Self::WorkspaceAuthoring | Self::Intake => &RELATION_WRITE_TOOL_NAMES, + Self::OrchestrationControl | Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES, } } } @@ -200,12 +271,6 @@ impl TicketFeature { } fn enabled_tool_names(&self) -> Vec<&'static str> { - if self.include_base_tools && self.include_orchestration_tools { - return match self.access { - TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(), - TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(), - }; - } let mut names = Vec::new(); if self.include_base_tools { names.extend_from_slice(self.access.base_tool_names()); @@ -421,6 +486,20 @@ impl TicketBackend for WorkspaceHttpTicketBackend { ) } + fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> TicketResult<Ticket> { + expect_ticket_result!( + self.invoke(TicketBackendOperation::EditItem { id, edit }), + TicketBackendOperationResult::Ticket + ) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketDependencyCheck> { + expect_ticket_result!( + self.invoke(TicketBackendOperation::DependencyCheck { id }), + TicketBackendOperationResult::DependencyCheck + ) + } + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> { match self.invoke(TicketBackendOperation::AddEvent { id, event })? { TicketBackendOperationResult::Unit => Ok(()), @@ -715,6 +794,74 @@ mod tests { ); } + #[test] + fn semantic_access_presets_expose_role_capability_surfaces() { + let temp = TempDir::new().unwrap(); + + let workspace_authoring = ticket_tools_feature_with_options( + temp.path(), + Some(TicketFeatureAccess::WorkspaceAuthoring), + false, + ); + let workspace_descriptor = workspace_authoring.descriptor(); + let workspace_tools = workspace_descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>(); + assert!(workspace_tools.contains(&"TicketCreate")); + assert!(workspace_tools.contains(&"TicketEditItem")); + assert!(workspace_tools.contains(&"TicketQueue")); + assert!(!workspace_tools.contains(&"TicketWorkflowState")); + + let orchestration = ticket_tools_feature_with_options( + temp.path(), + Some(TicketFeatureAccess::OrchestrationControl), + true, + ); + let orchestration_descriptor = orchestration.descriptor(); + let orchestration_tools = orchestration_descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>(); + assert!(orchestration_tools.contains(&"TicketWorkflowState")); + assert!(orchestration_tools.contains(&"TicketDependencyCheck")); + assert!(orchestration_tools.contains(&"TicketRelationRecord")); + assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); + assert!(!orchestration_tools.contains(&"TicketEditItem")); + assert!(!orchestration_tools.contains(&"TicketQueue")); + + let work_report = ticket_tools_feature_with_options( + temp.path(), + Some(TicketFeatureAccess::WorkReport), + false, + ); + let work_report_descriptor = work_report.descriptor(); + let work_report_tools = work_report_descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>(); + assert!(work_report_tools.contains(&"TicketComment")); + assert!(!work_report_tools.contains(&"TicketReview")); + assert!(!work_report_tools.contains(&"TicketWorkflowState")); + + let review = ticket_tools_feature_with_options( + temp.path(), + Some(TicketFeatureAccess::Review), + false, + ); + let review_descriptor = review.descriptor(); + let review_tools = review_descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>(); + assert!(review_tools.contains(&"TicketReview")); + assert!(!review_tools.contains(&"TicketWorkflowState")); + } + #[test] fn read_only_installation_does_not_expose_mutating_tools() { let temp = TempDir::new().unwrap(); From 064965d35000708268b6b35bda81db50b94b0929 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 20 Jul 2026 19:51:46 +0900 Subject: [PATCH 5/6] ticket: collapse ticket access into one feature --- crates/manifest/src/config.rs | 39 +-- crates/manifest/src/lib.rs | 14 +- crates/manifest/src/profile.rs | 41 +-- crates/tui/src/setup_model.rs | 10 +- crates/worker/src/controller.rs | 17 +- crates/worker/src/feature/builtin.rs | 2 +- crates/worker/src/feature/builtin/ticket.rs | 291 ++++++++------------ resources/profiles/coder.dcdl | 2 +- resources/profiles/companion.dcdl | 2 +- resources/profiles/default.dcdl | 3 +- resources/profiles/intake.dcdl | 2 +- resources/profiles/orchestrator.dcdl | 2 +- resources/profiles/reviewer.dcdl | 2 +- 13 files changed, 147 insertions(+), 280 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index c3003053..6ff27827 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -87,8 +87,6 @@ pub struct FeatureConfigPartial { #[serde(default)] pub ticket: Option<TicketFeatureConfigPartial>, #[serde(default)] - pub ticket_orchestration: Option<FeatureFlagConfigPartial>, - #[serde(default)] pub plugins: Option<FeatureFlagConfigPartial>, } @@ -100,11 +98,6 @@ impl FeatureConfigPartial { web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), - ticket_orchestration: merge_option( - self.ticket_orchestration, - other.ticket_orchestration, - FeatureFlagConfigPartial::merge, - ), plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge), } } @@ -128,10 +121,6 @@ impl FeatureFlagConfigPartial { pub struct TicketFeatureConfigPartial { #[serde(default)] pub enabled: Option<bool>, - /// Legacy access field. Prefer `preset` for new profile/DCDL authoring. - #[serde(default)] - pub access: Option<TicketFeatureAccessConfig>, - /// Semantic Ticket access preset for profile/DCDL authoring. #[serde(default)] pub preset: Option<TicketFeatureAccessConfig>, } @@ -140,7 +129,6 @@ impl TicketFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { enabled: other.enabled.or(self.enabled), - access: other.access.or(self.access), preset: other.preset.or(self.preset), } } @@ -163,10 +151,6 @@ impl From<FeatureConfigPartial> for FeatureConfig { .ticket .map(TicketFeatureConfig::from) .unwrap_or_default(), - ticket_orchestration: value - .ticket_orchestration - .map(FeatureFlagConfig::from) - .unwrap_or_default(), plugins: value .plugins .map(FeatureFlagConfig::from) @@ -195,7 +179,7 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), - access: value.preset.or(value.access).unwrap_or_default(), + preset: value.preset.unwrap_or_default(), } } } @@ -204,8 +188,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial { fn from(value: TicketFeatureConfig) -> Self { Self { enabled: Some(value.enabled), - access: Some(value.access), - preset: None, + preset: Some(value.preset), } } } @@ -218,7 +201,6 @@ impl From<FeatureConfig> for FeatureConfigPartial { web: Some(value.web.into()), workers: Some(value.workers.into()), ticket: Some(value.ticket.into()), - ticket_orchestration: Some(value.ticket_orchestration.into()), plugins: Some(value.plugins.into()), } } @@ -1764,7 +1746,6 @@ worker_max_turns = 7 assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.workers.enabled); assert!(!manifest.feature.ticket.enabled); - assert!(!manifest.feature.ticket_orchestration.enabled); } #[test] @@ -1776,10 +1757,7 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" - -[feature.ticket_orchestration] -enabled = true +preset = "read_only" "#, ) .unwrap(); @@ -1810,10 +1788,9 @@ enabled = true assert!(manifest.feature.task.enabled); assert!(manifest.feature.ticket.enabled); assert_eq!( - manifest.feature.ticket.access, + manifest.feature.ticket.preset, TicketFeatureAccessConfig::ReadOnly ); - assert!(manifest.feature.ticket_orchestration.enabled); assert!(!manifest.feature.memory.enabled); } @@ -1826,14 +1803,14 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" +preset = "read_only" "#, ) .unwrap(); let upper = WorkerManifestConfig::from_toml( r#" [feature.ticket] -access = "lifecycle" +preset = "orchestration_control" [feature.web] enabled = true @@ -1868,8 +1845,8 @@ enabled = true assert!(manifest.feature.memory.enabled); assert!(manifest.feature.ticket.enabled); assert_eq!( - manifest.feature.ticket.access, - TicketFeatureAccessConfig::Lifecycle + manifest.feature.ticket.preset, + TicketFeatureAccessConfig::OrchestrationControl ); assert!(manifest.feature.web.enabled); assert!(!manifest.feature.workers.enabled); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 7891cd49..41f17280 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -115,8 +115,6 @@ pub struct FeatureConfig { #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] - pub ticket_orchestration: FeatureFlagConfig, - #[serde(default)] pub plugins: FeatureFlagConfig, } @@ -128,7 +126,6 @@ impl Default for FeatureConfig { web: FeatureFlagConfig::disabled(), workers: FeatureFlagConfig::disabled(), ticket: TicketFeatureConfig::default(), - ticket_orchestration: FeatureFlagConfig::disabled(), plugins: FeatureFlagConfig::disabled(), } } @@ -160,18 +157,15 @@ impl Default for FeatureFlagConfig { pub struct TicketFeatureConfig { #[serde(default)] pub enabled: bool, - /// Which non-orchestration Ticket surface to expose when `enabled = true`. - /// Orchestration-plan/relation tools are controlled independently by - /// `[feature.ticket_orchestration].enabled`. #[serde(default)] - pub access: TicketFeatureAccessConfig, + pub preset: TicketFeatureAccessConfig, } impl Default for TicketFeatureConfig { fn default() -> Self { Self { enabled: false, - access: TicketFeatureAccessConfig::Lifecycle, + preset: TicketFeatureAccessConfig::default(), } } } @@ -185,13 +179,11 @@ pub enum TicketFeatureAccessConfig { OrchestrationControl, WorkReport, Review, - /// Legacy broad mutation preset retained as a migration shim. - Lifecycle, } impl Default for TicketFeatureAccessConfig { fn default() -> Self { - Self::Lifecycle + Self::WorkspaceAuthoring } } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index acd8026e..63a4d59a 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -894,7 +894,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { true, true, true, - false, ); Some(value) } @@ -908,7 +907,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { true, true, false, - false, ); Some(value) } @@ -922,7 +920,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { true, true, true, - true, ); Some(value) } @@ -936,7 +933,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { true, true, false, - false, ); Some(value) } @@ -950,7 +946,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { true, true, false, - false, ); Some(value) } @@ -976,8 +971,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value { "memory": { "enabled": true }, "web": { "enabled": true }, "workers": { "enabled": true }, - "ticket": { "enabled": true, "access": "lifecycle" }, - "ticket_orchestration": { "enabled": false } + "ticket": { "enabled": true, "preset": "workspace_authoring" } }, "memory": { "extract_threshold": 50000, @@ -1004,7 +998,6 @@ fn apply_role_profile( memory: bool, web: bool, workers: bool, - ticket_orchestration: bool, ) { value["slug"] = serde_json::Value::String(slug.to_string()); value["description"] = serde_json::Value::String(description.to_string()); @@ -1018,14 +1011,12 @@ fn apply_role_profile( "orchestrator" => TicketFeatureAccessConfig::OrchestrationControl, "coder" => TicketFeatureAccessConfig::WorkReport, "reviewer" => TicketFeatureAccessConfig::Review, - _ => TicketFeatureAccessConfig::Lifecycle, + _ => TicketFeatureAccessConfig::WorkspaceAuthoring, }; value["feature"]["ticket"] = serde_json::json!({ "enabled": true, "preset": ticket_access, }); - value["feature"]["ticket_orchestration"] = - serde_json::json!({ "enabled": ticket_orchestration }); } fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { @@ -1452,10 +1443,9 @@ mod tests { assert!(companion.web.is_some()); assert!(companion.feature.ticket.enabled); assert_eq!( - companion.feature.ticket.access, + companion.feature.ticket.preset, TicketFeatureAccessConfig::WorkspaceAuthoring ); - assert!(!companion.feature.ticket_orchestration.enabled); assert_eq!( companion.compaction.as_ref().unwrap().threshold, Some(240000) @@ -1478,7 +1468,7 @@ mod tests { assert!(!intake.feature.workers.enabled); assert!(intake.feature.ticket.enabled); assert_eq!( - intake.feature.ticket.access, + intake.feature.ticket.preset, TicketFeatureAccessConfig::Intake ); assert!(intake.scope.allow.is_empty()); @@ -1486,17 +1476,15 @@ mod tests { assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(intake.web.is_some()); assert!(intake.compaction.is_some()); - assert!(!intake.feature.ticket_orchestration.enabled); let orchestrator = resolve("orchestrator"); assert!(orchestrator.feature.task.enabled); assert!(orchestrator.feature.workers.enabled); assert!(orchestrator.feature.ticket.enabled); assert_eq!( - orchestrator.feature.ticket.access, + orchestrator.feature.ticket.preset, TicketFeatureAccessConfig::OrchestrationControl ); - assert!(orchestrator.feature.ticket_orchestration.enabled); assert!(orchestrator.scope.allow.is_empty()); assert!(orchestrator.delegation_scope.allow.is_empty()); assert_eq!( @@ -1516,20 +1504,18 @@ mod tests { assert!(coder.compaction.is_some()); assert!(coder.feature.ticket.enabled); assert_eq!( - coder.feature.ticket.access, + coder.feature.ticket.preset, TicketFeatureAccessConfig::WorkReport ); - assert!(!coder.feature.ticket_orchestration.enabled); let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.workers.enabled); assert!(reviewer.feature.ticket.enabled); assert_eq!( - reviewer.feature.ticket.access, + reviewer.feature.ticket.preset, TicketFeatureAccessConfig::Review ); - assert!(!reviewer.feature.ticket_orchestration.enabled); assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty()); assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1678,10 +1664,7 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" - -[feature.ticket_orchestration] -enabled = false +preset = "read_only" "#, ); let workspace = tmp.path().join("workspace"); @@ -1700,10 +1683,9 @@ enabled = false assert!(resolved.manifest.feature.workers.enabled); assert!(resolved.manifest.feature.ticket.enabled); assert_eq!( - resolved.manifest.feature.ticket.access, + resolved.manifest.feature.ticket.preset, crate::TicketFeatureAccessConfig::ReadOnly ); - assert!(!resolved.manifest.feature.ticket_orchestration.enabled); assert_eq!( resolved.manifest.delegation_scope.allow[0].target, workspace @@ -1791,10 +1773,9 @@ worker_context_max_tokens = 68000 assert!(resolved.manifest.session.record_event_trace); assert!(resolved.manifest.feature.ticket.enabled); assert_eq!( - resolved.manifest.feature.ticket.access, - crate::TicketFeatureAccessConfig::Lifecycle + resolved.manifest.feature.ticket.preset, + crate::TicketFeatureAccessConfig::WorkspaceAuthoring ); - assert!(!resolved.manifest.feature.ticket_orchestration.enabled); assert_eq!( resolved.profile.as_ref().unwrap().name.as_deref(), Some("default") diff --git a/crates/tui/src/setup_model.rs b/crates/tui/src/setup_model.rs index f7d2670e..0c4d6230 100644 --- a/crates/tui/src/setup_model.rs +++ b/crates/tui/src/setup_model.rs @@ -238,10 +238,7 @@ enabled = false [feature.ticket] enabled = true -access = "lifecycle" - -[feature.ticket_orchestration] -enabled = false +preset = "workspace_authoring" [memory] extract_threshold = 50000 @@ -298,8 +295,9 @@ mod tests { assert!(profile.contains("slug = \"default\"")); assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\"")); assert!(profile.contains("scope = \"workspace_write\"")); - assert!(profile.contains("[feature.ticket]\nenabled = true\naccess = \"lifecycle\"")); - assert!(profile.contains("[feature.ticket_orchestration]\nenabled = false")); + assert!( + profile.contains("[feature.ticket]\nenabled = true\npreset = \"workspace_authoring\"") + ); } #[test] diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 58e25e20..cc131c6c 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -548,10 +548,7 @@ fn install_ticket_event_companion_notify_hook<C, St>( let ticket_feature = &worker.manifest().feature.ticket; if !ticket_feature.enabled - || !matches!( - ticket_feature.access, - TicketFeatureAccessConfig::OrchestrationControl | TicketFeatureAccessConfig::Lifecycle - ) + || ticket_feature.preset != TicketFeatureAccessConfig::OrchestrationControl { return; } @@ -677,8 +674,8 @@ where if feature_config.task.enabled { feature_registry.add_module(task_feature); } - if feature_config.ticket.enabled || feature_config.ticket_orchestration.enabled { - let ticket_access = match feature_config.ticket.access { + if feature_config.ticket.enabled { + let ticket_access = match feature_config.ticket.preset { TicketFeatureAccessConfig::ReadOnly => { crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly } @@ -697,9 +694,6 @@ where TicketFeatureAccessConfig::Review => { crate::feature::builtin::ticket::TicketFeatureAccess::Review } - TicketFeatureAccessConfig::Lifecycle => { - crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle - } }; // Ticket tools are typed operations over the current workspace Ticket backend. // Runtime-hosted Workers prefer the workspace API URI carried by the @@ -728,10 +722,9 @@ where } }; feature_registry.add_module( - crate::feature::builtin::ticket::ticket_tools_feature_with_options( + crate::feature::builtin::ticket::ticket_tools_feature_with_backend( ticket_backend, - feature_config.ticket.enabled.then_some(ticket_access), - feature_config.ticket_orchestration.enabled, + ticket_access, ), ); } diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 82c35226..bef8b017 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -14,5 +14,5 @@ pub(crate) use session_explore::{ pub use task::{TaskFeature, task_tools_feature}; pub use ticket::{ TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, - ticket_tools_feature_with_options, + ticket_tools_feature_with_backend, }; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 65c69abf..2d046a05 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -14,11 +14,7 @@ use ticket::{ TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, TicketStateChange, TicketSummary, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, - tool::{ - TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, - TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, - TicketToolBackend, ticket_tool_description, ticket_tools, - }, + tool::{TicketToolBackend, ticket_tool_description, ticket_tools}, }; use crate::feature::{ @@ -45,11 +41,18 @@ pub enum TicketFeatureAccess { WorkReport, /// Reviewer-style access for reading Tickets and writing review/comment thread events. Review, - /// Legacy full lifecycle access retained as a migration shim. - Lifecycle, } -const WORKSPACE_AUTHORING_BASE_TOOL_NAMES: [&str; 10] = [ +const READ_ONLY_TOOL_NAMES: [&str; 6] = [ + "TicketList", + "TicketShow", + "TicketDependencyCheck", + "TicketDoctor", + "TicketRelationQuery", + "TicketOrchestrationPlanQuery", +]; + +const WORKSPACE_AUTHORING_TOOL_NAMES: [&str; 12] = [ "TicketCreate", "TicketEditItem", "TicketList", @@ -60,9 +63,11 @@ const WORKSPACE_AUTHORING_BASE_TOOL_NAMES: [&str; 10] = [ "TicketClose", "TicketDependencyCheck", "TicketDoctor", + "TicketRelationRecord", + "TicketRelationQuery", ]; -const INTAKE_BASE_TOOL_NAMES: [&str; 8] = [ +const INTAKE_TOOL_NAMES: [&str; 10] = [ "TicketCreate", "TicketEditItem", "TicketList", @@ -71,9 +76,11 @@ const INTAKE_BASE_TOOL_NAMES: [&str; 8] = [ "TicketIntakeReady", "TicketDependencyCheck", "TicketDoctor", + "TicketRelationRecord", + "TicketRelationQuery", ]; -const ORCHESTRATION_CONTROL_BASE_TOOL_NAMES: [&str; 8] = [ +const ORCHESTRATION_CONTROL_TOOL_NAMES: [&str; 12] = [ "TicketList", "TicketShow", "TicketComment", @@ -82,47 +89,42 @@ const ORCHESTRATION_CONTROL_BASE_TOOL_NAMES: [&str; 8] = [ "TicketClose", "TicketDependencyCheck", "TicketDoctor", + "TicketRelationRecord", + "TicketRelationQuery", + "TicketOrchestrationPlanRecord", + "TicketOrchestrationPlanQuery", ]; -const WORK_REPORT_BASE_TOOL_NAMES: [&str; 5] = [ +const WORK_REPORT_TOOL_NAMES: [&str; 7] = [ "TicketList", "TicketShow", "TicketComment", "TicketDependencyCheck", "TicketDoctor", + "TicketRelationQuery", + "TicketOrchestrationPlanQuery", ]; -const REVIEW_BASE_TOOL_NAMES: [&str; 6] = [ +const REVIEW_TOOL_NAMES: [&str; 8] = [ "TicketList", "TicketShow", "TicketComment", "TicketReview", "TicketDependencyCheck", "TicketDoctor", + "TicketRelationQuery", + "TicketOrchestrationPlanQuery", ]; -const RELATION_WRITE_TOOL_NAMES: [&str; 2] = ["TicketRelationRecord", "TicketRelationQuery"]; - impl TicketFeatureAccess { - pub fn base_tool_names(self) -> &'static [&'static str] { + pub fn tool_names(self) -> &'static [&'static str] { match self { - Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES, - Self::WorkspaceAuthoring => &WORKSPACE_AUTHORING_BASE_TOOL_NAMES, - Self::Intake => &INTAKE_BASE_TOOL_NAMES, - Self::OrchestrationControl => &ORCHESTRATION_CONTROL_BASE_TOOL_NAMES, - Self::WorkReport => &WORK_REPORT_BASE_TOOL_NAMES, - Self::Review => &REVIEW_BASE_TOOL_NAMES, - Self::Lifecycle => &TICKET_BASE_TOOL_NAMES, - } - } - - pub fn orchestration_tool_names(self) -> &'static [&'static str] { - match self { - Self::ReadOnly | Self::WorkReport | Self::Review => { - &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES - } - Self::WorkspaceAuthoring | Self::Intake => &RELATION_WRITE_TOOL_NAMES, - Self::OrchestrationControl | Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES, + Self::ReadOnly => &READ_ONLY_TOOL_NAMES, + Self::WorkspaceAuthoring => &WORKSPACE_AUTHORING_TOOL_NAMES, + Self::Intake => &INTAKE_TOOL_NAMES, + Self::OrchestrationControl => &ORCHESTRATION_CONTROL_TOOL_NAMES, + Self::WorkReport => &WORK_REPORT_TOOL_NAMES, + Self::Review => &REVIEW_TOOL_NAMES, } } } @@ -167,94 +169,59 @@ pub struct TicketFeature { record_language: Option<String>, config_error: Option<String>, access: TicketFeatureAccess, - include_base_tools: bool, - include_orchestration_tools: bool, } impl TicketFeature { pub fn new(backend_root: impl Into<PathBuf>) -> Self { - Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle) + Self::new_with_access(backend_root, TicketFeatureAccess::WorkspaceAuthoring) } pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self { - Self::new_with_options(backend_root, Some(access), true) - } - - pub fn new_with_options( - backend_root: impl Into<PathBuf>, - access: Option<TicketFeatureAccess>, - include_orchestration_tools: bool, - ) -> Self { Self::with_backend( TicketFeatureBackend::Local { root: backend_root.into(), }, access, - include_orchestration_tools, ) } - pub fn with_backend( - backend: TicketFeatureBackend, - access: Option<TicketFeatureAccess>, - include_orchestration_tools: bool, - ) -> Self { + pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self { if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend { - return Self::for_workspace_with_options( - workspace_root, - access, - include_orchestration_tools, - ); + return Self::for_workspace_with_access(workspace_root, access); } Self { backend, record_language: None, config_error: None, - access: access.unwrap_or(TicketFeatureAccess::Lifecycle), - include_base_tools: access.is_some(), - include_orchestration_tools, + access, } } pub fn for_workspace(workspace: impl AsRef<Path>) -> Self { - Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle) + Self::for_workspace_with_access(workspace, TicketFeatureAccess::WorkspaceAuthoring) } pub fn for_workspace_with_access( workspace: impl AsRef<Path>, access: TicketFeatureAccess, - ) -> Self { - Self::for_workspace_with_options(workspace, Some(access), true) - } - - pub fn for_workspace_with_options( - workspace: impl AsRef<Path>, - access: Option<TicketFeatureAccess>, - include_orchestration_tools: bool, ) -> Self { let workspace = workspace.as_ref(); match TicketConfig::load_workspace(workspace) { Ok(config) => { let backend_root = config.backend_root().to_path_buf(); let record_language = config.ticket_record_language().map(str::to_string); - let mut feature = - Self::new_with_options(backend_root, access, include_orchestration_tools); + let mut feature = Self::new_with_access(backend_root, access); feature.record_language = record_language; feature } - Err(error) => { - let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle); - Self { - backend: TicketFeatureBackend::Local { - root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), - }, - record_language: None, - config_error: Some(error.to_string()), - access: access_value, - include_base_tools: access.is_some(), - include_orchestration_tools, - } - } + Err(error) => Self { + backend: TicketFeatureBackend::Local { + root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + }, + record_language: None, + config_error: Some(error.to_string()), + access, + }, } } @@ -270,15 +237,8 @@ impl TicketFeature { self.access } - fn enabled_tool_names(&self) -> Vec<&'static str> { - let mut names = Vec::new(); - if self.include_base_tools { - names.extend_from_slice(self.access.base_tool_names()); - } - if self.include_orchestration_tools { - names.extend_from_slice(self.access.orchestration_tool_names()); - } - names + fn enabled_tool_names(&self) -> &'static [&'static str] { + self.access.tool_names() } fn usable_backend_root(&self) -> Result<PathBuf, String> { @@ -336,7 +296,7 @@ impl FeatureModule for TicketFeature { let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) .with_description(FEATURE_DESCRIPTION); let enabled_tool_names = self.enabled_tool_names(); - for name in &enabled_tool_names { + for name in enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new( *name, ticket_tool_description(name, self.record_language.as_deref()), @@ -680,12 +640,11 @@ pub fn ticket_tools_feature_with_access( TicketFeature::for_workspace_with_access(workspace, access) } -pub fn ticket_tools_feature_with_options( +pub fn ticket_tools_feature_with_backend( backend: impl Into<TicketFeatureBackend>, - access: Option<TicketFeatureAccess>, - include_orchestration_tools: bool, + access: TicketFeatureAccess, ) -> TicketFeature { - TicketFeature::with_backend(backend.into(), access, include_orchestration_tools) + TicketFeature::with_backend(backend.into(), access) } #[cfg(test)] @@ -697,10 +656,6 @@ mod tests { use std::net::TcpListener; use std::thread; use tempfile::TempDir; - use ticket::tool::{ - TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES, - TICKET_TOOL_NAMES, - }; fn make_ticket_root(root: &Path) { std::fs::create_dir_all(root).unwrap(); @@ -732,14 +687,14 @@ mod tests { let descriptor = feature.descriptor(); assert_eq!(descriptor.id.to_string(), "builtin:ticket"); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); - assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len()); + assert_eq!(descriptor.tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::<Vec<_>>(), - TICKET_TOOL_NAMES + WORKSPACE_AUTHORING_TOOL_NAMES ); } @@ -749,48 +704,33 @@ mod tests { let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly); let descriptor = feature.descriptor(); assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly); - assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); + assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::<Vec<_>>(), - TICKET_READ_ONLY_TOOL_NAMES + READ_ONLY_TOOL_NAMES ); } #[test] - fn descriptor_can_expose_base_ticket_without_orchestration_tools() { + fn orchestration_control_descriptor_declares_orchestration_tools() { let temp = TempDir::new().unwrap(); - let feature = ticket_tools_feature_with_options( + let feature = ticket_tools_feature_with_access( temp.path(), - Some(TicketFeatureAccess::Lifecycle), - false, + TicketFeatureAccess::OrchestrationControl, ); let descriptor = feature.descriptor(); + assert_eq!(feature.access(), TicketFeatureAccess::OrchestrationControl); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::<Vec<_>>(), - TICKET_BASE_TOOL_NAMES - ); - } - - #[test] - fn descriptor_can_expose_orchestration_only_tools() { - let temp = TempDir::new().unwrap(); - let feature = ticket_tools_feature_with_options(temp.path(), None, true); - let descriptor = feature.descriptor(); - assert_eq!( - descriptor - .tools - .iter() - .map(|tool| tool.name.as_str()) - .collect::<Vec<_>>(), - TICKET_ORCHESTRATION_TOOL_NAMES + ORCHESTRATION_CONTROL_TOOL_NAMES ); } @@ -798,11 +738,8 @@ mod tests { fn semantic_access_presets_expose_role_capability_surfaces() { let temp = TempDir::new().unwrap(); - let workspace_authoring = ticket_tools_feature_with_options( - temp.path(), - Some(TicketFeatureAccess::WorkspaceAuthoring), - false, - ); + let workspace_authoring = + ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::WorkspaceAuthoring); let workspace_descriptor = workspace_authoring.descriptor(); let workspace_tools = workspace_descriptor .tools @@ -814,10 +751,9 @@ mod tests { assert!(workspace_tools.contains(&"TicketQueue")); assert!(!workspace_tools.contains(&"TicketWorkflowState")); - let orchestration = ticket_tools_feature_with_options( + let orchestration = ticket_tools_feature_with_access( temp.path(), - Some(TicketFeatureAccess::OrchestrationControl), - true, + TicketFeatureAccess::OrchestrationControl, ); let orchestration_descriptor = orchestration.descriptor(); let orchestration_tools = orchestration_descriptor @@ -832,11 +768,8 @@ mod tests { assert!(!orchestration_tools.contains(&"TicketEditItem")); assert!(!orchestration_tools.contains(&"TicketQueue")); - let work_report = ticket_tools_feature_with_options( - temp.path(), - Some(TicketFeatureAccess::WorkReport), - false, - ); + let work_report = + ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::WorkReport); let work_report_descriptor = work_report.descriptor(); let work_report_tools = work_report_descriptor .tools @@ -847,11 +780,7 @@ mod tests { assert!(!work_report_tools.contains(&"TicketReview")); assert!(!work_report_tools.contains(&"TicketWorkflowState")); - let review = ticket_tools_feature_with_options( - temp.path(), - Some(TicketFeatureAccess::Review), - false, - ); + let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::Review); let review_descriptor = review.descriptor(); let review_tools = review_descriptor .tools @@ -875,16 +804,13 @@ mod tests { )) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); - assert_eq!( - report.reports[0].installed_tools, - TICKET_READ_ONLY_TOOL_NAMES - ); + assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len()); + assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES); let pending_names = pending_tools .iter() .map(|definition| definition().0.name) .collect::<Vec<_>>(); - assert_eq!(pending_names, TICKET_READ_ONLY_TOOL_NAMES); + assert_eq!(pending_names, READ_ONLY_TOOL_NAMES); for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES { assert!( !report.reports[0] @@ -924,11 +850,8 @@ language = "Japanese" .with_module(feature) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); - assert_eq!( - report.reports[0].installed_tools, - TICKET_READ_ONLY_TOOL_NAMES - ); + assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len()); + assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES); let description = pending_tool_description(&pending_tools, "TicketShow"); assert!(description.contains("Ticket record language: Japanese")); assert!(description.contains("distinct from worker.language")); @@ -936,7 +859,7 @@ language = "Japanese" } #[test] - fn lifecycle_installation_exposes_lifecycle_tools() { + fn workspace_authoring_installation_exposes_authoring_tools() { let temp = TempDir::new().unwrap(); make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); let mut pending_tools = Vec::new(); @@ -944,36 +867,31 @@ language = "Japanese" let report = FeatureRegistryBuilder::new() .with_module(ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::Lifecycle, + TicketFeatureAccess::WorkspaceAuthoring, )) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); - assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); - for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES { - assert!( - report.reports[0] - .installed_tools - .iter() - .any(|tool| tool == name) - ); - } + assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); + let installed = report.reports[0] + .installed_tools + .iter() + .map(String::as_str) + .collect::<Vec<_>>(); + assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES); + assert!(installed.iter().any(|tool| *tool == "TicketCreate")); + assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); + assert!(installed.iter().any(|tool| *tool == "TicketQueue")); + assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); + assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState")); assert!( - report.reports[0] - .installed_tools + !installed .iter() - .any(|tool| tool == "TicketIntakeReady") - ); - assert!( - report.reports[0] - .installed_tools - .iter() - .any(|tool| tool == "TicketWorkflowState") + .any(|tool| *tool == "TicketOrchestrationPlanRecord") ); } #[test] - fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() { + fn workspace_authoring_context_exposes_ticket_language_guidance() { let temp = TempDir::new().unwrap(); write_ticket_config( temp.path(), @@ -988,12 +906,15 @@ language = "Japanese" let report = FeatureRegistryBuilder::new() .with_module(ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::Lifecycle, + TicketFeatureAccess::WorkspaceAuthoring, )) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); - assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); + assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); + assert_eq!( + report.reports[0].installed_tools, + WORKSPACE_AUTHORING_TOOL_NAMES + ); let description = pending_tool_description(&pending_tools, "TicketComment"); assert!(description.contains("Ticket record language: Japanese")); assert!(description.contains("durable Ticket record and Ticket tool body text")); @@ -1011,10 +932,13 @@ language = "Japanese" .with_module(ticket_tools_feature(temp.path())) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); + assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); assert_eq!(report.reports.len(), 1); assert!(report.reports[0].installed); - assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); + assert_eq!( + report.reports[0].installed_tools, + WORKSPACE_AUTHORING_TOOL_NAMES + ); assert!(report.reports[0].skipped.is_empty()); } @@ -1046,7 +970,7 @@ profile = "project:coder" .with_module(feature) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); + assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); assert!(report.reports[0].diagnostics.is_empty()); } @@ -1132,8 +1056,11 @@ provider = "github" .with_module(ticket_tools_feature(temp.path())) .install_into_pending(&mut pending_tools, &mut hooks); - assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); - assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); + assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len()); + assert_eq!( + report.reports[0].installed_tools, + WORKSPACE_AUTHORING_TOOL_NAMES + ); assert!(report.reports[0].diagnostics.is_empty()); assert!(!root.join("open").exists()); assert!(!root.join("pending").exists()); diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index ea53295d..7a6a4e75 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket_orchestration = { enabled = false; }; + ticket = { enabled = true; preset = "work_report"; }; }; } diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index 2c1c3851..cb78855e 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket_orchestration = { enabled = false; }; + ticket = { enabled = true; preset = "workspace_authoring"; }; }; } diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 81d06f66..9b261b41 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -26,8 +26,7 @@ feature = { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = true; access = "lifecycle"; }; - ticket_orchestration = { enabled = false; }; + ticket = { enabled = true; preset = "workspace_authoring"; }; }; memory = { diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index 91e98b5e..dc0be16b 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket_orchestration = { enabled = false; }; + ticket = { enabled = true; preset = "intake"; }; }; } diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index a502b999..951bdef3 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket_orchestration = { enabled = true; }; + ticket = { enabled = true; preset = "orchestration_control"; }; }; } diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index 9f714f86..41c32401 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket_orchestration = { enabled = false; }; + ticket = { enabled = true; preset = "review"; }; }; } From 7f7d2fe7fc0987b58a384b2a0b87c342e232396b Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Tue, 21 Jul 2026 06:13:26 +0900 Subject: [PATCH 6/6] ticket: use boolean ticket capabilities --- crates/manifest/src/config.rs | 58 ++++-- crates/manifest/src/lib.rs | 36 +--- crates/manifest/src/profile.rs | 98 ++++----- crates/tui/src/setup_model.rs | 5 +- crates/worker/src/controller.rs | 29 +-- crates/worker/src/feature/builtin/ticket.rs | 209 ++++++++++++-------- resources/profiles/coder.dcdl | 2 +- resources/profiles/companion.dcdl | 2 +- resources/profiles/default.dcdl | 2 +- resources/profiles/intake.dcdl | 2 +- resources/profiles/orchestrator.dcdl | 2 +- resources/profiles/reviewer.dcdl | 2 +- 12 files changed, 239 insertions(+), 208 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 6ff27827..73534508 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -19,8 +19,8 @@ use crate::plugin::PluginConfig; use crate::{ CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig, - SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits, - ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta, + SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, + WebConfig, WorkerManifest, WorkerMeta, }; /// Partial-form Worker manifest. Every field is optional; one or more @@ -117,19 +117,24 @@ impl FeatureFlagConfigPartial { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] pub struct TicketFeatureConfigPartial { - #[serde(default)] pub enabled: Option<bool>, - #[serde(default)] - pub preset: Option<TicketFeatureAccessConfig>, + pub authoring: Option<bool>, + pub thread: Option<bool>, + pub intake: Option<bool>, + pub orchestration_control: Option<bool>, } impl TicketFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { enabled: other.enabled.or(self.enabled), - preset: other.preset.or(self.preset), + authoring: other.authoring.or(self.authoring), + thread: other.thread.or(self.thread), + intake: other.intake.or(self.intake), + orchestration_control: other.orchestration_control.or(self.orchestration_control), } } } @@ -179,7 +184,10 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), - preset: value.preset.unwrap_or_default(), + authoring: value.authoring.unwrap_or_default(), + thread: value.thread.unwrap_or_default(), + intake: value.intake.unwrap_or_default(), + orchestration_control: value.orchestration_control.unwrap_or_default(), } } } @@ -188,7 +196,10 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial { fn from(value: TicketFeatureConfig) -> Self { Self { enabled: Some(value.enabled), - preset: Some(value.preset), + authoring: Some(value.authoring), + thread: Some(value.thread), + intake: Some(value.intake), + orchestration_control: Some(value.orchestration_control), } } } @@ -1757,7 +1768,10 @@ enabled = true [feature.ticket] enabled = true -preset = "read_only" +authoring = false +thread = false +intake = false +orchestration_control = false "#, ) .unwrap(); @@ -1787,10 +1801,10 @@ preset = "read_only" .unwrap(); assert!(manifest.feature.task.enabled); assert!(manifest.feature.ticket.enabled); - assert_eq!( - manifest.feature.ticket.preset, - TicketFeatureAccessConfig::ReadOnly - ); + assert!(!manifest.feature.ticket.authoring); + assert!(!manifest.feature.ticket.thread); + assert!(!manifest.feature.ticket.intake); + assert!(!manifest.feature.ticket.orchestration_control); assert!(!manifest.feature.memory.enabled); } @@ -1803,14 +1817,18 @@ enabled = true [feature.ticket] enabled = true -preset = "read_only" +authoring = false +thread = false +intake = false +orchestration_control = false "#, ) .unwrap(); let upper = WorkerManifestConfig::from_toml( r#" [feature.ticket] -preset = "orchestration_control" +thread = true +orchestration_control = true [feature.web] enabled = true @@ -1844,10 +1862,10 @@ enabled = true .unwrap(); assert!(manifest.feature.memory.enabled); assert!(manifest.feature.ticket.enabled); - assert_eq!( - manifest.feature.ticket.preset, - TicketFeatureAccessConfig::OrchestrationControl - ); + assert!(!manifest.feature.ticket.authoring); + assert!(manifest.feature.ticket.thread); + assert!(!manifest.feature.ticket.intake); + assert!(manifest.feature.ticket.orchestration_control); assert!(manifest.feature.web.enabled); assert!(!manifest.feature.workers.enabled); } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 41f17280..d4855059 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -153,38 +153,18 @@ impl Default for FeatureFlagConfig { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct TicketFeatureConfig { #[serde(default)] pub enabled: bool, #[serde(default)] - pub preset: TicketFeatureAccessConfig, -} - -impl Default for TicketFeatureConfig { - fn default() -> Self { - Self { - enabled: false, - preset: TicketFeatureAccessConfig::default(), - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TicketFeatureAccessConfig { - ReadOnly, - WorkspaceAuthoring, - Intake, - OrchestrationControl, - WorkReport, - Review, -} - -impl Default for TicketFeatureAccessConfig { - fn default() -> Self { - Self::WorkspaceAuthoring - } + pub authoring: bool, + #[serde(default)] + pub thread: bool, + #[serde(default)] + pub intake: bool, + #[serde(default)] + pub orchestration_control: bool, } /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 63a4d59a..139f1ee3 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -16,8 +16,8 @@ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, TicketFeatureAccessConfig, WebConfig, WorkerManifest, - WorkerManifestConfig, WorkerMetaConfig, paths, + ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, + WorkerMetaConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; @@ -971,7 +971,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value { "memory": { "enabled": true }, "web": { "enabled": true }, "workers": { "enabled": true }, - "ticket": { "enabled": true, "preset": "workspace_authoring" } + "ticket": { "enabled": true, "authoring": true, "thread": true } }, "memory": { "extract_threshold": 50000, @@ -1005,18 +1005,19 @@ fn apply_role_profile( value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); value["feature"]["web"] = serde_json::json!({ "enabled": web }); value["feature"]["workers"] = serde_json::json!({ "enabled": workers }); - let ticket_access = match slug { - "companion" => TicketFeatureAccessConfig::WorkspaceAuthoring, - "intake" => TicketFeatureAccessConfig::Intake, - "orchestrator" => TicketFeatureAccessConfig::OrchestrationControl, - "coder" => TicketFeatureAccessConfig::WorkReport, - "reviewer" => TicketFeatureAccessConfig::Review, - _ => TicketFeatureAccessConfig::WorkspaceAuthoring, + let ticket = match slug { + "companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), + "intake" => { + serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true }) + } + "orchestrator" => { + serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true }) + } + "coder" => serde_json::json!({ "enabled": true, "thread": true }), + "reviewer" => serde_json::json!({ "enabled": true, "thread": true }), + _ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), }; - value["feature"]["ticket"] = serde_json::json!({ - "enabled": true, - "preset": ticket_access, - }); + value["feature"]["ticket"] = ticket; } fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { @@ -1442,10 +1443,10 @@ mod tests { assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(companion.web.is_some()); assert!(companion.feature.ticket.enabled); - assert_eq!( - companion.feature.ticket.preset, - TicketFeatureAccessConfig::WorkspaceAuthoring - ); + assert!(companion.feature.ticket.authoring); + assert!(companion.feature.ticket.thread); + assert!(!companion.feature.ticket.intake); + assert!(!companion.feature.ticket.orchestration_control); assert_eq!( companion.compaction.as_ref().unwrap().threshold, Some(240000) @@ -1467,10 +1468,11 @@ mod tests { assert!(intake.feature.task.enabled); assert!(!intake.feature.workers.enabled); assert!(intake.feature.ticket.enabled); - assert_eq!( - intake.feature.ticket.preset, - TicketFeatureAccessConfig::Intake - ); + assert!(intake.feature.ticket.enabled); + assert!(intake.feature.ticket.authoring); + assert!(intake.feature.ticket.thread); + assert!(intake.feature.ticket.intake); + assert!(!intake.feature.ticket.orchestration_control); assert!(intake.scope.allow.is_empty()); assert!(intake.delegation_scope.allow.is_empty()); assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1481,10 +1483,11 @@ mod tests { assert!(orchestrator.feature.task.enabled); assert!(orchestrator.feature.workers.enabled); assert!(orchestrator.feature.ticket.enabled); - assert_eq!( - orchestrator.feature.ticket.preset, - TicketFeatureAccessConfig::OrchestrationControl - ); + assert!(orchestrator.feature.ticket.enabled); + assert!(!orchestrator.feature.ticket.authoring); + assert!(orchestrator.feature.ticket.thread); + assert!(!orchestrator.feature.ticket.intake); + assert!(orchestrator.feature.ticket.orchestration_control); assert!(orchestrator.scope.allow.is_empty()); assert!(orchestrator.delegation_scope.allow.is_empty()); assert_eq!( @@ -1503,19 +1506,20 @@ mod tests { assert!(coder.web.is_some()); assert!(coder.compaction.is_some()); assert!(coder.feature.ticket.enabled); - assert_eq!( - coder.feature.ticket.preset, - TicketFeatureAccessConfig::WorkReport - ); - + assert!(coder.feature.ticket.enabled); + assert!(!coder.feature.ticket.authoring); + assert!(coder.feature.ticket.thread); + assert!(!coder.feature.ticket.intake); + assert!(!coder.feature.ticket.orchestration_control); let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.workers.enabled); assert!(reviewer.feature.ticket.enabled); - assert_eq!( - reviewer.feature.ticket.preset, - TicketFeatureAccessConfig::Review - ); + assert!(reviewer.feature.ticket.enabled); + assert!(!reviewer.feature.ticket.authoring); + assert!(reviewer.feature.ticket.thread); + assert!(!reviewer.feature.ticket.intake); + assert!(!reviewer.feature.ticket.orchestration_control); assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty()); assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1664,7 +1668,10 @@ enabled = true [feature.ticket] enabled = true -preset = "read_only" +authoring = false +thread = false +intake = false +orchestration_control = false "#, ); let workspace = tmp.path().join("workspace"); @@ -1682,10 +1689,10 @@ preset = "read_only" assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.workers.enabled); assert!(resolved.manifest.feature.ticket.enabled); - assert_eq!( - resolved.manifest.feature.ticket.preset, - crate::TicketFeatureAccessConfig::ReadOnly - ); + assert!(!resolved.manifest.feature.ticket.authoring); + assert!(!resolved.manifest.feature.ticket.thread); + assert!(!resolved.manifest.feature.ticket.intake); + assert!(!resolved.manifest.feature.ticket.orchestration_control); assert_eq!( resolved.manifest.delegation_scope.allow[0].target, workspace @@ -1768,14 +1775,11 @@ worker_context_max_tokens = 68000 resolved.manifest.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5") ); - assert!(resolved.manifest.scope.allow.is_empty()); - assert!(resolved.manifest.delegation_scope.allow.is_empty()); - assert!(resolved.manifest.session.record_event_trace); assert!(resolved.manifest.feature.ticket.enabled); - assert_eq!( - resolved.manifest.feature.ticket.preset, - crate::TicketFeatureAccessConfig::WorkspaceAuthoring - ); + assert!(resolved.manifest.feature.ticket.authoring); + assert!(resolved.manifest.feature.ticket.thread); + assert!(!resolved.manifest.feature.ticket.intake); + assert!(!resolved.manifest.feature.ticket.orchestration_control); assert_eq!( resolved.profile.as_ref().unwrap().name.as_deref(), Some("default") diff --git a/crates/tui/src/setup_model.rs b/crates/tui/src/setup_model.rs index 0c4d6230..b554dd0c 100644 --- a/crates/tui/src/setup_model.rs +++ b/crates/tui/src/setup_model.rs @@ -238,7 +238,8 @@ enabled = false [feature.ticket] enabled = true -preset = "workspace_authoring" +authoring = true +thread = true [memory] extract_threshold = 50000 @@ -296,7 +297,7 @@ mod tests { assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\"")); assert!(profile.contains("scope = \"workspace_write\"")); assert!( - profile.contains("[feature.ticket]\nenabled = true\npreset = \"workspace_authoring\"") + profile.contains("[feature.ticket]\nenabled = true\nauthoring = true\nthread = true") ); } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index cc131c6c..181a7041 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -4,7 +4,6 @@ use std::sync::atomic::Ordering; use llm_engine::EngineError; use llm_engine::llm_client::client::LlmClient; -use manifest::TicketFeatureAccessConfig; use session_store::WorkerMetadataStore; use session_store::{LogEntry, Store}; use ticket::LocalTicketBackend; @@ -547,9 +546,7 @@ fn install_ticket_event_companion_notify_hook<C, St>( } let ticket_feature = &worker.manifest().feature.ticket; - if !ticket_feature.enabled - || ticket_feature.preset != TicketFeatureAccessConfig::OrchestrationControl - { + if !ticket_feature.enabled || !ticket_feature.orchestration_control { return; } @@ -675,25 +672,11 @@ where feature_registry.add_module(task_feature); } if feature_config.ticket.enabled { - let ticket_access = match feature_config.ticket.preset { - TicketFeatureAccessConfig::ReadOnly => { - crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly - } - TicketFeatureAccessConfig::WorkspaceAuthoring => { - crate::feature::builtin::ticket::TicketFeatureAccess::WorkspaceAuthoring - } - TicketFeatureAccessConfig::Intake => { - crate::feature::builtin::ticket::TicketFeatureAccess::Intake - } - TicketFeatureAccessConfig::OrchestrationControl => { - crate::feature::builtin::ticket::TicketFeatureAccess::OrchestrationControl - } - TicketFeatureAccessConfig::WorkReport => { - crate::feature::builtin::ticket::TicketFeatureAccess::WorkReport - } - TicketFeatureAccessConfig::Review => { - crate::feature::builtin::ticket::TicketFeatureAccess::Review - } + let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess { + authoring: feature_config.ticket.authoring, + thread: feature_config.ticket.thread, + intake: feature_config.ticket.intake, + orchestration_control: feature_config.ticket.orchestration_control, }; // Ticket tools are typed operations over the current workspace Ticket backend. // Runtime-hosted Workers prefer the workspace API URI carried by the diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 2d046a05..2a086cbd 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -14,7 +14,7 @@ use ticket::{ TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, TicketStateChange, TicketSummary, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, - tool::{TicketToolBackend, ticket_tool_description, ticket_tools}, + tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools}, }; use crate::feature::{ @@ -27,23 +27,88 @@ const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ The tools operate through the ticket crate backend and do not grant generic filesystem write scope."; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TicketFeatureAccess { - /// Status/diagnostic access for views that must not mutate Tickets. - ReadOnly, - /// User/Companion authoring access for shaping current Ticket specs and queueing work. - WorkspaceAuthoring, - /// Intake access for creating/refining planning Tickets and marking them ready. - Intake, - /// Orchestrator control access for state/thread/relation/orchestration operations. - OrchestrationControl, - /// Coder-style access for reading Tickets and writing implementation reports/comments. - WorkReport, - /// Reviewer-style access for reading Tickets and writing review/comment thread events. - Review, +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TicketFeatureAccess { + pub authoring: bool, + pub thread: bool, + pub intake: bool, + pub orchestration_control: bool, } -const READ_ONLY_TOOL_NAMES: [&str; 6] = [ +impl TicketFeatureAccess { + pub const fn read_only() -> Self { + Self { + authoring: false, + thread: false, + intake: false, + orchestration_control: false, + } + } + + pub const fn workspace_authoring() -> Self { + Self { + authoring: true, + thread: true, + intake: false, + orchestration_control: false, + } + } + + pub const fn intake() -> Self { + Self { + authoring: true, + thread: true, + intake: true, + orchestration_control: false, + } + } + + pub const fn orchestration_control() -> Self { + Self { + authoring: false, + thread: true, + intake: false, + orchestration_control: true, + } + } + + pub const fn work_report() -> Self { + Self { + authoring: false, + thread: true, + intake: false, + orchestration_control: false, + } + } + + pub const fn review() -> Self { + Self { + authoring: false, + thread: true, + intake: false, + orchestration_control: false, + } + } + + pub fn tool_names(self) -> Vec<&'static str> { + TICKET_TOOL_NAMES + .iter() + .copied() + .filter(|name| self.allows_tool(name)) + .collect() + } + + fn allows_tool(self, name: &str) -> bool { + READ_ONLY_TOOL_NAMES.contains(&name) + || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) + || (self.thread && THREAD_TOOL_NAMES.contains(&name)) + || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) + || (self.orchestration_control + && ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name)) + } +} + +const READ_ONLY_TOOL_NAMES: &[&str] = &[ "TicketList", "TicketShow", "TicketDependencyCheck", @@ -52,7 +117,19 @@ const READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -const WORKSPACE_AUTHORING_TOOL_NAMES: [&str; 12] = [ +const AUTHORING_TOOL_NAMES: &[&str] = &[ + "TicketCreate", + "TicketEditItem", + "TicketQueue", + "TicketClose", + "TicketRelationRecord", +]; + +const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"]; + +const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; + +const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", "TicketList", @@ -65,22 +142,10 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: [&str; 12] = [ "TicketDoctor", "TicketRelationRecord", "TicketRelationQuery", + "TicketOrchestrationPlanQuery", ]; -const INTAKE_TOOL_NAMES: [&str; 10] = [ - "TicketCreate", - "TicketEditItem", - "TicketList", - "TicketShow", - "TicketComment", - "TicketIntakeReady", - "TicketDependencyCheck", - "TicketDoctor", - "TicketRelationRecord", - "TicketRelationQuery", -]; - -const ORCHESTRATION_CONTROL_TOOL_NAMES: [&str; 12] = [ +const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[ "TicketList", "TicketShow", "TicketComment", @@ -95,40 +160,13 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: [&str; 12] = [ "TicketOrchestrationPlanQuery", ]; -const WORK_REPORT_TOOL_NAMES: [&str; 7] = [ - "TicketList", - "TicketShow", - "TicketComment", - "TicketDependencyCheck", - "TicketDoctor", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", +const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[ + "TicketWorkflowState", + "TicketClose", + "TicketRelationRecord", + "TicketOrchestrationPlanRecord", ]; -const REVIEW_TOOL_NAMES: [&str; 8] = [ - "TicketList", - "TicketShow", - "TicketComment", - "TicketReview", - "TicketDependencyCheck", - "TicketDoctor", - "TicketRelationQuery", - "TicketOrchestrationPlanQuery", -]; - -impl TicketFeatureAccess { - pub fn tool_names(self) -> &'static [&'static str] { - match self { - Self::ReadOnly => &READ_ONLY_TOOL_NAMES, - Self::WorkspaceAuthoring => &WORKSPACE_AUTHORING_TOOL_NAMES, - Self::Intake => &INTAKE_TOOL_NAMES, - Self::OrchestrationControl => &ORCHESTRATION_CONTROL_TOOL_NAMES, - Self::WorkReport => &WORK_REPORT_TOOL_NAMES, - Self::Review => &REVIEW_TOOL_NAMES, - } - } -} - #[derive(Clone, Debug)] pub enum TicketFeatureBackend { Local { @@ -173,7 +211,7 @@ pub struct TicketFeature { impl TicketFeature { pub fn new(backend_root: impl Into<PathBuf>) -> Self { - Self::new_with_access(backend_root, TicketFeatureAccess::WorkspaceAuthoring) + Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring()) } pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self { @@ -198,7 +236,7 @@ impl TicketFeature { } pub fn for_workspace(workspace: impl AsRef<Path>) -> Self { - Self::for_workspace_with_access(workspace, TicketFeatureAccess::WorkspaceAuthoring) + Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring()) } pub fn for_workspace_with_access( @@ -237,7 +275,7 @@ impl TicketFeature { self.access } - fn enabled_tool_names(&self) -> &'static [&'static str] { + fn enabled_tool_names(&self) -> Vec<&'static str> { self.access.tool_names() } @@ -298,7 +336,7 @@ impl FeatureModule for TicketFeature { let enabled_tool_names = self.enabled_tool_names(); for name in enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new( - *name, + name, ticket_tool_description(name, self.record_language.as_deref()), )); } @@ -701,9 +739,10 @@ mod tests { #[test] fn read_only_descriptor_declares_only_state_tools() { let temp = TempDir::new().unwrap(); - let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly); + let feature = + ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only()); let descriptor = feature.descriptor(); - assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly); + assert_eq!(feature.access(), TicketFeatureAccess::read_only()); assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len()); assert_eq!( descriptor @@ -720,10 +759,13 @@ mod tests { let temp = TempDir::new().unwrap(); let feature = ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::OrchestrationControl, + TicketFeatureAccess::orchestration_control(), ); let descriptor = feature.descriptor(); - assert_eq!(feature.access(), TicketFeatureAccess::OrchestrationControl); + assert_eq!( + feature.access(), + TicketFeatureAccess::orchestration_control() + ); assert_eq!( descriptor .tools @@ -735,11 +777,13 @@ mod tests { } #[test] - fn semantic_access_presets_expose_role_capability_surfaces() { + fn additive_ticket_capabilities_expose_expected_tool_surfaces() { let temp = TempDir::new().unwrap(); - let workspace_authoring = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::WorkspaceAuthoring); + let workspace_authoring = ticket_tools_feature_with_access( + temp.path(), + TicketFeatureAccess::workspace_authoring(), + ); let workspace_descriptor = workspace_authoring.descriptor(); let workspace_tools = workspace_descriptor .tools @@ -753,7 +797,7 @@ mod tests { let orchestration = ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::OrchestrationControl, + TicketFeatureAccess::orchestration_control(), ); let orchestration_descriptor = orchestration.descriptor(); let orchestration_tools = orchestration_descriptor @@ -769,7 +813,7 @@ mod tests { assert!(!orchestration_tools.contains(&"TicketQueue")); let work_report = - ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::WorkReport); + ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report()); let work_report_descriptor = work_report.descriptor(); let work_report_tools = work_report_descriptor .tools @@ -777,10 +821,10 @@ mod tests { .map(|tool| tool.name.as_str()) .collect::<Vec<_>>(); assert!(work_report_tools.contains(&"TicketComment")); - assert!(!work_report_tools.contains(&"TicketReview")); + assert!(work_report_tools.contains(&"TicketReview")); assert!(!work_report_tools.contains(&"TicketWorkflowState")); - let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::Review); + let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review()); let review_descriptor = review.descriptor(); let review_tools = review_descriptor .tools @@ -800,7 +844,7 @@ mod tests { let report = FeatureRegistryBuilder::new() .with_module(ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::ReadOnly, + TicketFeatureAccess::read_only(), )) .install_into_pending(&mut pending_tools, &mut hooks); @@ -833,7 +877,8 @@ language = "Japanese" "#, ); make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); - let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly); + let feature = + ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only()); let descriptor = feature.descriptor(); let descriptor_description = descriptor .tools @@ -867,7 +912,7 @@ language = "Japanese" let report = FeatureRegistryBuilder::new() .with_module(ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::WorkspaceAuthoring, + TicketFeatureAccess::workspace_authoring(), )) .install_into_pending(&mut pending_tools, &mut hooks); @@ -906,7 +951,7 @@ language = "Japanese" let report = FeatureRegistryBuilder::new() .with_module(ticket_tools_feature_with_access( temp.path(), - TicketFeatureAccess::WorkspaceAuthoring, + TicketFeatureAccess::workspace_authoring(), )) .install_into_pending(&mut pending_tools, &mut hooks); diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 7a6a4e75..e04783c5 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = true; preset = "work_report"; }; + ticket = { enabled = true; thread = true; }; }; } diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index cb78855e..0e84e56e 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = true; preset = "workspace_authoring"; }; + ticket = { enabled = true; authoring = true; thread = true; }; }; } diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 9b261b41..41b3b763 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -26,7 +26,7 @@ feature = { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = true; preset = "workspace_authoring"; }; + ticket = { enabled = true; authoring = true; thread = true; }; }; memory = { diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index dc0be16b..28bea099 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = true; preset = "intake"; }; + ticket = { enabled = true; authoring = true; thread = true; intake = true; }; }; } diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index 951bdef3..a96df119 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = true; preset = "orchestration_control"; }; + ticket = { enabled = true; thread = true; orchestration_control = true; }; }; } diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index 41c32401..1e01c3dd 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -8,6 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = true; preset = "review"; }; + ticket = { enabled = true; thread = true; }; }; }