Merge branch 'work/ticket-access-capabilities' into develop

This commit is contained in:
Keisuke Hirata 2026-07-21 06:28:41 +09:00
commit 2e0cd3d161
No known key found for this signature in database
18 changed files with 1658 additions and 651 deletions

View File

@ -19,8 +19,8 @@ use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta, WebConfig, WorkerManifest, WorkerMeta,
}; };
/// Partial-form Worker manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
@ -87,8 +87,6 @@ pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>, pub plugins: Option<FeatureFlagConfigPartial>,
} }
@ -100,11 +98,6 @@ impl FeatureConfigPartial {
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge), workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::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), 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 { pub struct TicketFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[serde(default)] pub authoring: Option<bool>,
pub access: Option<TicketFeatureAccessConfig>, pub thread: Option<bool>,
pub intake: Option<bool>,
pub orchestration_control: Option<bool>,
} }
impl TicketFeatureConfigPartial { impl TicketFeatureConfigPartial {
fn merge(self, other: Self) -> Self { fn merge(self, other: Self) -> Self {
Self { Self {
enabled: other.enabled.or(self.enabled), 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<FeatureConfigPartial> for FeatureConfig {
.ticket .ticket
.map(TicketFeatureConfig::from) .map(TicketFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
ticket_orchestration: value
.ticket_orchestration
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
plugins: value plugins: value
.plugins .plugins
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
@ -190,7 +184,10 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
fn from(value: TicketFeatureConfigPartial) -> Self { fn from(value: TicketFeatureConfigPartial) -> Self {
Self { Self {
enabled: value.enabled.unwrap_or_default(), 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<TicketFeatureConfig> for TicketFeatureConfigPartial {
fn from(value: TicketFeatureConfig) -> Self { fn from(value: TicketFeatureConfig) -> Self {
Self { Self {
enabled: Some(value.enabled), 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<FeatureConfig> for FeatureConfigPartial {
web: Some(value.web.into()), web: Some(value.web.into()),
workers: Some(value.workers.into()), workers: Some(value.workers.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()),
plugins: Some(value.plugins.into()), plugins: Some(value.plugins.into()),
} }
} }
@ -1758,7 +1757,6 @@ worker_max_turns = 7
assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.workers.enabled); assert!(!manifest.feature.workers.enabled);
assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket_orchestration.enabled);
} }
#[test] #[test]
@ -1770,10 +1768,10 @@ enabled = true
[feature.ticket] [feature.ticket]
enabled = true enabled = true
access = "read_only" authoring = false
thread = false
[feature.ticket_orchestration] intake = false
enabled = true orchestration_control = false
"#, "#,
) )
.unwrap(); .unwrap();
@ -1803,11 +1801,10 @@ enabled = true
.unwrap(); .unwrap();
assert!(manifest.feature.task.enabled); assert!(manifest.feature.task.enabled);
assert!(manifest.feature.ticket.enabled); assert!(manifest.feature.ticket.enabled);
assert_eq!( assert!(!manifest.feature.ticket.authoring);
manifest.feature.ticket.access, assert!(!manifest.feature.ticket.thread);
TicketFeatureAccessConfig::ReadOnly assert!(!manifest.feature.ticket.intake);
); assert!(!manifest.feature.ticket.orchestration_control);
assert!(manifest.feature.ticket_orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.enabled);
} }
@ -1820,14 +1817,18 @@ enabled = true
[feature.ticket] [feature.ticket]
enabled = true enabled = true
access = "read_only" authoring = false
thread = false
intake = false
orchestration_control = false
"#, "#,
) )
.unwrap(); .unwrap();
let upper = WorkerManifestConfig::from_toml( let upper = WorkerManifestConfig::from_toml(
r#" r#"
[feature.ticket] [feature.ticket]
access = "lifecycle" thread = true
orchestration_control = true
[feature.web] [feature.web]
enabled = true enabled = true
@ -1861,10 +1862,10 @@ enabled = true
.unwrap(); .unwrap();
assert!(manifest.feature.memory.enabled); assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.ticket.enabled); assert!(manifest.feature.ticket.enabled);
assert_eq!( assert!(!manifest.feature.ticket.authoring);
manifest.feature.ticket.access, assert!(manifest.feature.ticket.thread);
TicketFeatureAccessConfig::Lifecycle assert!(!manifest.feature.ticket.intake);
); assert!(manifest.feature.ticket.orchestration_control);
assert!(manifest.feature.web.enabled); assert!(manifest.feature.web.enabled);
assert!(!manifest.feature.workers.enabled); assert!(!manifest.feature.workers.enabled);
} }

View File

@ -115,8 +115,6 @@ pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig, pub plugins: FeatureFlagConfig,
} }
@ -128,7 +126,6 @@ impl Default for FeatureConfig {
web: FeatureFlagConfig::disabled(), web: FeatureFlagConfig::disabled(),
workers: FeatureFlagConfig::disabled(), workers: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: 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 { pub struct TicketFeatureConfig {
#[serde(default)] #[serde(default)]
pub enabled: bool, 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)] #[serde(default)]
pub access: TicketFeatureAccessConfig, pub authoring: bool,
} #[serde(default)]
pub thread: bool,
impl Default for TicketFeatureConfig { #[serde(default)]
fn default() -> Self { pub intake: bool,
Self { #[serde(default)]
enabled: false, pub orchestration_control: bool,
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
}
} }
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are

View File

@ -894,7 +894,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true, true,
true, true,
true, true,
false,
); );
Some(value) Some(value)
} }
@ -908,7 +907,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true, true,
true, true,
false, false,
false,
); );
Some(value) Some(value)
} }
@ -922,7 +920,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true, true,
true, true,
true, true,
true,
); );
Some(value) Some(value)
} }
@ -936,7 +933,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true, true,
true, true,
false, false,
false,
); );
Some(value) Some(value)
} }
@ -950,7 +946,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true, true,
true, true,
false, false,
false,
); );
Some(value) Some(value)
} }
@ -976,8 +971,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value {
"memory": { "enabled": true }, "memory": { "enabled": true },
"web": { "enabled": true }, "web": { "enabled": true },
"workers": { "enabled": true }, "workers": { "enabled": true },
"ticket": { "enabled": true, "access": "lifecycle" }, "ticket": { "enabled": true, "authoring": true, "thread": true }
"ticket_orchestration": { "enabled": false }
}, },
"memory": { "memory": {
"extract_threshold": 50000, "extract_threshold": 50000,
@ -1004,7 +998,6 @@ fn apply_role_profile(
memory: bool, memory: bool,
web: bool, web: bool,
workers: bool, workers: bool,
ticket_orchestration: bool,
) { ) {
value["slug"] = serde_json::Value::String(slug.to_string()); value["slug"] = serde_json::Value::String(slug.to_string());
value["description"] = serde_json::Value::String(description.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"]["memory"] = serde_json::json!({ "enabled": memory });
value["feature"]["web"] = serde_json::json!({ "enabled": web }); value["feature"]["web"] = serde_json::json!({ "enabled": web });
value["feature"]["workers"] = serde_json::json!({ "enabled": workers }); value["feature"]["workers"] = serde_json::json!({ "enabled": workers });
value["feature"]["ticket_orchestration"] = let ticket = match slug {
serde_json::json!({ "enabled": ticket_orchestration }); "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> { 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_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(companion.web.is_some()); assert!(companion.web.is_some());
assert!(companion.feature.ticket.enabled); 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!( assert_eq!(
companion.compaction.as_ref().unwrap().threshold, companion.compaction.as_ref().unwrap().threshold,
Some(240000) Some(240000)
@ -1461,18 +1468,26 @@ mod tests {
assert!(intake.feature.task.enabled); assert!(intake.feature.task.enabled);
assert!(!intake.feature.workers.enabled); assert!(!intake.feature.workers.enabled);
assert!(intake.feature.ticket.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.scope.allow.is_empty());
assert!(intake.delegation_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_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(intake.web.is_some()); assert!(intake.web.is_some());
assert!(intake.compaction.is_some()); assert!(intake.compaction.is_some());
assert!(!intake.feature.ticket_orchestration.enabled);
let orchestrator = resolve("orchestrator"); let orchestrator = resolve("orchestrator");
assert!(orchestrator.feature.task.enabled); assert!(orchestrator.feature.task.enabled);
assert!(orchestrator.feature.workers.enabled); assert!(orchestrator.feature.workers.enabled);
assert!(orchestrator.feature.ticket.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.scope.allow.is_empty());
assert!(orchestrator.delegation_scope.allow.is_empty()); assert!(orchestrator.delegation_scope.allow.is_empty());
assert_eq!( assert_eq!(
@ -1491,13 +1506,20 @@ mod tests {
assert!(coder.web.is_some()); assert!(coder.web.is_some());
assert!(coder.compaction.is_some()); assert!(coder.compaction.is_some());
assert!(coder.feature.ticket.enabled); 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"); let reviewer = resolve("reviewer");
assert!(reviewer.feature.task.enabled); assert!(reviewer.feature.task.enabled);
assert!(!reviewer.feature.workers.enabled); assert!(!reviewer.feature.workers.enabled);
assert!(reviewer.feature.ticket.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.scope.allow.is_empty());
assert!(reviewer.delegation_scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty());
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@ -1646,10 +1668,10 @@ enabled = true
[feature.ticket] [feature.ticket]
enabled = true enabled = true
access = "read_only" authoring = false
thread = false
[feature.ticket_orchestration] intake = false
enabled = false orchestration_control = false
"#, "#,
); );
let workspace = tmp.path().join("workspace"); let workspace = tmp.path().join("workspace");
@ -1667,11 +1689,10 @@ enabled = false
assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.workers.enabled); assert!(resolved.manifest.feature.workers.enabled);
assert!(resolved.manifest.feature.ticket.enabled); assert!(resolved.manifest.feature.ticket.enabled);
assert_eq!( assert!(!resolved.manifest.feature.ticket.authoring);
resolved.manifest.feature.ticket.access, assert!(!resolved.manifest.feature.ticket.thread);
crate::TicketFeatureAccessConfig::ReadOnly assert!(!resolved.manifest.feature.ticket.intake);
); assert!(!resolved.manifest.feature.ticket.orchestration_control);
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
assert_eq!( assert_eq!(
resolved.manifest.delegation_scope.allow[0].target, resolved.manifest.delegation_scope.allow[0].target,
workspace workspace
@ -1754,15 +1775,11 @@ worker_context_max_tokens = 68000
resolved.manifest.model.ref_.as_deref(), resolved.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.5") 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!(resolved.manifest.feature.ticket.enabled);
assert_eq!( assert!(resolved.manifest.feature.ticket.authoring);
resolved.manifest.feature.ticket.access, assert!(resolved.manifest.feature.ticket.thread);
crate::TicketFeatureAccessConfig::Lifecycle assert!(!resolved.manifest.feature.ticket.intake);
); assert!(!resolved.manifest.feature.ticket.orchestration_control);
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
assert_eq!( assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(), resolved.profile.as_ref().unwrap().name.as_deref(),
Some("default") Some("default")

View File

@ -511,17 +511,138 @@ impl NewTicket {
} }
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketFilter { pub struct TicketItemEdit {
pub state: Option<TicketWorkflowState>, 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 {
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 {
/// 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<TicketListState>),
}
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,
}
} }
impl TicketFilter {
pub fn all() -> Self { pub fn all() -> Self {
Self { state: None } Self {
state: TicketStateSelector::All,
}
} }
pub fn state(state: TicketWorkflowState) -> Self { pub fn state(state: TicketListState) -> Self {
Self { state: Some(state) } Self::states([state])
}
pub fn states(states: impl IntoIterator<Item = TicketListState>) -> 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::<Vec<_>>()
.join(","),
}
} }
} }
@ -633,6 +754,415 @@ pub struct TicketRelationView {
pub notices: Vec<TicketRelationNotice>, pub notices: Vec<TicketRelationNotice>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceActionPriority {
ReadyForQueue,
ActiveWork,
Background,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceNextAction {
Clarify,
QueueForOrchestrator,
Close,
WaitForOrchestrator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketWorkspaceRowKind {
Planning,
Ticket,
Review,
ActiveWork,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkspaceStateOverlay {
pub source: String,
pub workflow_state: TicketWorkflowState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketQueueGuard {
pub can_queue_for_orchestrator: bool,
pub reason: Option<String>,
pub blocked_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkspaceProjection {
pub kind: TicketWorkspaceRowKind,
pub priority: TicketWorkspaceActionPriority,
pub next_action: Option<TicketWorkspaceNextAction>,
pub visible_state: String,
pub visible_overlay: Option<TicketWorkspaceStateOverlay>,
pub disabled_reason: Option<String>,
pub key_hint: Option<String>,
pub blocked_reason: Option<String>,
pub queue_guard: TicketQueueGuard,
}
pub fn project_ticket_workspace_item(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
) -> TicketWorkspaceProjection {
let visible_overlay = orchestration_overlay
.filter(|overlay| {
ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
})
.cloned();
let mut projection = derive_ticket_workspace_projection(summary, relation_blockers);
if let Some(overlay) = visible_overlay.as_ref() {
apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay);
}
projection.visible_state =
ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref());
projection.visible_overlay = visible_overlay;
projection.queue_guard = ticket_queue_guard(
summary,
relation_blockers,
projection.visible_overlay.as_ref(),
);
projection
}
pub fn ticket_queue_guard(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
) -> TicketQueueGuard {
if orchestration_overlay.is_some() {
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(
"orchestration overlay already shows progress; duplicate queue is suppressed"
.to_string(),
),
blocked_reason: None,
};
}
if summary.workflow_state != TicketWorkflowState::Ready {
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(format!(
"Ticket state is {}; only ready Tickets can be queued for Orchestrator",
summary.workflow_state.as_str()
)),
blocked_reason: None,
};
}
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
let blockers = format_workspace_relation_blockers(&active_blockers);
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(format!("waiting for {blockers}")),
blocked_reason: Some(blockers),
};
}
TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
}
}
fn derive_ticket_workspace_projection(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
) -> TicketWorkspaceProjection {
if !relation_blockers.is_empty() {
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
let blockers_to_report = if active_blockers.is_empty() {
relation_blockers.iter().collect::<Vec<_>>()
} else {
active_blockers
};
let blockers = format_workspace_relation_blockers(&blockers_to_report);
let waiting_reason = format!("waiting for {blockers}");
return TicketWorkspaceProjection {
kind: workspace_row_kind_for_state(summary.workflow_state),
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
TicketWorkspaceActionPriority::ActiveWork
}
_ => TicketWorkspaceActionPriority::Background,
},
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(waiting_reason),
blocked_reason: None,
},
};
}
let blockers = format_workspace_relation_blockers(
&relation_blockers
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
return TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Ticket,
priority: TicketWorkspaceActionPriority::ReadyForQueue,
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: None,
key_hint: Some(format!(
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
)),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
},
};
}
match summary.workflow_state {
TicketWorkflowState::Ready => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Ticket,
priority: TicketWorkspaceActionPriority::ReadyForQueue,
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: None,
key_hint: Some(
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
blocked_reason: None,
},
},
TicketWorkflowState::Queued => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::ActiveWork,
priority: TicketWorkspaceActionPriority::ActiveWork,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is already queued for Orchestrator routing".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::InProgress => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::ActiveWork,
priority: TicketWorkspaceActionPriority::ActiveWork,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is already in progress.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is already in progress".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Done => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Review,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::Close),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(
"state is done; close if a resolution is still missing.".to_string(),
),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is done; close or review instead of queueing".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Planning => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Planning,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::Clarify),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(
"Ticket is still in planning; mark it ready before queueing.".to_string(),
),
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is still in planning".to_string()),
blocked_reason: None,
},
},
TicketWorkflowState::Closed => TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Review,
priority: TicketWorkspaceActionPriority::Background,
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some("Ticket is closed.".to_string()),
key_hint: None,
blocked_reason: None,
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Ticket is closed".to_string()),
blocked_reason: None,
},
},
}
}
fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind {
match state {
TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
TicketWorkspaceRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review,
TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket,
}
}
fn apply_workspace_overlay_to_projection(
projection: &mut TicketWorkspaceProjection,
local: TicketWorkflowState,
overlay: &TicketWorkspaceStateOverlay,
) {
projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator);
let overlay_state = overlay.workflow_state.as_str();
match overlay.workflow_state {
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
projection.kind = TicketWorkspaceRowKind::Review;
projection.priority = TicketWorkspaceActionPriority::Background;
projection.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
overlay.source,
local.as_str()
));
projection.key_hint = Some(format!(
"Merge pending: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
projection.kind = TicketWorkspaceRowKind::ActiveWork;
projection.priority = TicketWorkspaceActionPriority::ActiveWork;
projection.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
overlay.source,
local.as_str()
));
projection.key_hint = Some(format!(
"Progress overlay: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
}
}
fn ticket_workspace_state_display(
local: TicketWorkflowState,
overlay: Option<&TicketWorkspaceStateOverlay>,
) -> String {
match overlay {
Some(overlay) => format!(
"{}→{}",
compact_ticket_state_label(local),
compact_ticket_state_label(overlay.workflow_state)
),
None => local.as_str().to_string(),
}
}
fn ticket_overlay_state_has_progressed(
local: TicketWorkflowState,
overlay: TicketWorkflowState,
) -> bool {
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
}
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
match state {
TicketWorkflowState::Planning => 0,
TicketWorkflowState::Ready => 1,
TicketWorkflowState::Queued => 2,
TicketWorkflowState::InProgress => 3,
TicketWorkflowState::Done => 4,
TicketWorkflowState::Closed => 5,
}
}
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
match state {
TicketWorkflowState::Planning => "plan",
TicketWorkflowState::Ready => "ready",
TicketWorkflowState::Queued => "q",
TicketWorkflowState::InProgress => "prog",
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
let shown_blockers = blockers.iter().take(3).count();
let mut formatted = blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
}
formatted
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum OrchestrationPlanKind { pub enum OrchestrationPlanKind {
@ -873,9 +1403,11 @@ impl TicketDoctorReport {
pub trait TicketBackend { pub trait TicketBackend {
fn default_intake_ready_state_change_body(&self, from: &str) -> String; fn default_intake_ready_state_change_body(&self, from: &str) -> String;
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>; fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>; fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>; 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_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
@ -926,7 +1458,7 @@ pub enum TicketBackendOperation {
from: String, from: String,
}, },
List { List {
filter: TicketFilter, filter: TicketListQuery,
}, },
Show { Show {
id: TicketIdOrSlug, id: TicketIdOrSlug,
@ -934,6 +1466,13 @@ pub enum TicketBackendOperation {
Create { Create {
input: NewTicket, input: NewTicket,
}, },
EditItem {
id: TicketIdOrSlug,
edit: TicketItemEdit,
},
DependencyCheck {
id: TicketIdOrSlug,
},
AddEvent { AddEvent {
id: TicketIdOrSlug, id: TicketIdOrSlug,
event: NewTicketEvent, event: NewTicketEvent,
@ -1002,6 +1541,7 @@ pub enum TicketBackendOperationResult {
Tickets(Vec<TicketSummary>), Tickets(Vec<TicketSummary>),
Ticket(Ticket), Ticket(Ticket),
TicketRef(TicketRef), TicketRef(TicketRef),
DependencyCheck(TicketDependencyCheck),
Relation(TicketRelation), Relation(TicketRelation),
Relations(Vec<TicketRelation>), Relations(Vec<TicketRelation>),
RelationView(TicketRelationView), RelationView(TicketRelationView),
@ -1032,6 +1572,12 @@ where
TicketBackendOperation::Create { input } => { TicketBackendOperation::Create { input } => {
TicketBackendOperationResult::TicketRef(backend.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 } => { TicketBackendOperation::AddEvent { id, event } => {
backend.add_event(id, event)?; backend.add_event(id, event)?;
TicketBackendOperationResult::Unit TicketBackendOperationResult::Unit
@ -1143,10 +1689,10 @@ impl LocalTicketBackend {
} }
} }
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> { pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
let mut output = TicketPartialList::default(); let mut output = TicketPartialList::default();
let mut invalid_seen = BTreeSet::new(); 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"); let item = dir.join("item.md");
if !item.exists() { if !item.exists() {
continue; continue;
@ -1155,10 +1701,7 @@ impl LocalTicketBackend {
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
{ {
Ok(meta) => { Ok(meta) => {
if filter if !filter.matches_state(meta.workflow_state) {
.state
.is_some_and(|state| meta.workflow_state != state)
{
continue; continue;
} }
output.tickets.push(ticket_summary_from_meta(meta)); output.tickets.push(ticket_summary_from_meta(meta));
@ -1255,7 +1798,7 @@ impl LocalTicketBackend {
} }
} }
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> { fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if !self.root.exists() { if !self.root.exists() {
return Ok(dirs); return Ok(dirs);
@ -1275,10 +1818,10 @@ impl LocalTicketBackend {
if !item.is_file() { if !item.is_file() {
continue; continue;
} }
if let Some(state) = filter.state { if !matches!(filter.state, TicketStateSelector::All) {
let parsed = read_item_file(&item)?; let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?; let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
if meta.workflow_state != state { if !filter.matches_state(meta.workflow_state) {
continue; continue;
} }
} }
@ -1495,7 +2038,7 @@ impl LocalTicketBackend {
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> { fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new(); 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)?); relations.extend(self.read_ticket_relations_for_dir(&dir)?);
} }
sort_ticket_relations(&mut relations); sort_ticket_relations(&mut relations);
@ -1508,7 +2051,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>, invalid_seen: &mut BTreeSet<String>,
) -> Result<Vec<TicketRelation>> { ) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new(); 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) { match self.read_ticket_relations_for_dir(&dir) {
Ok(records) => relations.extend(records), Ok(records) => relations.extend(records),
Err(error) => { Err(error) => {
@ -1539,7 +2082,7 @@ impl LocalTicketBackend {
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> { fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new(); 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 item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
states.insert(meta.id, meta.workflow_state); states.insert(meta.id, meta.workflow_state);
@ -1553,7 +2096,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>, invalid_seen: &mut BTreeSet<String>,
) -> Result<HashMap<String, TicketWorkflowState>> { ) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new(); 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 item = dir.join("item.md");
match read_item_file(&item) match read_item_file(&item)
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) .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) self.default_intake_ready_state_change_body(from)
} }
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> { fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
let mut tickets = Vec::new(); let mut tickets = Vec::new();
for dir in self.iter_ticket_dirs(filter)? { for dir in self.iter_ticket_dirs(filter)? {
let item = dir.join("item.md"); let item = dir.join("item.md");
@ -1706,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<()> { fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> {
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?; let dir = self.find_ticket_dir(&id)?;
@ -2088,7 +2704,7 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&ticket)?; let dir = self.find_ticket_dir(&ticket)?;
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
} else { } 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)?); 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) { let ticket_id = match ticket_id_from_dir(&dir) {
Ok(id) => id, Ok(id) => id,
Err(err) => { Err(err) => {
@ -3225,6 +3841,32 @@ fn replace_frontmatter_fields(
Ok(out) 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> { fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
let mut out = String::from("<!--"); let mut out = String::from("<!--");
for (key, value) in attrs { for (key, value) in attrs {
@ -3859,6 +4501,108 @@ mod tests {
LocalTicketBackend::new(dir.path().join("tickets")) LocalTicketBackend::new(dir.path().join("tickets"))
} }
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary {
id: "000TEST".to_string(),
slug: "000TEST".to_string(),
title: "Test Ticket".to_string(),
status: ExtensibleTicketStatus::Open,
kind: "ticket".to_string(),
priority: "P2".to_string(),
labels: Vec::new(),
readiness: None,
workflow_state: state,
workflow_state_explicit: true,
queued_by: None,
queued_at: None,
updated_at: Some("2026-07-20T00:00:00Z".to_string()),
}
}
fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker {
TicketRelationBlocker {
blocking_ticket: "000BLOCK".to_string(),
reason_kind: "depends_on".to_string(),
relation_kind: TicketRelationKind::DependsOn,
note: None,
blocking_state: state,
}
}
#[test]
fn workspace_projection_queues_ready_ticket_for_orchestrator() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let projection = project_ticket_workspace_item(&summary, &[], None);
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
assert_eq!(
projection.priority,
TicketWorkspaceActionPriority::ReadyForQueue
);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.disabled_reason.is_none());
}
#[test]
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
);
assert!(!projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_some());
assert!(projection.disabled_reason.is_some());
}
#[test]
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_none());
assert!(
projection
.key_hint
.as_deref()
.unwrap_or_default()
.contains("Orchestrator will preserve order")
);
}
#[test]
fn workspace_projection_overlay_suppresses_duplicate_queue() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let overlay = TicketWorkspaceStateOverlay {
source: "orchestration".to_string(),
workflow_state: TicketWorkflowState::InProgress,
};
let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay));
assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
);
assert_eq!(projection.visible_state, "ready→prog");
assert!(!projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.visible_overlay.is_some());
}
#[test] #[test]
fn workflow_state_rejects_legacy_intake_alias() { fn workflow_state_rejects_legacy_intake_alias() {
assert_eq!( assert_eq!(
@ -3975,6 +4719,57 @@ state: planning
assert!(err.contains("invalid YAML frontmatter"), "{err}"); 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::<Vec<_>>();
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::<Vec<_>>();
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(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([
TicketListState::Planning,
TicketListState::Closed,
]))
.unwrap();
let explicit_ids = planning_or_closed
.iter()
.map(|ticket| ticket.id.as_str())
.collect::<Vec<_>>();
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] #[test]
fn create_writes_local_ticket_layout() { fn create_writes_local_ticket_layout() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@ -4036,9 +4831,9 @@ state: planning
) )
.unwrap(); .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.len(), 1);
assert_eq!(partial.tickets[0].id, valid.id); assert_eq!(partial.tickets[0].id, valid.id);
assert_eq!(partial.invalid_records.len(), 1); assert_eq!(partial.invalid_records.len(), 1);

View File

@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview, TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; 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 DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500; const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [ pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
"TicketCreate", "TicketCreate",
"TicketEditItem",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketDoctor", "TicketDoctor",
]; ];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] = pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
["TicketList", "TicketShow", "TicketDoctor"]; "TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
];
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
"TicketRelationRecord", "TicketRelationRecord",
@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; ["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 13] = [ pub const TICKET_TOOL_NAMES: [&str; 16] = [
"TicketCreate", "TicketCreate",
"TicketEditItem",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord", "TicketRelationRecord",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
"TicketDoctor",
]; ];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
"TicketDoctor",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
"TicketCreate", "TicketCreate",
"TicketEditItem",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@ -96,9 +110,12 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ 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 \ 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."; 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 \ 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`, \ lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \ state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
routing, closing, planning, or implementation decisions."; routing, closing, planning, or implementation decisions.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ 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 \ typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
@ -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 \ 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 \ Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`."; 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 \ const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \ 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."; explicit state decisions.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \ const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics through the typed backend without shelling out to external commands."; 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 { fn base_tool_description(name: &str) -> &'static str {
match name { match name {
"TicketCreate" => CREATE_DESCRIPTION, "TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION, "TicketList" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION, "TicketShow" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION, "TicketComment" => COMMENT_DESCRIPTION,
"TicketReview" => REVIEW_DESCRIPTION, "TicketReview" => REVIEW_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
"TicketClose" => CLOSE_DESCRIPTION, "TicketClose" => CLOSE_DESCRIPTION,
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION, "TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION, "TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
"TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION,
"TicketDoctor" => DOCTOR_DESCRIPTION, "TicketDoctor" => DOCTOR_DESCRIPTION,
_ => "Ticket backend tool.", _ => "Ticket backend tool.",
} }
@ -214,7 +239,7 @@ impl TicketBackend for TicketToolBackend {
self.backend.default_intake_ready_state_change_body(from) self.backend.default_intake_ready_state_change_body(from)
} }
fn list(&self, filter: crate::TicketFilter) -> TicketResult<Vec<TicketSummary>> { fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
self.backend.list(filter) self.backend.list(filter)
} }
@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend {
self.backend.create(input) 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<()> { fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
self.backend.add_event(id, event) self.backend.add_event(id, event)
} }
@ -351,6 +384,21 @@ struct TicketCreateParams {
queued_at: Option<String>, 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)] #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum TicketWorkflowStateParam { enum TicketWorkflowStateParam {
@ -373,11 +421,23 @@ impl TicketWorkflowStateParam {
Self::Closed => TicketWorkflowState::Closed, 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, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum TicketListStateParam { enum TicketListStateParam {
Active,
Planning, Planning,
Ready, Ready,
Queued, Queued,
@ -388,47 +448,62 @@ enum TicketListStateParam {
} }
impl TicketListStateParam { impl TicketListStateParam {
fn as_filter(self) -> (crate::TicketFilter, &'static str) { fn as_list_state(self) -> Option<TicketListState> {
match self { match self {
Self::Planning => ( Self::Planning => Some(TicketListState::Planning),
crate::TicketFilter::state(TicketWorkflowState::Planning), Self::Ready => Some(TicketListState::Ready),
"planning", Self::Queued => Some(TicketListState::Queued),
), Self::Inprogress => Some(TicketListState::InProgress),
Self::Ready => ( Self::Done => Some(TicketListState::Done),
crate::TicketFilter::state(TicketWorkflowState::Ready), Self::Closed => Some(TicketListState::Closed),
"ready", Self::Active | Self::All => None,
),
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"),
} }
} }
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams { 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)] #[serde(default)]
state: Option<TicketListStateParam>, state: Option<TicketListStateParam>,
/// Explicit workflow-state filter list. Cannot be combined with `state`.
#[serde(default)]
states: Option<Vec<TicketWorkflowStateParam>>,
/// Maximum number of summaries to return. Defaults to 50, max 100. /// Maximum number of summaries to return. Defaults to 50, max 100.
#[serde(default)] #[serde(default)]
limit: Option<usize>, limit: Option<usize>,
} }
impl TicketListParams {
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), 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_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_list_state()
.expect("workflow state list param maps to TicketListState"),
),
}
};
let label = query.state_filter_label();
Ok((query, label, self.limit))
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams { struct TicketShowParams {
/// Ticket id. Exactly one of `id` or `query` must be provided. /// Ticket id. Exactly one of `id` or `query` must be provided.
@ -507,6 +582,15 @@ struct TicketIntakeReadyParams {
state_change_body: Option<String>, 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)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams { struct TicketWorkflowStateParams {
/// Ticket id. /// Ticket id.
@ -532,6 +616,12 @@ struct TicketCloseParams {
resolution: String, resolution: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams {
/// Ticket id.
ticket: String,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum TicketRelationKindParam { enum TicketRelationKindParam {
@ -723,6 +813,11 @@ struct TicketCreateTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketEditItemTool {
backend: TicketToolBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketListTool { struct TicketListTool {
backend: TicketToolBackend, backend: TicketToolBackend,
@ -748,6 +843,11 @@ struct TicketIntakeReadyTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketQueueTool {
backend: TicketToolBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketWorkflowStateTool { struct TicketWorkflowStateTool {
backend: TicketToolBackend, backend: TicketToolBackend,
@ -783,6 +883,11 @@ struct TicketDoctorTool {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
#[derive(Clone)]
struct TicketDependencyCheckTool {
backend: TicketToolBackend,
}
#[async_trait] #[async_trait]
impl Tool for TicketCreateTool { impl Tool for TicketCreateTool {
async fn execute( async fn execute(
@ -817,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] #[async_trait]
impl Tool for TicketListTool { impl Tool for TicketListTool {
async fn execute( async fn execute(
@ -825,9 +959,10 @@ impl Tool for TicketListTool {
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?; let params: TicketListParams = parse_input("TicketList", input_json)?;
let state = params.state.unwrap_or(TicketListStateParam::All); let (filter, state_filter, params_limit) = params
let (filter, state_filter) = state.as_filter(); .into_query()
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); .map_err(|error| backend_error("TicketList", error))?;
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self let tickets = self
.backend .backend
.list(filter) .list(filter)
@ -987,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] #[async_trait]
impl Tool for TicketWorkflowStateTool { impl Tool for TicketWorkflowStateTool {
async fn execute( async fn execute(
@ -1216,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> { fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
serde_json::from_str(input_json) serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@ -1481,15 +1662,20 @@ where
fn input_schema(name: &str) -> Value { fn input_schema(name: &str) -> Value {
match name { match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)), "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)), "TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)), "TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)), "TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)), "TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => { "TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
} }
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketDependencyCheck" => {
serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams))
}
"TicketRelationRecord" => { "TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams)) serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
} }
@ -1519,11 +1705,13 @@ macro_rules! impl_from_backend {
} }
impl_from_backend!(TicketCreateTool); impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool); impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool); impl_from_backend!(TicketShowTool);
impl_from_backend!(TicketCommentTool); impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool); impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool); impl_from_backend!(TicketRelationRecordTool);
@ -1531,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool); impl_from_backend!(TicketDoctorTool);
impl_from_backend!(TicketDependencyCheckTool);
/// Build all MVP Ticket tool definitions over the supplied backend. /// Build all MVP Ticket tool definitions over the supplied backend.
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> { pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
let backend = backend.into(); let backend = backend.into();
vec![ vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()), tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()), tool_definition::<TicketListTool>("TicketList", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()), tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()), tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()), tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()), tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()), tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", 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::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()), tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>( tool_definition::<TicketOrchestrationPlanRecordTool>(
@ -1554,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
backend.clone(), backend.clone(),
), ),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
] ]
} }
@ -1599,18 +1791,21 @@ mod tests {
[ [
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery", "TicketRelationQuery",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery"
"TicketDoctor"
] ]
); );
assert_eq!( assert_eq!(
TICKET_MUTATING_TOOL_NAMES, TICKET_MUTATING_TOOL_NAMES,
[ [
"TicketCreate", "TicketCreate",
"TicketEditItem",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord", "TicketRelationRecord",
@ -1825,6 +2020,16 @@ mod tests {
.unwrap(); .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 let all = list
.execute(&json!({ "state": "all" }).to_string(), Default::default()) .execute(&json!({ "state": "all" }).to_string(), Default::default())
.await .await
@ -1857,6 +2062,53 @@ mod tests {
assert_eq!(closed_json["truncated"].as_bool(), Some(true)); 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::<Vec<_>>();
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] #[tokio::test]
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -2408,7 +2660,10 @@ mod tests {
assert!(!id.contains("escape")); assert!(!id.contains("escape"));
assert!(!temp.path().join("escape").exists()); assert!(!temp.path().join("escape").exists());
assert!(temp.path().join("tickets").join(id).is_dir()); 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] #[test]

View File

@ -238,10 +238,8 @@ enabled = false
[feature.ticket] [feature.ticket]
enabled = true enabled = true
access = "lifecycle" authoring = true
thread = true
[feature.ticket_orchestration]
enabled = false
[memory] [memory]
extract_threshold = 50000 extract_threshold = 50000
@ -298,8 +296,9 @@ mod tests {
assert!(profile.contains("slug = \"default\"")); assert!(profile.contains("slug = \"default\""));
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\"")); assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
assert!(profile.contains("scope = \"workspace_write\"")); assert!(profile.contains("scope = \"workspace_write\""));
assert!(profile.contains("[feature.ticket]\nenabled = true\naccess = \"lifecycle\"")); assert!(
assert!(profile.contains("[feature.ticket_orchestration]\nenabled = false")); profile.contains("[feature.ticket]\nenabled = true\nauthoring = true\nthread = true")
);
} }
#[test] #[test]

View File

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

View File

@ -4,7 +4,6 @@ use std::sync::atomic::Ordering;
use llm_engine::EngineError; use llm_engine::EngineError;
use llm_engine::llm_client::client::LlmClient; use llm_engine::llm_client::client::LlmClient;
use manifest::TicketFeatureAccessConfig;
use session_store::WorkerMetadataStore; use session_store::WorkerMetadataStore;
use session_store::{LogEntry, Store}; use session_store::{LogEntry, Store};
use ticket::LocalTicketBackend; use ticket::LocalTicketBackend;
@ -547,9 +546,7 @@ fn install_ticket_event_companion_notify_hook<C, St>(
} }
let ticket_feature = &worker.manifest().feature.ticket; let ticket_feature = &worker.manifest().feature.ticket;
if !ticket_feature.enabled if !ticket_feature.enabled || !ticket_feature.orchestration_control {
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
{
return; return;
} }
@ -674,14 +671,12 @@ where
if feature_config.task.enabled { if feature_config.task.enabled {
feature_registry.add_module(task_feature); feature_registry.add_module(task_feature);
} }
if feature_config.ticket.enabled || feature_config.ticket_orchestration.enabled { if feature_config.ticket.enabled {
let ticket_access = match feature_config.ticket.access { let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess {
TicketFeatureAccessConfig::ReadOnly => { authoring: feature_config.ticket.authoring,
crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly thread: feature_config.ticket.thread,
} intake: feature_config.ticket.intake,
TicketFeatureAccessConfig::Lifecycle => { orchestration_control: feature_config.ticket.orchestration_control,
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
}
}; };
// Ticket tools are typed operations over the current workspace Ticket backend. // Ticket tools are typed operations over the current workspace Ticket backend.
// Runtime-hosted Workers prefer the workspace API URI carried by the // Runtime-hosted Workers prefer the workspace API URI carried by the
@ -710,10 +705,9 @@ where
} }
}; };
feature_registry.add_module( feature_registry.add_module(
crate::feature::builtin::ticket::ticket_tools_feature_with_options( crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
ticket_backend, ticket_backend,
feature_config.ticket.enabled.then_some(ticket_access), ticket_access,
feature_config.ticket_orchestration.enabled,
), ),
); );
} }

View File

@ -14,5 +14,5 @@ pub(crate) use session_explore::{
pub use task::{TaskFeature, task_tools_feature}; pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{ pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
ticket_tools_feature_with_options, ticket_tools_feature_with_backend,
}; };

View File

@ -10,16 +10,11 @@ use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation, Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug, TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
TicketReview, TicketStateChange, TicketSummary, TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{ tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
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,
},
}; };
use crate::feature::{ use crate::feature::{
@ -32,29 +27,145 @@ const FEATURE_NAME: &str = "Ticket tools";
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ 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."; The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TicketFeatureAccess { pub struct TicketFeatureAccess {
/// Status/diagnostic access for views such as Companion that must not mutate Tickets. pub authoring: bool,
ReadOnly, pub thread: bool,
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools. pub intake: bool,
Lifecycle, pub orchestration_control: bool,
} }
impl TicketFeatureAccess { impl TicketFeatureAccess {
pub fn base_tool_names(self) -> &'static [&'static str] { pub const fn read_only() -> Self {
match self { Self {
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES, authoring: false,
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES, thread: false,
intake: false,
orchestration_control: false,
} }
} }
pub fn orchestration_tool_names(self) -> &'static [&'static str] { pub const fn workspace_authoring() -> Self {
match self { Self {
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, authoring: true,
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES, 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",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
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",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketQueue",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
];
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
];
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord",
];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum TicketFeatureBackend { pub enum TicketFeatureBackend {
@ -96,94 +207,59 @@ pub struct TicketFeature {
record_language: Option<String>, record_language: Option<String>,
config_error: Option<String>, config_error: Option<String>,
access: TicketFeatureAccess, access: TicketFeatureAccess,
include_base_tools: bool,
include_orchestration_tools: bool,
} }
impl TicketFeature { impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self { pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle) Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
} }
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self { 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( Self::with_backend(
TicketFeatureBackend::Local { TicketFeatureBackend::Local {
root: backend_root.into(), root: backend_root.into(),
}, },
access, access,
include_orchestration_tools,
) )
} }
pub fn with_backend( pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
backend: TicketFeatureBackend,
access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool,
) -> Self {
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend { if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
return Self::for_workspace_with_options( return Self::for_workspace_with_access(workspace_root, access);
workspace_root,
access,
include_orchestration_tools,
);
} }
Self { Self {
backend, backend,
record_language: None, record_language: None,
config_error: None, config_error: None,
access: access.unwrap_or(TicketFeatureAccess::Lifecycle), access,
include_base_tools: access.is_some(),
include_orchestration_tools,
} }
} }
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self { pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle) Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
} }
pub fn for_workspace_with_access( pub fn for_workspace_with_access(
workspace: impl AsRef<Path>, workspace: impl AsRef<Path>,
access: TicketFeatureAccess, 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 { ) -> Self {
let workspace = workspace.as_ref(); let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) { match TicketConfig::load_workspace(workspace) {
Ok(config) => { Ok(config) => {
let backend_root = config.backend_root().to_path_buf(); let backend_root = config.backend_root().to_path_buf();
let record_language = config.ticket_record_language().map(str::to_string); let record_language = config.ticket_record_language().map(str::to_string);
let mut feature = let mut feature = Self::new_with_access(backend_root, access);
Self::new_with_options(backend_root, access, include_orchestration_tools);
feature.record_language = record_language; feature.record_language = record_language;
feature feature
} }
Err(error) => { Err(error) => Self {
let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle);
Self {
backend: TicketFeatureBackend::Local { backend: TicketFeatureBackend::Local {
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
}, },
record_language: None, record_language: None,
config_error: Some(error.to_string()), config_error: Some(error.to_string()),
access: access_value, access,
include_base_tools: access.is_some(), },
include_orchestration_tools,
}
}
} }
} }
@ -200,20 +276,7 @@ impl TicketFeature {
} }
fn enabled_tool_names(&self) -> Vec<&'static str> { fn enabled_tool_names(&self) -> Vec<&'static str> {
if self.include_base_tools && self.include_orchestration_tools { self.access.tool_names()
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());
}
if self.include_orchestration_tools {
names.extend_from_slice(self.access.orchestration_tool_names());
}
names
} }
fn usable_backend_root(&self) -> Result<PathBuf, String> { fn usable_backend_root(&self) -> Result<PathBuf, String> {
@ -271,9 +334,9 @@ impl FeatureModule for TicketFeature {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION); .with_description(FEATURE_DESCRIPTION);
let enabled_tool_names = self.enabled_tool_names(); 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( descriptor = descriptor.with_tool(ToolDeclaration::new(
*name, name,
ticket_tool_description(name, self.record_language.as_deref()), ticket_tool_description(name, self.record_language.as_deref()),
)); ));
} }
@ -400,7 +463,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
} }
} }
fn list(&self, filter: TicketFilter) -> TicketResult<Vec<TicketSummary>> { fn list(&self, filter: TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
expect_ticket_result!( expect_ticket_result!(
self.invoke(TicketBackendOperation::List { filter }), self.invoke(TicketBackendOperation::List { filter }),
TicketBackendOperationResult::Tickets TicketBackendOperationResult::Tickets
@ -421,6 +484,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<()> { fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::AddEvent { id, event })? { match self.invoke(TicketBackendOperation::AddEvent { id, event })? {
TicketBackendOperationResult::Unit => Ok(()), TicketBackendOperationResult::Unit => Ok(()),
@ -601,12 +678,11 @@ pub fn ticket_tools_feature_with_access(
TicketFeature::for_workspace_with_access(workspace, 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>, backend: impl Into<TicketFeatureBackend>,
access: Option<TicketFeatureAccess>, access: TicketFeatureAccess,
include_orchestration_tools: bool,
) -> TicketFeature { ) -> TicketFeature {
TicketFeature::with_backend(backend.into(), access, include_orchestration_tools) TicketFeature::with_backend(backend.into(), access)
} }
#[cfg(test)] #[cfg(test)]
@ -618,10 +694,6 @@ mod tests {
use std::net::TcpListener; use std::net::TcpListener;
use std::thread; use std::thread;
use tempfile::TempDir; 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) { fn make_ticket_root(root: &Path) {
std::fs::create_dir_all(root).unwrap(); std::fs::create_dir_all(root).unwrap();
@ -653,66 +725,114 @@ mod tests {
let descriptor = feature.descriptor(); let descriptor = feature.descriptor();
assert_eq!(descriptor.id.to_string(), "builtin:ticket"); assert_eq!(descriptor.id.to_string(), "builtin:ticket");
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); 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!( assert_eq!(
descriptor descriptor
.tools .tools
.iter() .iter()
.map(|tool| tool.name.as_str()) .map(|tool| tool.name.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
TICKET_TOOL_NAMES WORKSPACE_AUTHORING_TOOL_NAMES
); );
} }
#[test] #[test]
fn read_only_descriptor_declares_only_state_tools() { fn read_only_descriptor_declares_only_state_tools() {
let temp = TempDir::new().unwrap(); 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(); let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly); assert_eq!(feature.access(), TicketFeatureAccess::read_only());
assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!( assert_eq!(
descriptor descriptor
.tools .tools
.iter() .iter()
.map(|tool| tool.name.as_str()) .map(|tool| tool.name.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
TICKET_READ_ONLY_TOOL_NAMES READ_ONLY_TOOL_NAMES
); );
} }
#[test] #[test]
fn descriptor_can_expose_base_ticket_without_orchestration_tools() { fn orchestration_control_descriptor_declares_orchestration_tools() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature_with_options( let feature = ticket_tools_feature_with_access(
temp.path(), temp.path(),
Some(TicketFeatureAccess::Lifecycle), TicketFeatureAccess::orchestration_control(),
false,
); );
let descriptor = feature.descriptor(); let descriptor = feature.descriptor();
assert_eq!(
feature.access(),
TicketFeatureAccess::orchestration_control()
);
assert_eq!( assert_eq!(
descriptor descriptor
.tools .tools
.iter() .iter()
.map(|tool| tool.name.as_str()) .map(|tool| tool.name.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
TICKET_BASE_TOOL_NAMES ORCHESTRATION_CONTROL_TOOL_NAMES
); );
} }
#[test] #[test]
fn descriptor_can_expose_orchestration_only_tools() { fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature_with_options(temp.path(), None, true);
let descriptor = feature.descriptor(); let workspace_authoring = ticket_tools_feature_with_access(
assert_eq!( temp.path(),
descriptor TicketFeatureAccess::workspace_authoring(),
);
let workspace_descriptor = workspace_authoring.descriptor();
let workspace_tools = workspace_descriptor
.tools .tools
.iter() .iter()
.map(|tool| tool.name.as_str()) .map(|tool| tool.name.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>();
TICKET_ORCHESTRATION_TOOL_NAMES 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_access(
temp.path(),
TicketFeatureAccess::orchestration_control(),
); );
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_access(temp.path(), TicketFeatureAccess::work_report());
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_access(temp.path(), TicketFeatureAccess::review());
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] #[test]
@ -724,20 +844,17 @@ mod tests {
let report = FeatureRegistryBuilder::new() let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access( .with_module(ticket_tools_feature_with_access(
temp.path(), temp.path(),
TicketFeatureAccess::ReadOnly, TicketFeatureAccess::read_only(),
)) ))
.install_into_pending(&mut pending_tools, &mut hooks); .install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!( assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
report.reports[0].installed_tools,
TICKET_READ_ONLY_TOOL_NAMES
);
let pending_names = pending_tools let pending_names = pending_tools
.iter() .iter()
.map(|definition| definition().0.name) .map(|definition| definition().0.name)
.collect::<Vec<_>>(); .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 { for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
assert!( assert!(
!report.reports[0] !report.reports[0]
@ -760,7 +877,8 @@ language = "Japanese"
"#, "#,
); );
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); 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 = feature.descriptor();
let descriptor_description = descriptor let descriptor_description = descriptor
.tools .tools
@ -777,11 +895,8 @@ language = "Japanese"
.with_module(feature) .with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks); .install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
assert_eq!( assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
report.reports[0].installed_tools,
TICKET_READ_ONLY_TOOL_NAMES
);
let description = pending_tool_description(&pending_tools, "TicketShow"); let description = pending_tool_description(&pending_tools, "TicketShow");
assert!(description.contains("Ticket record language: Japanese")); assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("distinct from worker.language")); assert!(description.contains("distinct from worker.language"));
@ -789,7 +904,7 @@ language = "Japanese"
} }
#[test] #[test]
fn lifecycle_installation_exposes_lifecycle_tools() { fn workspace_authoring_installation_exposes_authoring_tools() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
@ -797,36 +912,31 @@ language = "Japanese"
let report = FeatureRegistryBuilder::new() let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access( .with_module(ticket_tools_feature_with_access(
temp.path(), temp.path(),
TicketFeatureAccess::Lifecycle, TicketFeatureAccess::workspace_authoring(),
)) ))
.install_into_pending(&mut pending_tools, &mut hooks); .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[0].installed_tools, TICKET_TOOL_NAMES); let installed = report.reports[0]
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
assert!(
report.reports[0]
.installed_tools .installed_tools
.iter() .iter()
.any(|tool| tool == name) .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!( assert!(
report.reports[0] !installed
.installed_tools
.iter() .iter()
.any(|tool| tool == "TicketIntakeReady") .any(|tool| *tool == "TicketOrchestrationPlanRecord")
);
assert!(
report.reports[0]
.installed_tools
.iter()
.any(|tool| tool == "TicketWorkflowState")
); );
} }
#[test] #[test]
fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() { fn workspace_authoring_context_exposes_ticket_language_guidance() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
@ -841,12 +951,15 @@ language = "Japanese"
let report = FeatureRegistryBuilder::new() let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access( .with_module(ticket_tools_feature_with_access(
temp.path(), temp.path(),
TicketFeatureAccess::Lifecycle, TicketFeatureAccess::workspace_authoring(),
)) ))
.install_into_pending(&mut pending_tools, &mut hooks); .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[0].installed_tools, TICKET_TOOL_NAMES); assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
let description = pending_tool_description(&pending_tools, "TicketComment"); let description = pending_tool_description(&pending_tools, "TicketComment");
assert!(description.contains("Ticket record language: Japanese")); assert!(description.contains("Ticket record language: Japanese"));
assert!(description.contains("durable Ticket record and Ticket tool body text")); assert!(description.contains("durable Ticket record and Ticket tool body text"));
@ -864,10 +977,13 @@ language = "Japanese"
.with_module(ticket_tools_feature(temp.path())) .with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks); .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_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed); 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()); assert!(report.reports[0].skipped.is_empty());
} }
@ -899,7 +1015,7 @@ profile = "project:coder"
.with_module(feature) .with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks); .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()); assert!(report.reports[0].diagnostics.is_empty());
} }
@ -985,8 +1101,11 @@ provider = "github"
.with_module(ticket_tools_feature(temp.path())) .with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks); .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[0].installed_tools, TICKET_TOOL_NAMES); assert_eq!(
report.reports[0].installed_tools,
WORKSPACE_AUTHORING_TOOL_NAMES
);
assert!(report.reports[0].diagnostics.is_empty()); assert!(report.reports[0].diagnostics.is_empty());
assert!(!root.join("open").exists()); assert!(!root.join("open").exists());
assert!(!root.join("pending").exists()); assert!(!root.join("pending").exists());

View File

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id; use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
use crate::{Error, Result}; use crate::{Error, Result};
@ -35,7 +35,7 @@ impl LocalProjectRecordReader {
} }
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> { pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let partial = self.ticket_backend.list_partial(TicketFilter::all())?; let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
let mut items = partial let mut items = partial
.tickets .tickets
.into_iter() .into_iter()

View File

@ -11,8 +11,9 @@ use ticket::config::{
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState, TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@ -45,15 +46,11 @@ pub struct CreateOptions {
pub title: String, pub title: String,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListState { pub enum ListState {
Planning, Active,
Ready,
Queued,
InProgress,
Done,
Closed,
All, All,
States(Vec<TicketListState>),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -381,13 +378,9 @@ fn list(
options: ListOptions, options: ListOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let filter = match options.state { let filter = match options.state {
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning), ListState::Active => TicketListQuery::active(),
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready), ListState::All => TicketListQuery::all(),
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued), ListState::States(states) => TicketListQuery::states(states),
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
ListState::All => TicketFilter::all(),
}; };
let tickets = backend.list(filter)?; let tickets = backend.list(filter)?;
let count = tickets.len(); let count = tickets.len();
@ -750,7 +743,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
} }
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> { fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
let mut state = ListState::All; let mut state = ListState::Active;
let mut limit = None; let mut limit = None;
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
@ -1042,17 +1035,42 @@ fn option_with_value(
Ok(None) Ok(None)
} }
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> { fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
match value { let tokens = raw
"planning" => Ok(ListState::Planning), .split(',')
"ready" => Ok(ListState::Ready), .map(str::trim)
"queued" => Ok(ListState::Queued), .filter(|token| !token.is_empty())
"inprogress" => Ok(ListState::InProgress), .collect::<Vec<_>>();
"done" => Ok(ListState::Done), if tokens.is_empty() {
"closed" => Ok(ListState::Closed), return Err(TicketCliError::new("--state must not be empty"));
"all" => Ok(ListState::All),
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
} }
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 = TicketListState::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<usize, TicketCliError> { fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
@ -1148,7 +1166,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <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)] #[cfg(test)]
@ -1533,6 +1551,29 @@ mod tests {
assert!(err.to_string().contains("use `yoi ticket close")); 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 {
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 {
TicketCli::Command(TicketCommand::List(options)) => assert_eq!(
options.state,
ListState::States(vec![TicketListState::Planning, TicketListState::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] #[test]
fn ticket_cli_help_lists_required_commands() { fn ticket_cli_help_lists_required_commands() {
let help = parse_ticket_args(&args(&["--help"])).unwrap(); let help = parse_ticket_args(&args(&["--help"])).unwrap();

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = false; }; workers = { enabled = false; };
ticket_orchestration = { enabled = false; }; ticket = { enabled = true; thread = true; };
}; };
} }

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = true; }; workers = { enabled = true; };
ticket_orchestration = { enabled = false; }; ticket = { enabled = true; authoring = true; thread = true; };
}; };
} }

View File

@ -26,8 +26,7 @@ feature = {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = true; }; workers = { enabled = true; };
ticket = { enabled = true; access = "lifecycle"; }; ticket = { enabled = true; authoring = true; thread = true; };
ticket_orchestration = { enabled = false; };
}; };
memory = { memory = {

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = false; }; workers = { enabled = false; };
ticket_orchestration = { enabled = false; }; ticket = { enabled = true; authoring = true; thread = true; intake = true; };
}; };
} }

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = true; }; workers = { enabled = true; };
ticket_orchestration = { enabled = true; }; ticket = { enabled = true; thread = true; orchestration_control = true; };
}; };
} }

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; }; memory = { enabled = true; };
web = { enabled = true; }; web = { enabled = true; };
workers = { enabled = false; }; workers = { enabled = false; };
ticket_orchestration = { enabled = false; }; ticket = { enabled = true; thread = true; };
}; };
} }