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

View File

@ -115,8 +115,6 @@ pub struct FeatureConfig {
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
pub ticket_orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
}
@ -128,7 +126,6 @@ impl Default for FeatureConfig {
web: FeatureFlagConfig::disabled(),
workers: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
}
}
@ -156,37 +153,18 @@ impl Default for FeatureFlagConfig {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TicketFeatureConfig {
#[serde(default)]
pub enabled: bool,
/// Which non-orchestration Ticket surface to expose when `enabled = true`.
/// Orchestration-plan/relation tools are controlled independently by
/// `[feature.ticket_orchestration].enabled`.
#[serde(default)]
pub access: TicketFeatureAccessConfig,
}
impl Default for TicketFeatureConfig {
fn default() -> Self {
Self {
enabled: false,
access: TicketFeatureAccessConfig::Lifecycle,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TicketFeatureAccessConfig {
ReadOnly,
Lifecycle,
}
impl Default for TicketFeatureAccessConfig {
fn default() -> Self {
Self::Lifecycle
}
pub authoring: bool,
#[serde(default)]
pub thread: bool,
#[serde(default)]
pub intake: bool,
#[serde(default)]
pub orchestration_control: bool,
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are

View File

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

View File

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

View File

@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@ -33,20 +34,27 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [
pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] =
["TicketList", "TicketShow", "TicketDoctor"];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
];
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
"TicketRelationRecord",
@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 13] = [
pub const TICKET_TOOL_NAMES: [&str; 16] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
@ -96,9 +110,12 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
routing, closing, planning, or implementation decisions.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
@ -111,6 +128,9 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
@ -131,21 +151,26 @@ Ticket id and/or relation kind. This is read-only planning context; Orchestrator
explicit state decisions.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics through the typed backend without shelling out to external commands.";
const DEPENDENCY_CHECK_DESCRIPTION: &str = "Return a structured Ticket dependency / queue readiness \
check through the typed Ticket backend. This read-only guard does not queue or transition the Ticket.";
fn base_tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION,
"TicketReview" => REVIEW_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
"TicketClose" => CLOSE_DESCRIPTION,
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
"TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION,
"TicketDoctor" => DOCTOR_DESCRIPTION,
_ => "Ticket backend tool.",
}
@ -214,7 +239,7 @@ impl TicketBackend for TicketToolBackend {
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)
}
@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend {
self.backend.create(input)
}
fn edit_item(&self, id: TicketIdOrSlug, edit: crate::TicketItemEdit) -> TicketResult<Ticket> {
self.backend.edit_item(id, edit)
}
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketDependencyCheck> {
self.backend.dependency_check(id)
}
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
self.backend.add_event(id, event)
}
@ -351,6 +384,21 @@ struct TicketCreateParams {
queued_at: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketEditItemParams {
/// Ticket id.
ticket: String,
/// Optional replacement title.
#[serde(default)]
title: Option<String>,
/// Optional replacement Markdown body.
#[serde(default)]
body: Option<String>,
/// Optional thread author for the audited item_edit event.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketWorkflowStateParam {
@ -373,11 +421,23 @@ impl TicketWorkflowStateParam {
Self::Closed => TicketWorkflowState::Closed,
}
}
fn into_list_state(self) -> TicketListState {
match self {
Self::Planning => TicketListState::Planning,
Self::Ready => TicketListState::Ready,
Self::Queued => TicketListState::Queued,
Self::Inprogress => TicketListState::InProgress,
Self::Done => TicketListState::Done,
Self::Closed => TicketListState::Closed,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketListStateParam {
Active,
Planning,
Ready,
Queued,
@ -388,47 +448,62 @@ enum TicketListStateParam {
}
impl TicketListStateParam {
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
fn as_list_state(self) -> Option<TicketListState> {
match self {
Self::Planning => (
crate::TicketFilter::state(TicketWorkflowState::Planning),
"planning",
),
Self::Ready => (
crate::TicketFilter::state(TicketWorkflowState::Ready),
"ready",
),
Self::Queued => (
crate::TicketFilter::state(TicketWorkflowState::Queued),
"queued",
),
Self::Inprogress => (
crate::TicketFilter::state(TicketWorkflowState::InProgress),
"inprogress",
),
Self::Done => (
crate::TicketFilter::state(TicketWorkflowState::Done),
"done",
),
Self::Closed => (
crate::TicketFilter::state(TicketWorkflowState::Closed),
"closed",
),
Self::All => (crate::TicketFilter::all(), "all"),
Self::Planning => Some(TicketListState::Planning),
Self::Ready => Some(TicketListState::Ready),
Self::Queued => Some(TicketListState::Queued),
Self::Inprogress => Some(TicketListState::InProgress),
Self::Done => Some(TicketListState::Done),
Self::Closed => Some(TicketListState::Closed),
Self::Active | Self::All => None,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams {
/// State filter. Defaults to all Tickets.
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
#[serde(default)]
state: Option<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.
#[serde(default)]
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)]
struct TicketShowParams {
/// Ticket id. Exactly one of `id` or `query` must be provided.
@ -507,6 +582,15 @@ struct TicketIntakeReadyParams {
state_change_body: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketQueueParams {
/// Ticket id.
ticket: String,
/// Optional queued_by frontmatter value. Defaults to the backend/user default.
#[serde(default)]
queued_by: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams {
/// Ticket id.
@ -532,6 +616,12 @@ struct TicketCloseParams {
resolution: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams {
/// Ticket id.
ticket: String,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketRelationKindParam {
@ -723,6 +813,11 @@ struct TicketCreateTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketEditItemTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketListTool {
backend: TicketToolBackend,
@ -748,6 +843,11 @@ struct TicketIntakeReadyTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketQueueTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketWorkflowStateTool {
backend: TicketToolBackend,
@ -783,6 +883,11 @@ struct TicketDoctorTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketDependencyCheckTool {
backend: TicketToolBackend,
}
#[async_trait]
impl Tool for TicketCreateTool {
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]
impl Tool for TicketListTool {
async fn execute(
@ -825,9 +959,10 @@ impl Tool for TicketListTool {
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?;
let state = params.state.unwrap_or(TicketListStateParam::All);
let (filter, state_filter) = state.as_filter();
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let (filter, state_filter, params_limit) = params
.into_query()
.map_err(|error| backend_error("TicketList", error))?;
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self
.backend
.list(filter)
@ -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]
impl Tool for TicketWorkflowStateTool {
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> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@ -1481,15 +1662,20 @@ where
fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketDependencyCheck" => {
serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams))
}
"TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
}
@ -1519,11 +1705,13 @@ macro_rules! impl_from_backend {
}
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool);
impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool);
@ -1531,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool);
impl_from_backend!(TicketDependencyCheckTool);
/// Build all MVP Ticket tool definitions over the supplied backend.
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
let backend = backend.into();
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
@ -1554,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketOrchestrationPlanQuery",
backend.clone(),
),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
]
}
@ -1599,18 +1791,21 @@ mod tests {
[
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor"
"TicketOrchestrationPlanQuery"
]
);
assert_eq!(
TICKET_MUTATING_TOOL_NAMES,
[
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
@ -1825,6 +2020,16 @@ mod tests {
.unwrap();
}
let active = list
.execute(&json!({}).to_string(), Default::default())
.await
.unwrap();
let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap();
assert_eq!(active_json["state_filter"], "active");
assert_eq!(active_json["count"].as_u64(), Some(3));
assert_eq!(active_json["returned"].as_u64(), Some(3));
assert_eq!(active_json["truncated"].as_bool(), Some(false));
let all = list
.execute(&json!({ "state": "all" }).to_string(), Default::default())
.await
@ -1857,6 +2062,53 @@ mod tests {
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
}
#[tokio::test]
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let list = tool_by_name(backend.clone(), "TicketList");
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
let mut ready_input = NewTicket::new("Ready Ticket");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let mut closed_input = NewTicket::new("Closed Ticket");
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
let closed = backend.create(closed_input).unwrap();
let listed = list
.execute(
&json!({ "states": ["planning", "closed"] }).to_string(),
Default::default(),
)
.await
.unwrap();
let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap();
assert_eq!(listed_json["state_filter"], "planning,closed");
assert_eq!(listed_json["count"].as_u64(), Some(2));
let listed_ids = listed_json["tickets"]
.as_array()
.unwrap()
.iter()
.map(|ticket| ticket["id"].as_str().unwrap())
.collect::<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]
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap();
@ -2408,7 +2660,10 @@ mod tests {
assert!(!id.contains("escape"));
assert!(!temp.path().join("escape").exists());
assert!(temp.path().join("tickets").join(id).is_dir());
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
assert_eq!(
backend.list(crate::TicketListQuery::all()).unwrap().len(),
1
);
}
#[test]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; };
web = { 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; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = true; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
ticket = { enabled = true; authoring = true; thread = true; };
};
memory = {

View File

@ -8,6 +8,6 @@ import "./default.dcdl" // {
memory = { enabled = true; };
web = { enabled = true; };
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; };
web = { 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; };
web = { enabled = true; };
workers = { enabled = false; };
ticket_orchestration = { enabled = false; };
ticket = { enabled = true; thread = true; };
};
}