Merge branch 'work/ticket-access-capabilities' into develop
This commit is contained in:
commit
2e0cd3d161
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
impl TicketFilter {
|
||||
pub fn all() -> Self {
|
||||
Self { state: None }
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketDependencyCheck {
|
||||
pub ticket: TicketSummary,
|
||||
pub blockers: Vec<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 state(state: TicketWorkflowState) -> Self {
|
||||
Self { state: Some(state) }
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Planning => "planning",
|
||||
Self::Ready => "ready",
|
||||
Self::Queued => "queued",
|
||||
Self::InProgress => "inprogress",
|
||||
Self::Done => "done",
|
||||
Self::Closed => "closed",
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_workflow_state(self, state: TicketWorkflowState) -> bool {
|
||||
match self {
|
||||
Self::Planning => state == TicketWorkflowState::Planning,
|
||||
Self::Ready => state == TicketWorkflowState::Ready,
|
||||
Self::Queued => state == TicketWorkflowState::Queued,
|
||||
Self::InProgress => state == TicketWorkflowState::InProgress,
|
||||
Self::Done => state == TicketWorkflowState::Done,
|
||||
Self::Closed => state == TicketWorkflowState::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketStateSelector {
|
||||
/// All non-closed workflow states: planning, ready, queued, inprogress, and done.
|
||||
Active,
|
||||
/// Every workflow state, including closed.
|
||||
All,
|
||||
/// An explicit set of list-query state tokens.
|
||||
States(BTreeSet<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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::All,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,30 +27,146 @@ 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 {
|
||||
Local {
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user