diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 62692195..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 @@ -87,8 +87,6 @@ pub struct FeatureConfigPartial { #[serde(default)] pub ticket: Option, #[serde(default)] - pub ticket_orchestration: Option, - #[serde(default)] pub plugins: Option, } @@ -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), } } @@ -124,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, - #[serde(default)] - pub access: Option, + pub authoring: Option, + pub thread: Option, + pub intake: Option, + pub orchestration_control: Option, } impl TicketFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { enabled: other.enabled.or(self.enabled), - access: other.access.or(self.access), + 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), } } } @@ -158,10 +156,6 @@ impl From 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) @@ -190,7 +184,10 @@ impl From for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), - access: value.access.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(), } } } @@ -199,7 +196,10 @@ impl From for TicketFeatureConfigPartial { fn from(value: TicketFeatureConfig) -> Self { Self { enabled: Some(value.enabled), - access: Some(value.access), + authoring: Some(value.authoring), + thread: Some(value.thread), + intake: Some(value.intake), + orchestration_control: Some(value.orchestration_control), } } } @@ -212,7 +212,6 @@ impl From 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()), } } @@ -1758,7 +1757,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] @@ -1770,10 +1768,10 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" - -[feature.ticket_orchestration] -enabled = true +authoring = false +thread = false +intake = false +orchestration_control = false "#, ) .unwrap(); @@ -1803,11 +1801,10 @@ enabled = true .unwrap(); assert!(manifest.feature.task.enabled); assert!(manifest.feature.ticket.enabled); - assert_eq!( - manifest.feature.ticket.access, - TicketFeatureAccessConfig::ReadOnly - ); - assert!(manifest.feature.ticket_orchestration.enabled); + 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); } @@ -1820,14 +1817,18 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" +authoring = false +thread = false +intake = false +orchestration_control = false "#, ) .unwrap(); let upper = WorkerManifestConfig::from_toml( r#" [feature.ticket] -access = "lifecycle" +thread = true +orchestration_control = true [feature.web] enabled = true @@ -1861,10 +1862,10 @@ enabled = true .unwrap(); assert!(manifest.feature.memory.enabled); assert!(manifest.feature.ticket.enabled); - assert_eq!( - manifest.feature.ticket.access, - TicketFeatureAccessConfig::Lifecycle - ); + 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 4c9989a1..d4855059 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(), } } @@ -156,37 +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, - /// 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, -} - -impl Default for TicketFeatureConfig { - fn default() -> Self { - Self { - enabled: false, - access: TicketFeatureAccessConfig::Lifecycle, - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TicketFeatureAccessConfig { - ReadOnly, - Lifecycle, -} - -impl Default for TicketFeatureAccessConfig { - fn default() -> Self { - Self::Lifecycle - } + 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 bd2adb68..139f1ee3 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -894,7 +894,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, true, - false, ); Some(value) } @@ -908,7 +907,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, false, - false, ); Some(value) } @@ -922,7 +920,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, true, - true, ); Some(value) } @@ -936,7 +933,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, false, - false, ); Some(value) } @@ -950,7 +946,6 @@ fn builtin_profile_artifact(label: &str) -> Option { 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, "authoring": true, "thread": true } }, "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()); @@ -1012,8 +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 }); - value["feature"]["ticket_orchestration"] = - serde_json::json!({ "enabled": ticket_orchestration }); + 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"] = ticket; } fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { @@ -1439,7 +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!(!companion.feature.ticket_orchestration.enabled); + 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) @@ -1461,18 +1468,26 @@ mod tests { assert!(intake.feature.task.enabled); assert!(!intake.feature.workers.enabled); assert!(intake.feature.ticket.enabled); + 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")); 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!(orchestrator.feature.ticket_orchestration.enabled); + 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!( @@ -1491,13 +1506,20 @@ mod tests { assert!(coder.web.is_some()); assert!(coder.compaction.is_some()); assert!(coder.feature.ticket.enabled); - assert!(!coder.feature.ticket_orchestration.enabled); - + 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!(!reviewer.feature.ticket_orchestration.enabled); + 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")); @@ -1646,10 +1668,10 @@ enabled = true [feature.ticket] enabled = true -access = "read_only" - -[feature.ticket_orchestration] -enabled = false +authoring = false +thread = false +intake = false +orchestration_control = false "#, ); let workspace = tmp.path().join("workspace"); @@ -1667,11 +1689,10 @@ enabled = false assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.workers.enabled); assert!(resolved.manifest.feature.ticket.enabled); - assert_eq!( - resolved.manifest.feature.ticket.access, - crate::TicketFeatureAccessConfig::ReadOnly - ); - assert!(!resolved.manifest.feature.ticket_orchestration.enabled); + 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 @@ -1754,15 +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.access, - crate::TicketFeatureAccessConfig::Lifecycle - ); - assert!(!resolved.manifest.feature.ticket_orchestration.enabled); + 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/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 36dcd375..b3c04dcb 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -511,17 +511,138 @@ impl NewTicket { } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketFilter { - pub state: Option, +pub struct TicketItemEdit { + pub title: Option, + pub body: Option, + pub author: Option, } -impl TicketFilter { - pub fn all() -> Self { - Self { state: None } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketDependencyCheck { + pub ticket: TicketSummary, + pub blockers: Vec, + 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 { + Planning, + Ready, + Queued, + InProgress, + Done, + Closed, +} + +impl TicketListState { + pub fn parse(value: &str) -> Option { + 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 state(state: TicketWorkflowState) -> Self { - Self { state: Some(state) } + 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 { + /// All non-closed workflow states: planning, ready, queued, inprogress, and done. + Active, + /// Every workflow state, including closed. + All, + /// An explicit set of list-query state tokens. + States(BTreeSet), +} + +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: TicketStateSelector::All, + } + } + + pub fn state(state: TicketListState) -> Self { + 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 + .iter() + .any(|query_state| query_state.matches_workflow_state(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(","), + } } } @@ -633,6 +754,415 @@ pub struct TicketRelationView { pub notices: Vec, } +#[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, + pub blocked_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketWorkspaceProjection { + pub kind: TicketWorkspaceRowKind, + pub priority: TicketWorkspaceActionPriority, + pub next_action: Option, + pub visible_state: String, + pub visible_overlay: Option, + pub disabled_reason: Option, + pub key_hint: Option, + pub blocked_reason: Option, + 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::>(); + 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::>(); + if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready { + let blockers_to_report = if active_blockers.is_empty() { + relation_blockers.iter().collect::>() + } 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::>(), + ); + 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::>() + .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 { @@ -873,9 +1403,11 @@ 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 edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result; + fn dependency_check(&self, id: TicketIdOrSlug) -> Result; 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<()>; @@ -926,7 +1458,7 @@ pub enum TicketBackendOperation { from: String, }, List { - filter: TicketFilter, + filter: TicketListQuery, }, Show { id: TicketIdOrSlug, @@ -934,6 +1466,13 @@ pub enum TicketBackendOperation { Create { input: NewTicket, }, + EditItem { + id: TicketIdOrSlug, + edit: TicketItemEdit, + }, + DependencyCheck { + id: TicketIdOrSlug, + }, AddEvent { id: TicketIdOrSlug, event: NewTicketEvent, @@ -1002,6 +1541,7 @@ pub enum TicketBackendOperationResult { Tickets(Vec), Ticket(Ticket), TicketRef(TicketRef), + DependencyCheck(TicketDependencyCheck), Relation(TicketRelation), Relations(Vec), RelationView(TicketRelationView), @@ -1032,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 @@ -1143,10 +1689,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 +1701,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 +1798,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 +1818,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 +2038,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 +2051,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 +2082,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 +2096,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 +2132,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"); @@ -1706,6 +2249,79 @@ impl TicketBackend for LocalTicketBackend { }) } + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result { + 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 { + 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)?; @@ -2088,7 +2704,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 +2733,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) => { @@ -3225,6 +3841,32 @@ fn replace_frontmatter_fields( Ok(out) } +fn replace_item_body(content: &str, body: &str) -> std::result::Result { + 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 { let mut out = String::from("