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::{
|
use crate::{
|
||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
|
||||||
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
WebConfig, WorkerManifest, WorkerMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
|
|
@ -87,8 +87,6 @@ pub struct FeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,11 +98,6 @@ impl FeatureConfigPartial {
|
||||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||||
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
|
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
|
||||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||||
ticket_orchestration: merge_option(
|
|
||||||
self.ticket_orchestration,
|
|
||||||
other.ticket_orchestration,
|
|
||||||
FeatureFlagConfigPartial::merge,
|
|
||||||
),
|
|
||||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,19 +117,24 @@ impl FeatureFlagConfigPartial {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct TicketFeatureConfigPartial {
|
pub struct TicketFeatureConfigPartial {
|
||||||
#[serde(default)]
|
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
#[serde(default)]
|
pub authoring: Option<bool>,
|
||||||
pub access: Option<TicketFeatureAccessConfig>,
|
pub thread: Option<bool>,
|
||||||
|
pub intake: Option<bool>,
|
||||||
|
pub orchestration_control: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeatureConfigPartial {
|
impl TicketFeatureConfigPartial {
|
||||||
fn merge(self, other: Self) -> Self {
|
fn merge(self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: other.enabled.or(self.enabled),
|
enabled: other.enabled.or(self.enabled),
|
||||||
access: other.access.or(self.access),
|
authoring: other.authoring.or(self.authoring),
|
||||||
|
thread: other.thread.or(self.thread),
|
||||||
|
intake: other.intake.or(self.intake),
|
||||||
|
orchestration_control: other.orchestration_control.or(self.orchestration_control),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,10 +156,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
ticket_orchestration: value
|
|
||||||
.ticket_orchestration
|
|
||||||
.map(FeatureFlagConfig::from)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
plugins: value
|
plugins: value
|
||||||
.plugins
|
.plugins
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
|
|
@ -190,7 +184,10 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: value.enabled.unwrap_or_default(),
|
enabled: value.enabled.unwrap_or_default(),
|
||||||
access: value.access.unwrap_or_default(),
|
authoring: value.authoring.unwrap_or_default(),
|
||||||
|
thread: value.thread.unwrap_or_default(),
|
||||||
|
intake: value.intake.unwrap_or_default(),
|
||||||
|
orchestration_control: value.orchestration_control.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +196,10 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
||||||
fn from(value: TicketFeatureConfig) -> Self {
|
fn from(value: TicketFeatureConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: Some(value.enabled),
|
enabled: Some(value.enabled),
|
||||||
access: Some(value.access),
|
authoring: Some(value.authoring),
|
||||||
|
thread: Some(value.thread),
|
||||||
|
intake: Some(value.intake),
|
||||||
|
orchestration_control: Some(value.orchestration_control),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +212,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||||
web: Some(value.web.into()),
|
web: Some(value.web.into()),
|
||||||
workers: Some(value.workers.into()),
|
workers: Some(value.workers.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
|
||||||
plugins: Some(value.plugins.into()),
|
plugins: Some(value.plugins.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1758,7 +1757,6 @@ worker_max_turns = 7
|
||||||
assert!(!manifest.feature.web.enabled);
|
assert!(!manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.workers.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
assert!(!manifest.feature.ticket.enabled);
|
assert!(!manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.ticket_orchestration.enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1770,10 +1768,10 @@ enabled = true
|
||||||
|
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
enabled = true
|
enabled = true
|
||||||
access = "read_only"
|
authoring = false
|
||||||
|
thread = false
|
||||||
[feature.ticket_orchestration]
|
intake = false
|
||||||
enabled = true
|
orchestration_control = false
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -1803,11 +1801,10 @@ enabled = true
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(manifest.feature.task.enabled);
|
assert!(manifest.feature.task.enabled);
|
||||||
assert!(manifest.feature.ticket.enabled);
|
assert!(manifest.feature.ticket.enabled);
|
||||||
assert_eq!(
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
manifest.feature.ticket.access,
|
assert!(!manifest.feature.ticket.thread);
|
||||||
TicketFeatureAccessConfig::ReadOnly
|
assert!(!manifest.feature.ticket.intake);
|
||||||
);
|
assert!(!manifest.feature.ticket.orchestration_control);
|
||||||
assert!(manifest.feature.ticket_orchestration.enabled);
|
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1820,14 +1817,18 @@ enabled = true
|
||||||
|
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
enabled = true
|
enabled = true
|
||||||
access = "read_only"
|
authoring = false
|
||||||
|
thread = false
|
||||||
|
intake = false
|
||||||
|
orchestration_control = false
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let upper = WorkerManifestConfig::from_toml(
|
let upper = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
access = "lifecycle"
|
thread = true
|
||||||
|
orchestration_control = true
|
||||||
|
|
||||||
[feature.web]
|
[feature.web]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
@ -1861,10 +1862,10 @@ enabled = true
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(manifest.feature.memory.enabled);
|
assert!(manifest.feature.memory.enabled);
|
||||||
assert!(manifest.feature.ticket.enabled);
|
assert!(manifest.feature.ticket.enabled);
|
||||||
assert_eq!(
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
manifest.feature.ticket.access,
|
assert!(manifest.feature.ticket.thread);
|
||||||
TicketFeatureAccessConfig::Lifecycle
|
assert!(!manifest.feature.ticket.intake);
|
||||||
);
|
assert!(manifest.feature.ticket.orchestration_control);
|
||||||
assert!(manifest.feature.web.enabled);
|
assert!(manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.workers.enabled);
|
assert!(!manifest.feature.workers.enabled);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,6 @@ pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket_orchestration: FeatureFlagConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: FeatureFlagConfig,
|
pub plugins: FeatureFlagConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +126,6 @@ impl Default for FeatureConfig {
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
workers: FeatureFlagConfig::disabled(),
|
workers: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
ticket_orchestration: FeatureFlagConfig::disabled(),
|
|
||||||
plugins: FeatureFlagConfig::disabled(),
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,37 +153,18 @@ impl Default for FeatureFlagConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
pub struct TicketFeatureConfig {
|
pub struct TicketFeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Which non-orchestration Ticket surface to expose when `enabled = true`.
|
|
||||||
/// Orchestration-plan/relation tools are controlled independently by
|
|
||||||
/// `[feature.ticket_orchestration].enabled`.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub access: TicketFeatureAccessConfig,
|
pub authoring: bool,
|
||||||
}
|
#[serde(default)]
|
||||||
|
pub thread: bool,
|
||||||
impl Default for TicketFeatureConfig {
|
#[serde(default)]
|
||||||
fn default() -> Self {
|
pub intake: bool,
|
||||||
Self {
|
#[serde(default)]
|
||||||
enabled: false,
|
pub orchestration_control: bool,
|
||||||
access: TicketFeatureAccessConfig::Lifecycle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TicketFeatureAccessConfig {
|
|
||||||
ReadOnly,
|
|
||||||
Lifecycle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TicketFeatureAccessConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Lifecycle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||||
|
|
|
||||||
|
|
@ -894,7 +894,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -908,7 +907,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -922,7 +920,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -936,7 +933,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -950,7 +946,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
@ -976,8 +971,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value {
|
||||||
"memory": { "enabled": true },
|
"memory": { "enabled": true },
|
||||||
"web": { "enabled": true },
|
"web": { "enabled": true },
|
||||||
"workers": { "enabled": true },
|
"workers": { "enabled": true },
|
||||||
"ticket": { "enabled": true, "access": "lifecycle" },
|
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
||||||
"ticket_orchestration": { "enabled": false }
|
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
"extract_threshold": 50000,
|
"extract_threshold": 50000,
|
||||||
|
|
@ -1004,7 +998,6 @@ fn apply_role_profile(
|
||||||
memory: bool,
|
memory: bool,
|
||||||
web: bool,
|
web: bool,
|
||||||
workers: bool,
|
workers: bool,
|
||||||
ticket_orchestration: bool,
|
|
||||||
) {
|
) {
|
||||||
value["slug"] = serde_json::Value::String(slug.to_string());
|
value["slug"] = serde_json::Value::String(slug.to_string());
|
||||||
value["description"] = serde_json::Value::String(description.to_string());
|
value["description"] = serde_json::Value::String(description.to_string());
|
||||||
|
|
@ -1012,8 +1005,19 @@ fn apply_role_profile(
|
||||||
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
|
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
|
||||||
value["feature"]["web"] = serde_json::json!({ "enabled": web });
|
value["feature"]["web"] = serde_json::json!({ "enabled": web });
|
||||||
value["feature"]["workers"] = serde_json::json!({ "enabled": workers });
|
value["feature"]["workers"] = serde_json::json!({ "enabled": workers });
|
||||||
value["feature"]["ticket_orchestration"] =
|
let ticket = match slug {
|
||||||
serde_json::json!({ "enabled": ticket_orchestration });
|
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||||
|
"intake" => {
|
||||||
|
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||||
|
}
|
||||||
|
"orchestrator" => {
|
||||||
|
serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true })
|
||||||
|
}
|
||||||
|
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
|
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
|
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||||
|
};
|
||||||
|
value["feature"]["ticket"] = ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
||||||
|
|
@ -1439,7 +1443,10 @@ mod tests {
|
||||||
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(companion.web.is_some());
|
assert!(companion.web.is_some());
|
||||||
assert!(companion.feature.ticket.enabled);
|
assert!(companion.feature.ticket.enabled);
|
||||||
assert!(!companion.feature.ticket_orchestration.enabled);
|
assert!(companion.feature.ticket.authoring);
|
||||||
|
assert!(companion.feature.ticket.thread);
|
||||||
|
assert!(!companion.feature.ticket.intake);
|
||||||
|
assert!(!companion.feature.ticket.orchestration_control);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
companion.compaction.as_ref().unwrap().threshold,
|
companion.compaction.as_ref().unwrap().threshold,
|
||||||
Some(240000)
|
Some(240000)
|
||||||
|
|
@ -1461,18 +1468,26 @@ mod tests {
|
||||||
assert!(intake.feature.task.enabled);
|
assert!(intake.feature.task.enabled);
|
||||||
assert!(!intake.feature.workers.enabled);
|
assert!(!intake.feature.workers.enabled);
|
||||||
assert!(intake.feature.ticket.enabled);
|
assert!(intake.feature.ticket.enabled);
|
||||||
|
assert!(intake.feature.ticket.enabled);
|
||||||
|
assert!(intake.feature.ticket.authoring);
|
||||||
|
assert!(intake.feature.ticket.thread);
|
||||||
|
assert!(intake.feature.ticket.intake);
|
||||||
|
assert!(!intake.feature.ticket.orchestration_control);
|
||||||
assert!(intake.scope.allow.is_empty());
|
assert!(intake.scope.allow.is_empty());
|
||||||
assert!(intake.delegation_scope.allow.is_empty());
|
assert!(intake.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(intake.web.is_some());
|
assert!(intake.web.is_some());
|
||||||
assert!(intake.compaction.is_some());
|
assert!(intake.compaction.is_some());
|
||||||
assert!(!intake.feature.ticket_orchestration.enabled);
|
|
||||||
|
|
||||||
let orchestrator = resolve("orchestrator");
|
let orchestrator = resolve("orchestrator");
|
||||||
assert!(orchestrator.feature.task.enabled);
|
assert!(orchestrator.feature.task.enabled);
|
||||||
assert!(orchestrator.feature.workers.enabled);
|
assert!(orchestrator.feature.workers.enabled);
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
|
assert!(!orchestrator.feature.ticket.authoring);
|
||||||
|
assert!(orchestrator.feature.ticket.thread);
|
||||||
|
assert!(!orchestrator.feature.ticket.intake);
|
||||||
|
assert!(orchestrator.feature.ticket.orchestration_control);
|
||||||
assert!(orchestrator.scope.allow.is_empty());
|
assert!(orchestrator.scope.allow.is_empty());
|
||||||
assert!(orchestrator.delegation_scope.allow.is_empty());
|
assert!(orchestrator.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1491,13 +1506,20 @@ mod tests {
|
||||||
assert!(coder.web.is_some());
|
assert!(coder.web.is_some());
|
||||||
assert!(coder.compaction.is_some());
|
assert!(coder.compaction.is_some());
|
||||||
assert!(coder.feature.ticket.enabled);
|
assert!(coder.feature.ticket.enabled);
|
||||||
assert!(!coder.feature.ticket_orchestration.enabled);
|
assert!(coder.feature.ticket.enabled);
|
||||||
|
assert!(!coder.feature.ticket.authoring);
|
||||||
|
assert!(coder.feature.ticket.thread);
|
||||||
|
assert!(!coder.feature.ticket.intake);
|
||||||
|
assert!(!coder.feature.ticket.orchestration_control);
|
||||||
let reviewer = resolve("reviewer");
|
let reviewer = resolve("reviewer");
|
||||||
assert!(reviewer.feature.task.enabled);
|
assert!(reviewer.feature.task.enabled);
|
||||||
assert!(!reviewer.feature.workers.enabled);
|
assert!(!reviewer.feature.workers.enabled);
|
||||||
assert!(reviewer.feature.ticket.enabled);
|
assert!(reviewer.feature.ticket.enabled);
|
||||||
assert!(!reviewer.feature.ticket_orchestration.enabled);
|
assert!(reviewer.feature.ticket.enabled);
|
||||||
|
assert!(!reviewer.feature.ticket.authoring);
|
||||||
|
assert!(reviewer.feature.ticket.thread);
|
||||||
|
assert!(!reviewer.feature.ticket.intake);
|
||||||
|
assert!(!reviewer.feature.ticket.orchestration_control);
|
||||||
assert!(reviewer.scope.allow.is_empty());
|
assert!(reviewer.scope.allow.is_empty());
|
||||||
assert!(reviewer.delegation_scope.allow.is_empty());
|
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
|
|
@ -1646,10 +1668,10 @@ enabled = true
|
||||||
|
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
enabled = true
|
enabled = true
|
||||||
access = "read_only"
|
authoring = false
|
||||||
|
thread = false
|
||||||
[feature.ticket_orchestration]
|
intake = false
|
||||||
enabled = false
|
orchestration_control = false
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let workspace = tmp.path().join("workspace");
|
let workspace = tmp.path().join("workspace");
|
||||||
|
|
@ -1667,11 +1689,10 @@ enabled = false
|
||||||
assert!(resolved.manifest.feature.web.enabled);
|
assert!(resolved.manifest.feature.web.enabled);
|
||||||
assert!(resolved.manifest.feature.workers.enabled);
|
assert!(resolved.manifest.feature.workers.enabled);
|
||||||
assert!(resolved.manifest.feature.ticket.enabled);
|
assert!(resolved.manifest.feature.ticket.enabled);
|
||||||
assert_eq!(
|
assert!(!resolved.manifest.feature.ticket.authoring);
|
||||||
resolved.manifest.feature.ticket.access,
|
assert!(!resolved.manifest.feature.ticket.thread);
|
||||||
crate::TicketFeatureAccessConfig::ReadOnly
|
assert!(!resolved.manifest.feature.ticket.intake);
|
||||||
);
|
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||||
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.delegation_scope.allow[0].target,
|
resolved.manifest.delegation_scope.allow[0].target,
|
||||||
workspace
|
workspace
|
||||||
|
|
@ -1754,15 +1775,11 @@ worker_context_max_tokens = 68000
|
||||||
resolved.manifest.model.ref_.as_deref(),
|
resolved.manifest.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.5")
|
Some("codex-oauth/gpt-5.5")
|
||||||
);
|
);
|
||||||
assert!(resolved.manifest.scope.allow.is_empty());
|
|
||||||
assert!(resolved.manifest.delegation_scope.allow.is_empty());
|
|
||||||
assert!(resolved.manifest.session.record_event_trace);
|
|
||||||
assert!(resolved.manifest.feature.ticket.enabled);
|
assert!(resolved.manifest.feature.ticket.enabled);
|
||||||
assert_eq!(
|
assert!(resolved.manifest.feature.ticket.authoring);
|
||||||
resolved.manifest.feature.ticket.access,
|
assert!(resolved.manifest.feature.ticket.thread);
|
||||||
crate::TicketFeatureAccessConfig::Lifecycle
|
assert!(!resolved.manifest.feature.ticket.intake);
|
||||||
);
|
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||||
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||||
Some("default")
|
Some("default")
|
||||||
|
|
|
||||||
|
|
@ -511,17 +511,138 @@ impl NewTicket {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketFilter {
|
pub struct TicketItemEdit {
|
||||||
pub state: Option<TicketWorkflowState>,
|
pub title: Option<String>,
|
||||||
|
pub body: Option<MarkdownText>,
|
||||||
|
pub author: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFilter {
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub fn all() -> Self {
|
pub struct TicketDependencyCheck {
|
||||||
Self { state: None }
|
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 {
|
pub fn as_str(self) -> &'static str {
|
||||||
Self { state: Some(state) }
|
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>,
|
pub notices: Vec<TicketRelationNotice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceActionPriority {
|
||||||
|
ReadyForQueue,
|
||||||
|
ActiveWork,
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceNextAction {
|
||||||
|
Clarify,
|
||||||
|
QueueForOrchestrator,
|
||||||
|
Close,
|
||||||
|
WaitForOrchestrator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceRowKind {
|
||||||
|
Planning,
|
||||||
|
Ticket,
|
||||||
|
Review,
|
||||||
|
ActiveWork,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketWorkspaceStateOverlay {
|
||||||
|
pub source: String,
|
||||||
|
pub workflow_state: TicketWorkflowState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketQueueGuard {
|
||||||
|
pub can_queue_for_orchestrator: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub blocked_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketWorkspaceProjection {
|
||||||
|
pub kind: TicketWorkspaceRowKind,
|
||||||
|
pub priority: TicketWorkspaceActionPriority,
|
||||||
|
pub next_action: Option<TicketWorkspaceNextAction>,
|
||||||
|
pub visible_state: String,
|
||||||
|
pub visible_overlay: Option<TicketWorkspaceStateOverlay>,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
pub key_hint: Option<String>,
|
||||||
|
pub blocked_reason: Option<String>,
|
||||||
|
pub queue_guard: TicketQueueGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_ticket_workspace_item(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> TicketWorkspaceProjection {
|
||||||
|
let visible_overlay = orchestration_overlay
|
||||||
|
.filter(|overlay| {
|
||||||
|
ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
let mut projection = derive_ticket_workspace_projection(summary, relation_blockers);
|
||||||
|
if let Some(overlay) = visible_overlay.as_ref() {
|
||||||
|
apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay);
|
||||||
|
}
|
||||||
|
projection.visible_state =
|
||||||
|
ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref());
|
||||||
|
projection.visible_overlay = visible_overlay;
|
||||||
|
projection.queue_guard = ticket_queue_guard(
|
||||||
|
summary,
|
||||||
|
relation_blockers,
|
||||||
|
projection.visible_overlay.as_ref(),
|
||||||
|
);
|
||||||
|
projection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ticket_queue_guard(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> TicketQueueGuard {
|
||||||
|
if orchestration_overlay.is_some() {
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(
|
||||||
|
"orchestration overlay already shows progress; duplicate queue is suppressed"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
blocked_reason: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if summary.workflow_state != TicketWorkflowState::Ready {
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(format!(
|
||||||
|
"Ticket state is {}; only ready Tickets can be queued for Orchestrator",
|
||||||
|
summary.workflow_state.as_str()
|
||||||
|
)),
|
||||||
|
blocked_reason: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let active_blockers = relation_blockers
|
||||||
|
.iter()
|
||||||
|
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !active_blockers.is_empty() {
|
||||||
|
let blockers = format_workspace_relation_blockers(&active_blockers);
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(format!("waiting for {blockers}")),
|
||||||
|
blocked_reason: Some(blockers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_ticket_workspace_projection(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
) -> TicketWorkspaceProjection {
|
||||||
|
if !relation_blockers.is_empty() {
|
||||||
|
let active_blockers = relation_blockers
|
||||||
|
.iter()
|
||||||
|
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
||||||
|
let blockers_to_report = if active_blockers.is_empty() {
|
||||||
|
relation_blockers.iter().collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
active_blockers
|
||||||
|
};
|
||||||
|
let blockers = format_workspace_relation_blockers(&blockers_to_report);
|
||||||
|
let waiting_reason = format!("waiting for {blockers}");
|
||||||
|
return TicketWorkspaceProjection {
|
||||||
|
kind: workspace_row_kind_for_state(summary.workflow_state),
|
||||||
|
priority: match summary.workflow_state {
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||||
|
TicketWorkspaceActionPriority::ActiveWork
|
||||||
|
}
|
||||||
|
_ => TicketWorkspaceActionPriority::Background,
|
||||||
|
},
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(format!(
|
||||||
|
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
||||||
|
)),
|
||||||
|
key_hint: Some(format!("Gate: {waiting_reason}")),
|
||||||
|
blocked_reason: Some(blockers),
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(waiting_reason),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockers = format_workspace_relation_blockers(
|
||||||
|
&relation_blockers
|
||||||
|
.iter()
|
||||||
|
.collect::<Vec<&TicketRelationBlocker>>(),
|
||||||
|
);
|
||||||
|
return TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Ticket,
|
||||||
|
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: None,
|
||||||
|
key_hint: Some(format!(
|
||||||
|
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
||||||
|
)),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match summary.workflow_state {
|
||||||
|
TicketWorkflowState::Ready => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Ticket,
|
||||||
|
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: None,
|
||||||
|
key_hint: Some(
|
||||||
|
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
||||||
|
),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Queued => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||||
|
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is already queued for Orchestrator routing".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::InProgress => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||||
|
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is already in progress".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Done => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Review,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::Close),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(
|
||||||
|
"state is done; close if a resolution is still missing.".to_string(),
|
||||||
|
),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is done; close or review instead of queueing".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Planning => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Planning,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::Clarify),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(
|
||||||
|
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
||||||
|
),
|
||||||
|
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is still in planning".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Closed => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Review,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is closed.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is closed".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning,
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||||
|
TicketWorkspaceRowKind::ActiveWork
|
||||||
|
}
|
||||||
|
TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review,
|
||||||
|
TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_workspace_overlay_to_projection(
|
||||||
|
projection: &mut TicketWorkspaceProjection,
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: &TicketWorkspaceStateOverlay,
|
||||||
|
) {
|
||||||
|
projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator);
|
||||||
|
let overlay_state = overlay.workflow_state.as_str();
|
||||||
|
match overlay.workflow_state {
|
||||||
|
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
||||||
|
projection.kind = TicketWorkspaceRowKind::Review;
|
||||||
|
projection.priority = TicketWorkspaceActionPriority::Background;
|
||||||
|
projection.disabled_reason = Some(format!(
|
||||||
|
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
||||||
|
overlay.source,
|
||||||
|
local.as_str()
|
||||||
|
));
|
||||||
|
projection.key_hint = Some(format!(
|
||||||
|
"Merge pending: local: {} · {}: {overlay_state}",
|
||||||
|
local.as_str(),
|
||||||
|
overlay.source
|
||||||
|
));
|
||||||
|
}
|
||||||
|
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
||||||
|
projection.kind = TicketWorkspaceRowKind::ActiveWork;
|
||||||
|
projection.priority = TicketWorkspaceActionPriority::ActiveWork;
|
||||||
|
projection.disabled_reason = Some(format!(
|
||||||
|
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
||||||
|
overlay.source,
|
||||||
|
local.as_str()
|
||||||
|
));
|
||||||
|
projection.key_hint = Some(format!(
|
||||||
|
"Progress overlay: local: {} · {}: {overlay_state}",
|
||||||
|
local.as_str(),
|
||||||
|
overlay.source
|
||||||
|
));
|
||||||
|
}
|
||||||
|
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_workspace_state_display(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> String {
|
||||||
|
match overlay {
|
||||||
|
Some(overlay) => format!(
|
||||||
|
"{}→{}",
|
||||||
|
compact_ticket_state_label(local),
|
||||||
|
compact_ticket_state_label(overlay.workflow_state)
|
||||||
|
),
|
||||||
|
None => local.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_overlay_state_has_progressed(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: TicketWorkflowState,
|
||||||
|
) -> bool {
|
||||||
|
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => 0,
|
||||||
|
TicketWorkflowState::Ready => 1,
|
||||||
|
TicketWorkflowState::Queued => 2,
|
||||||
|
TicketWorkflowState::InProgress => 3,
|
||||||
|
TicketWorkflowState::Done => 4,
|
||||||
|
TicketWorkflowState::Closed => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => "plan",
|
||||||
|
TicketWorkflowState::Ready => "ready",
|
||||||
|
TicketWorkflowState::Queued => "q",
|
||||||
|
TicketWorkflowState::InProgress => "prog",
|
||||||
|
TicketWorkflowState::Done => "done",
|
||||||
|
TicketWorkflowState::Closed => "cls",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
||||||
|
matches!(
|
||||||
|
blocker.blocking_state,
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
||||||
|
let shown_blockers = blockers.iter().take(3).count();
|
||||||
|
let mut formatted = blockers
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|blocker| {
|
||||||
|
format!(
|
||||||
|
"{} via {} (state: {})",
|
||||||
|
blocker.blocking_ticket,
|
||||||
|
blocker.reason_kind,
|
||||||
|
blocker.blocking_state.as_str()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
||||||
|
if remaining_blockers > 0 {
|
||||||
|
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
||||||
|
}
|
||||||
|
formatted
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum OrchestrationPlanKind {
|
pub enum OrchestrationPlanKind {
|
||||||
|
|
@ -873,9 +1403,11 @@ impl TicketDoctorReport {
|
||||||
|
|
||||||
pub trait TicketBackend {
|
pub trait TicketBackend {
|
||||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
||||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>;
|
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
|
||||||
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
|
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
|
||||||
fn create(&self, input: NewTicket) -> Result<TicketRef>;
|
fn create(&self, input: NewTicket) -> Result<TicketRef>;
|
||||||
|
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket>;
|
||||||
|
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck>;
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
|
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
|
||||||
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
||||||
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
|
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
|
||||||
|
|
@ -926,7 +1458,7 @@ pub enum TicketBackendOperation {
|
||||||
from: String,
|
from: String,
|
||||||
},
|
},
|
||||||
List {
|
List {
|
||||||
filter: TicketFilter,
|
filter: TicketListQuery,
|
||||||
},
|
},
|
||||||
Show {
|
Show {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
|
|
@ -934,6 +1466,13 @@ pub enum TicketBackendOperation {
|
||||||
Create {
|
Create {
|
||||||
input: NewTicket,
|
input: NewTicket,
|
||||||
},
|
},
|
||||||
|
EditItem {
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
edit: TicketItemEdit,
|
||||||
|
},
|
||||||
|
DependencyCheck {
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
},
|
||||||
AddEvent {
|
AddEvent {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
event: NewTicketEvent,
|
event: NewTicketEvent,
|
||||||
|
|
@ -1002,6 +1541,7 @@ pub enum TicketBackendOperationResult {
|
||||||
Tickets(Vec<TicketSummary>),
|
Tickets(Vec<TicketSummary>),
|
||||||
Ticket(Ticket),
|
Ticket(Ticket),
|
||||||
TicketRef(TicketRef),
|
TicketRef(TicketRef),
|
||||||
|
DependencyCheck(TicketDependencyCheck),
|
||||||
Relation(TicketRelation),
|
Relation(TicketRelation),
|
||||||
Relations(Vec<TicketRelation>),
|
Relations(Vec<TicketRelation>),
|
||||||
RelationView(TicketRelationView),
|
RelationView(TicketRelationView),
|
||||||
|
|
@ -1032,6 +1572,12 @@ where
|
||||||
TicketBackendOperation::Create { input } => {
|
TicketBackendOperation::Create { input } => {
|
||||||
TicketBackendOperationResult::TicketRef(backend.create(input)?)
|
TicketBackendOperationResult::TicketRef(backend.create(input)?)
|
||||||
}
|
}
|
||||||
|
TicketBackendOperation::EditItem { id, edit } => {
|
||||||
|
TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?)
|
||||||
|
}
|
||||||
|
TicketBackendOperation::DependencyCheck { id } => {
|
||||||
|
TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?)
|
||||||
|
}
|
||||||
TicketBackendOperation::AddEvent { id, event } => {
|
TicketBackendOperation::AddEvent { id, event } => {
|
||||||
backend.add_event(id, event)?;
|
backend.add_event(id, event)?;
|
||||||
TicketBackendOperationResult::Unit
|
TicketBackendOperationResult::Unit
|
||||||
|
|
@ -1143,10 +1689,10 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
|
pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
|
||||||
let mut output = TicketPartialList::default();
|
let mut output = TicketPartialList::default();
|
||||||
let mut invalid_seen = BTreeSet::new();
|
let mut invalid_seen = BTreeSet::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
let item = dir.join("item.md");
|
let item = dir.join("item.md");
|
||||||
if !item.exists() {
|
if !item.exists() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1155,10 +1701,7 @@ impl LocalTicketBackend {
|
||||||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||||
{
|
{
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
if filter
|
if !filter.matches_state(meta.workflow_state) {
|
||||||
.state
|
|
||||||
.is_some_and(|state| meta.workflow_state != state)
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
output.tickets.push(ticket_summary_from_meta(meta));
|
output.tickets.push(ticket_summary_from_meta(meta));
|
||||||
|
|
@ -1255,7 +1798,7 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> {
|
fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
if !self.root.exists() {
|
if !self.root.exists() {
|
||||||
return Ok(dirs);
|
return Ok(dirs);
|
||||||
|
|
@ -1275,10 +1818,10 @@ impl LocalTicketBackend {
|
||||||
if !item.is_file() {
|
if !item.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(state) = filter.state {
|
if !matches!(filter.state, TicketStateSelector::All) {
|
||||||
let parsed = read_item_file(&item)?;
|
let parsed = read_item_file(&item)?;
|
||||||
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
|
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
|
||||||
if meta.workflow_state != state {
|
if !filter.matches_state(meta.workflow_state) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1495,7 +2038,7 @@ impl LocalTicketBackend {
|
||||||
|
|
||||||
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
|
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
|
||||||
let mut relations = Vec::new();
|
let mut relations = Vec::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
||||||
}
|
}
|
||||||
sort_ticket_relations(&mut relations);
|
sort_ticket_relations(&mut relations);
|
||||||
|
|
@ -1508,7 +2051,7 @@ impl LocalTicketBackend {
|
||||||
invalid_seen: &mut BTreeSet<String>,
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
) -> Result<Vec<TicketRelation>> {
|
) -> Result<Vec<TicketRelation>> {
|
||||||
let mut relations = Vec::new();
|
let mut relations = Vec::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
match self.read_ticket_relations_for_dir(&dir) {
|
match self.read_ticket_relations_for_dir(&dir) {
|
||||||
Ok(records) => relations.extend(records),
|
Ok(records) => relations.extend(records),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -1539,7 +2082,7 @@ impl LocalTicketBackend {
|
||||||
|
|
||||||
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
let item = dir.join("item.md");
|
let item = dir.join("item.md");
|
||||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
||||||
states.insert(meta.id, meta.workflow_state);
|
states.insert(meta.id, meta.workflow_state);
|
||||||
|
|
@ -1553,7 +2096,7 @@ impl LocalTicketBackend {
|
||||||
invalid_seen: &mut BTreeSet<String>,
|
invalid_seen: &mut BTreeSet<String>,
|
||||||
) -> Result<HashMap<String, TicketWorkflowState>> {
|
) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
let item = dir.join("item.md");
|
let item = dir.join("item.md");
|
||||||
match read_item_file(&item)
|
match read_item_file(&item)
|
||||||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||||
|
|
@ -1589,7 +2132,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
self.default_intake_ready_state_change_body(from)
|
self.default_intake_ready_state_change_body(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
|
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
|
||||||
let mut tickets = Vec::new();
|
let mut tickets = Vec::new();
|
||||||
for dir in self.iter_ticket_dirs(filter)? {
|
for dir in self.iter_ticket_dirs(filter)? {
|
||||||
let item = dir.join("item.md");
|
let item = dir.join("item.md");
|
||||||
|
|
@ -1706,6 +2249,79 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket> {
|
||||||
|
if edit.title.is_none() && edit.body.is_none() {
|
||||||
|
return Err(TicketError::Conflict(
|
||||||
|
"TicketEditItem requires at least one of title or body".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(title) = edit.title.as_deref() {
|
||||||
|
validate_required_event_value("title", title)?;
|
||||||
|
}
|
||||||
|
if let Some(author) = edit.author.as_deref() {
|
||||||
|
validate_required_event_value("author", author)?;
|
||||||
|
}
|
||||||
|
let _lock = self.acquire_lock()?;
|
||||||
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
|
let item = dir.join("item.md");
|
||||||
|
let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
if let Some(title) = edit.title.as_deref() {
|
||||||
|
updates.push(("title", title));
|
||||||
|
}
|
||||||
|
if !updates.is_empty() {
|
||||||
|
content = replace_frontmatter_fields(&content, &updates).map_err(|message| {
|
||||||
|
TicketError::Parse {
|
||||||
|
path: item.clone(),
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Some(body) = edit.body.as_ref() {
|
||||||
|
content = replace_item_body(&content, body.as_str()).map_err(|message| {
|
||||||
|
TicketError::Parse {
|
||||||
|
path: item.clone(),
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
atomic_write(&item, content.as_bytes())?;
|
||||||
|
|
||||||
|
let author = edit.author.unwrap_or_else(default_author);
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
if edit.title.is_some() {
|
||||||
|
changes.push("title");
|
||||||
|
}
|
||||||
|
if edit.body.is_some() {
|
||||||
|
changes.push("body");
|
||||||
|
}
|
||||||
|
let body = MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", ")));
|
||||||
|
self.append_thread_event(
|
||||||
|
&dir,
|
||||||
|
"item_edit",
|
||||||
|
self.generated_heading("Item updated", "項目更新"),
|
||||||
|
&author,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&body,
|
||||||
|
)?;
|
||||||
|
self.ticket_from_dir(&dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> {
|
||||||
|
let ticket = self.show(id)?;
|
||||||
|
let summary = ticket_summary_from_meta(ticket.meta.clone());
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &ticket.relations.blockers, None);
|
||||||
|
Ok(TicketDependencyCheck {
|
||||||
|
ticket: summary,
|
||||||
|
blockers: ticket.relations.blockers,
|
||||||
|
queue_guard: projection.queue_guard,
|
||||||
|
recommended_action: projection
|
||||||
|
.next_action
|
||||||
|
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> {
|
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> {
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
|
|
@ -2088,7 +2704,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
let dir = self.find_ticket_dir(&ticket)?;
|
let dir = self.find_ticket_dir(&ticket)?;
|
||||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||||
} else {
|
} else {
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2117,7 +2733,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||||
let ticket_id = match ticket_id_from_dir(&dir) {
|
let ticket_id = match ticket_id_from_dir(&dir) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -3225,6 +3841,32 @@ fn replace_frontmatter_fields(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_item_body(content: &str, body: &str) -> std::result::Result<String, String> {
|
||||||
|
let mut lines = content.lines();
|
||||||
|
if lines.next() != Some("---") {
|
||||||
|
return Err("item.md missing frontmatter opener".to_string());
|
||||||
|
}
|
||||||
|
let mut frontmatter = vec!["---".to_string()];
|
||||||
|
let mut found_close = false;
|
||||||
|
for line in lines.by_ref() {
|
||||||
|
frontmatter.push(line.to_string());
|
||||||
|
if line == "---" {
|
||||||
|
found_close = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found_close {
|
||||||
|
return Err("item.md missing frontmatter closer".to_string());
|
||||||
|
}
|
||||||
|
let mut out = frontmatter.join("\n");
|
||||||
|
out.push_str("\n");
|
||||||
|
out.push_str(body);
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
|
fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
|
||||||
let mut out = String::from("<!--");
|
let mut out = String::from("<!--");
|
||||||
for (key, value) in attrs {
|
for (key, value) in attrs {
|
||||||
|
|
@ -3859,6 +4501,108 @@ mod tests {
|
||||||
LocalTicketBackend::new(dir.path().join("tickets"))
|
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||||
|
TicketSummary {
|
||||||
|
id: "000TEST".to_string(),
|
||||||
|
slug: "000TEST".to_string(),
|
||||||
|
title: "Test Ticket".to_string(),
|
||||||
|
status: ExtensibleTicketStatus::Open,
|
||||||
|
kind: "ticket".to_string(),
|
||||||
|
priority: "P2".to_string(),
|
||||||
|
labels: Vec::new(),
|
||||||
|
readiness: None,
|
||||||
|
workflow_state: state,
|
||||||
|
workflow_state_explicit: true,
|
||||||
|
queued_by: None,
|
||||||
|
queued_at: None,
|
||||||
|
updated_at: Some("2026-07-20T00:00:00Z".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker {
|
||||||
|
TicketRelationBlocker {
|
||||||
|
blocking_ticket: "000BLOCK".to_string(),
|
||||||
|
reason_kind: "depends_on".to_string(),
|
||||||
|
relation_kind: TicketRelationKind::DependsOn,
|
||||||
|
note: None,
|
||||||
|
blocking_state: state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_queues_ready_ticket_for_orchestrator() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &[], None);
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||||
|
assert_eq!(
|
||||||
|
projection.priority,
|
||||||
|
TicketWorkspaceActionPriority::ReadyForQueue
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.disabled_reason.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.blocked_reason.is_some());
|
||||||
|
assert!(projection.disabled_reason.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.blocked_reason.is_none());
|
||||||
|
assert!(
|
||||||
|
projection
|
||||||
|
.key_hint
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("Orchestrator will preserve order")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_overlay_suppresses_duplicate_queue() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let overlay = TicketWorkspaceStateOverlay {
|
||||||
|
source: "orchestration".to_string(),
|
||||||
|
workflow_state: TicketWorkflowState::InProgress,
|
||||||
|
};
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay));
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||||
|
);
|
||||||
|
assert_eq!(projection.visible_state, "ready→prog");
|
||||||
|
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.visible_overlay.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_state_rejects_legacy_intake_alias() {
|
fn workflow_state_rejects_legacy_intake_alias() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -3975,6 +4719,57 @@ state: planning
|
||||||
assert!(err.contains("invalid YAML frontmatter"), "{err}");
|
assert!(err.contains("invalid YAML frontmatter"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_query_defaults_to_active_and_supports_all_or_explicit_states() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&tmp);
|
||||||
|
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||||
|
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||||
|
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let ready = backend.create(ready_input).unwrap();
|
||||||
|
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||||
|
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||||
|
let closed = backend.create(closed_input).unwrap();
|
||||||
|
|
||||||
|
let active = backend.list(TicketListQuery::default()).unwrap();
|
||||||
|
let active_ids = active
|
||||||
|
.iter()
|
||||||
|
.map(|ticket| ticket.id.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(active_ids.contains(&planning.id.as_str()));
|
||||||
|
assert!(active_ids.contains(&ready.id.as_str()));
|
||||||
|
assert!(!active_ids.contains(&closed.id.as_str()));
|
||||||
|
|
||||||
|
let all = backend.list(TicketListQuery::all()).unwrap();
|
||||||
|
let all_ids = all
|
||||||
|
.iter()
|
||||||
|
.map(|ticket| ticket.id.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(all_ids.contains(&planning.id.as_str()));
|
||||||
|
assert!(all_ids.contains(&ready.id.as_str()));
|
||||||
|
assert!(all_ids.contains(&closed.id.as_str()));
|
||||||
|
|
||||||
|
let ready_only = backend
|
||||||
|
.list(TicketListQuery::state(TicketListState::Ready))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ready_only.len(), 1);
|
||||||
|
assert_eq!(ready_only[0].id, ready.id);
|
||||||
|
|
||||||
|
let planning_or_closed = backend
|
||||||
|
.list(TicketListQuery::states([
|
||||||
|
TicketListState::Planning,
|
||||||
|
TicketListState::Closed,
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let explicit_ids = planning_or_closed
|
||||||
|
.iter()
|
||||||
|
.map(|ticket| ticket.id.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(explicit_ids.contains(&planning.id.as_str()));
|
||||||
|
assert!(explicit_ids.contains(&closed.id.as_str()));
|
||||||
|
assert!(!explicit_ids.contains(&ready.id.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_writes_local_ticket_layout() {
|
fn create_writes_local_ticket_layout() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
@ -4036,9 +4831,9 @@ state: planning
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(backend.list(TicketFilter::all()).is_err());
|
assert!(backend.list(TicketListQuery::all()).is_err());
|
||||||
|
|
||||||
let partial = backend.list_partial(TicketFilter::all()).unwrap();
|
let partial = backend.list_partial(TicketListQuery::all()).unwrap();
|
||||||
assert_eq!(partial.tickets.len(), 1);
|
assert_eq!(partial.tickets.len(), 1);
|
||||||
assert_eq!(partial.tickets[0].id, valid.id);
|
assert_eq!(partial.tickets[0].id, valid.id);
|
||||||
assert_eq!(partial.invalid_records.len(), 1);
|
assert_eq!(partial.invalid_records.len(), 1);
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ use crate::{
|
||||||
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
||||||
Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
|
Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
|
||||||
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
||||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
||||||
TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
|
TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||||
|
default_author,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
|
|
@ -33,20 +34,27 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [
|
pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] =
|
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
||||||
["TicketList", "TicketShow", "TicketDoctor"];
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
|
@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 13] = [
|
pub const TICKET_TOOL_NAMES: [&str; 16] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
"TicketDoctor",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
|
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
"TicketDoctor",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
|
@ -96,9 +110,12 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
||||||
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
||||||
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
||||||
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
||||||
|
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
|
||||||
|
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
|
||||||
|
User/Companion authoring surfaces, not Orchestrator implementation control.";
|
||||||
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
||||||
lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
|
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||||
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \
|
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||||
routing, closing, planning, or implementation decisions.";
|
routing, closing, planning, or implementation decisions.";
|
||||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||||
|
|
@ -111,6 +128,9 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be
|
||||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||||
for `state`, and transitions state to `ready`.";
|
for `state`, and transitions state to `ready`.";
|
||||||
|
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||||
|
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||||
|
and rejects unresolved blocking relations.";
|
||||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||||
|
|
@ -131,21 +151,26 @@ Ticket id and/or relation kind. This is read-only planning context; Orchestrator
|
||||||
explicit state decisions.";
|
explicit state decisions.";
|
||||||
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
||||||
diagnostics through the typed backend without shelling out to external commands.";
|
diagnostics through the typed backend without shelling out to external commands.";
|
||||||
|
const DEPENDENCY_CHECK_DESCRIPTION: &str = "Return a structured Ticket dependency / queue readiness \
|
||||||
|
check through the typed Ticket backend. This read-only guard does not queue or transition the Ticket.";
|
||||||
|
|
||||||
fn base_tool_description(name: &str) -> &'static str {
|
fn base_tool_description(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"TicketCreate" => CREATE_DESCRIPTION,
|
"TicketCreate" => CREATE_DESCRIPTION,
|
||||||
|
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
|
||||||
"TicketList" => LIST_DESCRIPTION,
|
"TicketList" => LIST_DESCRIPTION,
|
||||||
"TicketShow" => SHOW_DESCRIPTION,
|
"TicketShow" => SHOW_DESCRIPTION,
|
||||||
"TicketComment" => COMMENT_DESCRIPTION,
|
"TicketComment" => COMMENT_DESCRIPTION,
|
||||||
"TicketReview" => REVIEW_DESCRIPTION,
|
"TicketReview" => REVIEW_DESCRIPTION,
|
||||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||||
|
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
"TicketClose" => CLOSE_DESCRIPTION,
|
"TicketClose" => CLOSE_DESCRIPTION,
|
||||||
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
||||||
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
||||||
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
||||||
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
||||||
|
"TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION,
|
||||||
"TicketDoctor" => DOCTOR_DESCRIPTION,
|
"TicketDoctor" => DOCTOR_DESCRIPTION,
|
||||||
_ => "Ticket backend tool.",
|
_ => "Ticket backend tool.",
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +239,7 @@ impl TicketBackend for TicketToolBackend {
|
||||||
self.backend.default_intake_ready_state_change_body(from)
|
self.backend.default_intake_ready_state_change_body(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, filter: crate::TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||||
self.backend.list(filter)
|
self.backend.list(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend {
|
||||||
self.backend.create(input)
|
self.backend.create(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_item(&self, id: TicketIdOrSlug, edit: crate::TicketItemEdit) -> TicketResult<Ticket> {
|
||||||
|
self.backend.edit_item(id, edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketDependencyCheck> {
|
||||||
|
self.backend.dependency_check(id)
|
||||||
|
}
|
||||||
|
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
||||||
self.backend.add_event(id, event)
|
self.backend.add_event(id, event)
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +384,21 @@ struct TicketCreateParams {
|
||||||
queued_at: Option<String>,
|
queued_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketEditItemParams {
|
||||||
|
/// Ticket id.
|
||||||
|
ticket: String,
|
||||||
|
/// Optional replacement title.
|
||||||
|
#[serde(default)]
|
||||||
|
title: Option<String>,
|
||||||
|
/// Optional replacement Markdown body.
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
/// Optional thread author for the audited item_edit event.
|
||||||
|
#[serde(default)]
|
||||||
|
author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum TicketWorkflowStateParam {
|
enum TicketWorkflowStateParam {
|
||||||
|
|
@ -373,11 +421,23 @@ impl TicketWorkflowStateParam {
|
||||||
Self::Closed => TicketWorkflowState::Closed,
|
Self::Closed => TicketWorkflowState::Closed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn into_list_state(self) -> TicketListState {
|
||||||
|
match self {
|
||||||
|
Self::Planning => TicketListState::Planning,
|
||||||
|
Self::Ready => TicketListState::Ready,
|
||||||
|
Self::Queued => TicketListState::Queued,
|
||||||
|
Self::Inprogress => TicketListState::InProgress,
|
||||||
|
Self::Done => TicketListState::Done,
|
||||||
|
Self::Closed => TicketListState::Closed,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum TicketListStateParam {
|
enum TicketListStateParam {
|
||||||
|
Active,
|
||||||
Planning,
|
Planning,
|
||||||
Ready,
|
Ready,
|
||||||
Queued,
|
Queued,
|
||||||
|
|
@ -388,47 +448,62 @@ enum TicketListStateParam {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketListStateParam {
|
impl TicketListStateParam {
|
||||||
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
|
fn as_list_state(self) -> Option<TicketListState> {
|
||||||
match self {
|
match self {
|
||||||
Self::Planning => (
|
Self::Planning => Some(TicketListState::Planning),
|
||||||
crate::TicketFilter::state(TicketWorkflowState::Planning),
|
Self::Ready => Some(TicketListState::Ready),
|
||||||
"planning",
|
Self::Queued => Some(TicketListState::Queued),
|
||||||
),
|
Self::Inprogress => Some(TicketListState::InProgress),
|
||||||
Self::Ready => (
|
Self::Done => Some(TicketListState::Done),
|
||||||
crate::TicketFilter::state(TicketWorkflowState::Ready),
|
Self::Closed => Some(TicketListState::Closed),
|
||||||
"ready",
|
Self::Active | Self::All => None,
|
||||||
),
|
|
||||||
Self::Queued => (
|
|
||||||
crate::TicketFilter::state(TicketWorkflowState::Queued),
|
|
||||||
"queued",
|
|
||||||
),
|
|
||||||
Self::Inprogress => (
|
|
||||||
crate::TicketFilter::state(TicketWorkflowState::InProgress),
|
|
||||||
"inprogress",
|
|
||||||
),
|
|
||||||
Self::Done => (
|
|
||||||
crate::TicketFilter::state(TicketWorkflowState::Done),
|
|
||||||
"done",
|
|
||||||
),
|
|
||||||
Self::Closed => (
|
|
||||||
crate::TicketFilter::state(TicketWorkflowState::Closed),
|
|
||||||
"closed",
|
|
||||||
),
|
|
||||||
Self::All => (crate::TicketFilter::all(), "all"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketListParams {
|
struct TicketListParams {
|
||||||
/// State filter. Defaults to all Tickets.
|
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
state: Option<TicketListStateParam>,
|
state: Option<TicketListStateParam>,
|
||||||
|
/// Explicit workflow-state filter list. Cannot be combined with `state`.
|
||||||
|
#[serde(default)]
|
||||||
|
states: Option<Vec<TicketWorkflowStateParam>>,
|
||||||
/// Maximum number of summaries to return. Defaults to 50, max 100.
|
/// Maximum number of summaries to return. Defaults to 50, max 100.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TicketListParams {
|
||||||
|
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
|
||||||
|
let query = if let Some(states) = self.states {
|
||||||
|
if self.state.is_some() {
|
||||||
|
return Err(TicketError::Conflict(
|
||||||
|
"TicketList accepts either `state` or `states`, not both".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if states.is_empty() {
|
||||||
|
return Err(TicketError::Conflict(
|
||||||
|
"TicketList `states` must include at least one workflow state".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
|
||||||
|
} else {
|
||||||
|
match self.state.unwrap_or(TicketListStateParam::Active) {
|
||||||
|
TicketListStateParam::Active => crate::TicketListQuery::active(),
|
||||||
|
TicketListStateParam::All => crate::TicketListQuery::all(),
|
||||||
|
state => crate::TicketListQuery::state(
|
||||||
|
state
|
||||||
|
.as_list_state()
|
||||||
|
.expect("workflow state list param maps to TicketListState"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let label = query.state_filter_label();
|
||||||
|
Ok((query, label, self.limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketShowParams {
|
struct TicketShowParams {
|
||||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||||
|
|
@ -507,6 +582,15 @@ struct TicketIntakeReadyParams {
|
||||||
state_change_body: Option<String>,
|
state_change_body: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketQueueParams {
|
||||||
|
/// Ticket id.
|
||||||
|
ticket: String,
|
||||||
|
/// Optional queued_by frontmatter value. Defaults to the backend/user default.
|
||||||
|
#[serde(default)]
|
||||||
|
queued_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketWorkflowStateParams {
|
struct TicketWorkflowStateParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
|
|
@ -532,6 +616,12 @@ struct TicketCloseParams {
|
||||||
resolution: String,
|
resolution: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketDependencyCheckParams {
|
||||||
|
/// Ticket id.
|
||||||
|
ticket: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum TicketRelationKindParam {
|
enum TicketRelationKindParam {
|
||||||
|
|
@ -723,6 +813,11 @@ struct TicketCreateTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketEditItemTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketListTool {
|
struct TicketListTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
|
|
@ -748,6 +843,11 @@ struct TicketIntakeReadyTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketQueueTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketWorkflowStateTool {
|
struct TicketWorkflowStateTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
|
|
@ -783,6 +883,11 @@ struct TicketDoctorTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketDependencyCheckTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketCreateTool {
|
impl Tool for TicketCreateTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
|
|
@ -817,6 +922,35 @@ impl Tool for TicketCreateTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketEditItemTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketEditItemParams = parse_input("TicketEditItem", input_json)?;
|
||||||
|
let edit = crate::TicketItemEdit {
|
||||||
|
title: params.title,
|
||||||
|
body: params.body.map(MarkdownText::new),
|
||||||
|
author: params.author,
|
||||||
|
};
|
||||||
|
let ticket = self
|
||||||
|
.backend
|
||||||
|
.edit_item(TicketIdOrSlug::from(params.ticket), edit)
|
||||||
|
.map_err(|error| backend_error("TicketEditItem", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Edited ticket {}", ticket.meta.id),
|
||||||
|
ticket_json(
|
||||||
|
&ticket,
|
||||||
|
DEFAULT_EVENT_LIMIT,
|
||||||
|
DEFAULT_ARTIFACT_LIMIT,
|
||||||
|
16 * 1024,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketListTool {
|
impl Tool for TicketListTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
|
|
@ -825,9 +959,10 @@ impl Tool for TicketListTool {
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||||
let state = params.state.unwrap_or(TicketListStateParam::All);
|
let (filter, state_filter, params_limit) = params
|
||||||
let (filter, state_filter) = state.as_filter();
|
.into_query()
|
||||||
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
.map_err(|error| backend_error("TicketList", error))?;
|
||||||
|
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||||
let tickets = self
|
let tickets = self
|
||||||
.backend
|
.backend
|
||||||
.list(filter)
|
.list(filter)
|
||||||
|
|
@ -987,6 +1122,25 @@ impl Tool for TicketIntakeReadyTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketQueueTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||||
|
let queued_by = params.queued_by.unwrap_or_else(default_author);
|
||||||
|
self.backend
|
||||||
|
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||||
|
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Queued ticket {} for Orchestrator", params.ticket),
|
||||||
|
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketWorkflowStateTool {
|
impl Tool for TicketWorkflowStateTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
|
|
@ -1216,6 +1370,33 @@ impl Tool for TicketDoctorTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketDependencyCheckTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketDependencyCheckParams = parse_input("TicketDependencyCheck", input_json)?;
|
||||||
|
let check = self
|
||||||
|
.backend
|
||||||
|
.dependency_check(TicketIdOrSlug::Query(params.ticket.clone()))
|
||||||
|
.map_err(|error| backend_error("TicketDependencyCheck", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Ticket {} dependency check: {}",
|
||||||
|
params.ticket,
|
||||||
|
if check.queue_guard.can_queue_for_orchestrator {
|
||||||
|
"queueable"
|
||||||
|
} else {
|
||||||
|
"not queueable"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
check,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||||
serde_json::from_str(input_json)
|
serde_json::from_str(input_json)
|
||||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||||
|
|
@ -1481,15 +1662,20 @@ where
|
||||||
fn input_schema(name: &str) -> Value {
|
fn input_schema(name: &str) -> Value {
|
||||||
match name {
|
match name {
|
||||||
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
|
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
|
||||||
|
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
|
||||||
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
||||||
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||||
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
||||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||||
|
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||||
"TicketWorkflowState" => {
|
"TicketWorkflowState" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||||
}
|
}
|
||||||
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||||
|
"TicketDependencyCheck" => {
|
||||||
|
serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams))
|
||||||
|
}
|
||||||
"TicketRelationRecord" => {
|
"TicketRelationRecord" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
||||||
}
|
}
|
||||||
|
|
@ -1519,11 +1705,13 @@ macro_rules! impl_from_backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_from_backend!(TicketCreateTool);
|
impl_from_backend!(TicketCreateTool);
|
||||||
|
impl_from_backend!(TicketEditItemTool);
|
||||||
impl_from_backend!(TicketListTool);
|
impl_from_backend!(TicketListTool);
|
||||||
impl_from_backend!(TicketShowTool);
|
impl_from_backend!(TicketShowTool);
|
||||||
impl_from_backend!(TicketCommentTool);
|
impl_from_backend!(TicketCommentTool);
|
||||||
impl_from_backend!(TicketReviewTool);
|
impl_from_backend!(TicketReviewTool);
|
||||||
impl_from_backend!(TicketIntakeReadyTool);
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
|
impl_from_backend!(TicketQueueTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
impl_from_backend!(TicketCloseTool);
|
impl_from_backend!(TicketCloseTool);
|
||||||
impl_from_backend!(TicketRelationRecordTool);
|
impl_from_backend!(TicketRelationRecordTool);
|
||||||
|
|
@ -1531,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
||||||
impl_from_backend!(TicketDoctorTool);
|
impl_from_backend!(TicketDoctorTool);
|
||||||
|
impl_from_backend!(TicketDependencyCheckTool);
|
||||||
|
|
||||||
/// Build all MVP Ticket tool definitions over the supplied backend.
|
/// Build all MVP Ticket tool definitions over the supplied backend.
|
||||||
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
|
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
|
||||||
let backend = backend.into();
|
let backend = backend.into();
|
||||||
vec![
|
vec![
|
||||||
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
|
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
|
||||||
|
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
|
||||||
tool_definition::<TicketListTool>("TicketList", backend.clone()),
|
tool_definition::<TicketListTool>("TicketList", backend.clone()),
|
||||||
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
|
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
|
||||||
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
|
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
|
||||||
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
||||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||||
|
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||||
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
||||||
|
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
|
||||||
|
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
|
||||||
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
||||||
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
||||||
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||||
|
|
@ -1554,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1599,18 +1791,21 @@ mod tests {
|
||||||
[
|
[
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
"TicketRelationQuery",
|
"TicketRelationQuery",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery"
|
||||||
"TicketDoctor"
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
TICKET_MUTATING_TOOL_NAMES,
|
TICKET_MUTATING_TOOL_NAMES,
|
||||||
[
|
[
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
|
@ -1825,6 +2020,16 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let active = list
|
||||||
|
.execute(&json!({}).to_string(), Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(active_json["state_filter"], "active");
|
||||||
|
assert_eq!(active_json["count"].as_u64(), Some(3));
|
||||||
|
assert_eq!(active_json["returned"].as_u64(), Some(3));
|
||||||
|
assert_eq!(active_json["truncated"].as_bool(), Some(false));
|
||||||
|
|
||||||
let all = list
|
let all = list
|
||||||
.execute(&json!({ "state": "all" }).to_string(), Default::default())
|
.execute(&json!({ "state": "all" }).to_string(), Default::default())
|
||||||
.await
|
.await
|
||||||
|
|
@ -1857,6 +2062,53 @@ mod tests {
|
||||||
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
|
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let list = tool_by_name(backend.clone(), "TicketList");
|
||||||
|
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||||
|
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||||
|
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
let ready = backend.create(ready_input).unwrap();
|
||||||
|
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||||
|
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||||
|
let closed = backend.create(closed_input).unwrap();
|
||||||
|
|
||||||
|
let listed = list
|
||||||
|
.execute(
|
||||||
|
&json!({ "states": ["planning", "closed"] }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(listed_json["state_filter"], "planning,closed");
|
||||||
|
assert_eq!(listed_json["count"].as_u64(), Some(2));
|
||||||
|
let listed_ids = listed_json["tickets"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|ticket| ticket["id"].as_str().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(listed_ids.contains(&planning.id.as_str()));
|
||||||
|
assert!(listed_ids.contains(&closed.id.as_str()));
|
||||||
|
assert!(!listed_ids.contains(&ready.id.as_str()));
|
||||||
|
|
||||||
|
let mixed = list
|
||||||
|
.execute(
|
||||||
|
&json!({ "state": "active", "states": ["planning"] }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(mixed.is_err());
|
||||||
|
|
||||||
|
let empty = list
|
||||||
|
.execute(&json!({ "states": [] }).to_string(), Default::default())
|
||||||
|
.await;
|
||||||
|
assert!(empty.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -2408,7 +2660,10 @@ mod tests {
|
||||||
assert!(!id.contains("escape"));
|
assert!(!id.contains("escape"));
|
||||||
assert!(!temp.path().join("escape").exists());
|
assert!(!temp.path().join("escape").exists());
|
||||||
assert!(temp.path().join("tickets").join(id).is_dir());
|
assert!(temp.path().join("tickets").join(id).is_dir());
|
||||||
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
assert_eq!(
|
||||||
|
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -238,10 +238,8 @@ enabled = false
|
||||||
|
|
||||||
[feature.ticket]
|
[feature.ticket]
|
||||||
enabled = true
|
enabled = true
|
||||||
access = "lifecycle"
|
authoring = true
|
||||||
|
thread = true
|
||||||
[feature.ticket_orchestration]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[memory]
|
[memory]
|
||||||
extract_threshold = 50000
|
extract_threshold = 50000
|
||||||
|
|
@ -298,8 +296,9 @@ mod tests {
|
||||||
assert!(profile.contains("slug = \"default\""));
|
assert!(profile.contains("slug = \"default\""));
|
||||||
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
||||||
assert!(profile.contains("scope = \"workspace_write\""));
|
assert!(profile.contains("scope = \"workspace_write\""));
|
||||||
assert!(profile.contains("[feature.ticket]\nenabled = true\naccess = \"lifecycle\""));
|
assert!(
|
||||||
assert!(profile.contains("[feature.ticket_orchestration]\nenabled = false"));
|
profile.contains("[feature.ticket]\nenabled = true\nauthoring = true\nthread = true")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ use ticket::config::{
|
||||||
WORKSPACE_SETTINGS_RELATIVE_PATH,
|
WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||||
};
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
|
||||||
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
|
||||||
|
TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction,
|
||||||
|
TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
||||||
|
|
@ -266,11 +268,7 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
|
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay;
|
||||||
pub(crate) struct TicketStateOverlay {
|
|
||||||
pub(crate) source: String,
|
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct TicketAssociatedIntakeEntry {
|
pub(crate) struct TicketAssociatedIntakeEntry {
|
||||||
|
|
@ -693,7 +691,7 @@ fn load_orchestration_ticket_overlay_states(
|
||||||
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
|
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
|
||||||
.with_record_language(overlay_config.ticket_record_language());
|
.with_record_language(overlay_config.ticket_record_language());
|
||||||
let partial = backend
|
let partial = backend
|
||||||
.list_partial(TicketFilter::all())
|
.list_partial(TicketListQuery::all())
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let mut states = BTreeMap::new();
|
let mut states = BTreeMap::new();
|
||||||
for summary in partial.tickets {
|
for summary in partial.tickets {
|
||||||
|
|
@ -1092,7 +1090,7 @@ fn build_ticket_rows(
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
|
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
|
||||||
) -> ticket::Result<TicketRowsBuild> {
|
) -> ticket::Result<TicketRowsBuild> {
|
||||||
let partial = backend.list_partial(TicketFilter::all())?;
|
let partial = backend.list_partial(TicketListQuery::all())?;
|
||||||
let mut ticket_rows = Vec::new();
|
let mut ticket_rows = Vec::new();
|
||||||
let mut invalid_records = partial.invalid_records;
|
let mut invalid_records = partial.invalid_records;
|
||||||
for summary in partial.tickets {
|
for summary in partial.tickets {
|
||||||
|
|
@ -1232,29 +1230,24 @@ fn ticket_row(
|
||||||
related_workers.push(worker_name);
|
related_workers.push(worker_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let visible_overlay = orchestration_overlay
|
let projection =
|
||||||
.filter(|overlay| {
|
project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay);
|
||||||
overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
|
||||||
})
|
|
||||||
.cloned();
|
|
||||||
let mut derived = derive_ticket_state(&summary, relation_blockers);
|
|
||||||
if let Some(overlay) = visible_overlay.as_ref() {
|
|
||||||
apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay);
|
|
||||||
}
|
|
||||||
let latest_event = events.last();
|
let latest_event = events.last();
|
||||||
let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref());
|
let kind = panel_row_kind_from_workspace(projection.kind);
|
||||||
|
let priority = action_priority_from_workspace(projection.priority);
|
||||||
|
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
id: summary.id.clone(),
|
id: summary.id.clone(),
|
||||||
title: summary.title.clone(),
|
title: summary.title.clone(),
|
||||||
priority: summary.priority.clone(),
|
priority: summary.priority.clone(),
|
||||||
workflow_state: summary.workflow_state,
|
workflow_state: summary.workflow_state,
|
||||||
workflow_state_explicit: summary.workflow_state_explicit,
|
workflow_state_explicit: summary.workflow_state_explicit,
|
||||||
orchestration_overlay: visible_overlay,
|
orchestration_overlay: projection.visible_overlay.clone(),
|
||||||
next_action: derived.action,
|
next_action,
|
||||||
updated_at: summary.updated_at.clone(),
|
updated_at: summary.updated_at.clone(),
|
||||||
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
||||||
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
||||||
blocked_reason: derived.blocked_reason.clone(),
|
blocked_reason: projection.blocked_reason.clone(),
|
||||||
related_workers: related_workers.clone(),
|
related_workers: related_workers.clone(),
|
||||||
local_claim,
|
local_claim,
|
||||||
intake_workers,
|
intake_workers,
|
||||||
|
|
@ -1262,251 +1255,42 @@ fn ticket_row(
|
||||||
let subtitle = ticket_subtitle(&entry);
|
let subtitle = ticket_subtitle(&entry);
|
||||||
PanelRow {
|
PanelRow {
|
||||||
key: PanelRowKey::Ticket(summary.id),
|
key: PanelRowKey::Ticket(summary.id),
|
||||||
kind: derived.kind,
|
kind,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
subtitle,
|
subtitle,
|
||||||
status: state_display,
|
status: projection.visible_state,
|
||||||
priority: derived.priority,
|
priority,
|
||||||
next_action: derived.action,
|
next_action,
|
||||||
ticket: Some(entry),
|
ticket: Some(entry),
|
||||||
related_workers,
|
related_workers,
|
||||||
disabled_reason: derived.disabled_reason,
|
disabled_reason: projection.disabled_reason,
|
||||||
key_hint: derived.key_hint,
|
key_hint: projection.key_hint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind {
|
||||||
struct DerivedTicketState {
|
match kind {
|
||||||
kind: PanelRowKind,
|
TicketWorkspaceRowKind::Planning => PanelRowKind::Planning,
|
||||||
priority: ActionPriority,
|
TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket,
|
||||||
action: Option<NextUserAction>,
|
TicketWorkspaceRowKind::Review => PanelRowKind::Review,
|
||||||
disabled_reason: Option<String>,
|
TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork,
|
||||||
key_hint: Option<String>,
|
|
||||||
blocked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
|
||||||
match state {
|
|
||||||
TicketWorkflowState::Planning => 0,
|
|
||||||
TicketWorkflowState::Ready => 1,
|
|
||||||
TicketWorkflowState::Queued => 2,
|
|
||||||
TicketWorkflowState::InProgress => 3,
|
|
||||||
TicketWorkflowState::Done => 4,
|
|
||||||
TicketWorkflowState::Closed => 5,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool {
|
fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority {
|
||||||
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
match priority {
|
||||||
}
|
TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue,
|
||||||
|
TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork,
|
||||||
fn ticket_state_display(
|
TicketWorkspaceActionPriority::Background => ActionPriority::Background,
|
||||||
local: TicketWorkflowState,
|
|
||||||
overlay: Option<&TicketStateOverlay>,
|
|
||||||
) -> String {
|
|
||||||
match overlay {
|
|
||||||
Some(overlay) => format!(
|
|
||||||
"{}→{}",
|
|
||||||
compact_ticket_state_label(local),
|
|
||||||
compact_ticket_state_label(overlay.workflow_state)
|
|
||||||
),
|
|
||||||
None => local.as_str().to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction {
|
||||||
match state {
|
match action {
|
||||||
TicketWorkflowState::Planning => "plan",
|
TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify,
|
||||||
TicketWorkflowState::Ready => "ready",
|
TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue,
|
||||||
TicketWorkflowState::Queued => "q",
|
TicketWorkspaceNextAction::Close => NextUserAction::Close,
|
||||||
TicketWorkflowState::InProgress => "prog",
|
TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait,
|
||||||
TicketWorkflowState::Done => "done",
|
|
||||||
TicketWorkflowState::Closed => "cls",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_orchestration_overlay_to_derived(
|
|
||||||
derived: &mut DerivedTicketState,
|
|
||||||
local: TicketWorkflowState,
|
|
||||||
overlay: &TicketStateOverlay,
|
|
||||||
) {
|
|
||||||
derived.action = Some(NextUserAction::Wait);
|
|
||||||
let overlay_state = overlay.workflow_state.as_str();
|
|
||||||
match overlay.workflow_state {
|
|
||||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
|
||||||
derived.kind = PanelRowKind::Review;
|
|
||||||
derived.priority = ActionPriority::Background;
|
|
||||||
derived.disabled_reason = Some(format!(
|
|
||||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
|
||||||
overlay.source,
|
|
||||||
local.as_str()
|
|
||||||
));
|
|
||||||
derived.key_hint = Some(format!(
|
|
||||||
"Merge pending: local: {} · {}: {overlay_state}",
|
|
||||||
local.as_str(),
|
|
||||||
overlay.source
|
|
||||||
));
|
|
||||||
}
|
|
||||||
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
|
||||||
derived.kind = PanelRowKind::ActiveWork;
|
|
||||||
derived.priority = ActionPriority::ActiveWork;
|
|
||||||
derived.disabled_reason = Some(format!(
|
|
||||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
|
||||||
overlay.source,
|
|
||||||
local.as_str()
|
|
||||||
));
|
|
||||||
derived.key_hint = Some(format!(
|
|
||||||
"Progress overlay: local: {} · {}: {overlay_state}",
|
|
||||||
local.as_str(),
|
|
||||||
overlay.source
|
|
||||||
));
|
|
||||||
}
|
|
||||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
|
||||||
let shown_blockers = blockers.iter().take(3).count();
|
|
||||||
let mut formatted = blockers
|
|
||||||
.iter()
|
|
||||||
.take(3)
|
|
||||||
.map(|blocker| {
|
|
||||||
format!(
|
|
||||||
"{} via {} (state: {})",
|
|
||||||
blocker.blocking_ticket,
|
|
||||||
blocker.reason_kind,
|
|
||||||
blocker.blocking_state.as_str()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
|
||||||
if remaining_blockers > 0 {
|
|
||||||
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
|
||||||
}
|
|
||||||
formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
|
||||||
matches!(
|
|
||||||
blocker.blocking_state,
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_ticket_state(
|
|
||||||
summary: &TicketSummary,
|
|
||||||
relation_blockers: &[TicketRelationBlocker],
|
|
||||||
) -> DerivedTicketState {
|
|
||||||
if !relation_blockers.is_empty() {
|
|
||||||
let active_blockers = relation_blockers
|
|
||||||
.iter()
|
|
||||||
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
|
||||||
let blockers_to_report = if active_blockers.is_empty() {
|
|
||||||
relation_blockers.iter().collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
active_blockers
|
|
||||||
};
|
|
||||||
let blockers = format_relation_blockers(&blockers_to_report);
|
|
||||||
let waiting_reason = format!("waiting for {blockers}");
|
|
||||||
return DerivedTicketState {
|
|
||||||
kind: match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Planning => PanelRowKind::Planning,
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
|
||||||
PanelRowKind::ActiveWork
|
|
||||||
}
|
|
||||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
|
|
||||||
TicketWorkflowState::Ready => PanelRowKind::Ticket,
|
|
||||||
},
|
|
||||||
priority: match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
|
||||||
ActionPriority::ActiveWork
|
|
||||||
}
|
|
||||||
_ => ActionPriority::Background,
|
|
||||||
},
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some(format!(
|
|
||||||
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
|
||||||
)),
|
|
||||||
key_hint: Some(format!("Gate: {waiting_reason}")),
|
|
||||||
blocked_reason: Some(blockers),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let blockers = format_relation_blockers(
|
|
||||||
&relation_blockers
|
|
||||||
.iter()
|
|
||||||
.collect::<Vec<&TicketRelationBlocker>>(),
|
|
||||||
);
|
|
||||||
return DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Ticket,
|
|
||||||
priority: ActionPriority::ReadyForQueue,
|
|
||||||
action: Some(NextUserAction::Queue),
|
|
||||||
disabled_reason: None,
|
|
||||||
key_hint: Some(format!(
|
|
||||||
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
|
||||||
)),
|
|
||||||
blocked_reason: None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Ready => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Ticket,
|
|
||||||
priority: ActionPriority::ReadyForQueue,
|
|
||||||
action: Some(NextUserAction::Queue),
|
|
||||||
disabled_reason: None,
|
|
||||||
key_hint: Some(
|
|
||||||
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
|
||||||
),
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Queued => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::ActiveWork,
|
|
||||||
priority: ActionPriority::ActiveWork,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::InProgress => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::ActiveWork,
|
|
||||||
priority: ActionPriority::ActiveWork,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Done => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Review,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Close),
|
|
||||||
disabled_reason: Some(
|
|
||||||
"state is done; close if a resolution is still missing.".to_string(),
|
|
||||||
),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Planning => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Planning,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Clarify),
|
|
||||||
disabled_reason: Some(
|
|
||||||
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
|
||||||
),
|
|
||||||
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Closed => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Review,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is closed.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1651,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod(
|
||||||
TicketLocalClaimStatus::Stale
|
TicketLocalClaimStatus::Stale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_state_display(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: Option<&TicketStateOverlay>,
|
||||||
|
) -> String {
|
||||||
|
match overlay {
|
||||||
|
Some(overlay) => format!(
|
||||||
|
"{}→{}",
|
||||||
|
compact_ticket_state_label(local),
|
||||||
|
compact_ticket_state_label(overlay.workflow_state)
|
||||||
|
),
|
||||||
|
None => local.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => "plan",
|
||||||
|
TicketWorkflowState::Ready => "ready",
|
||||||
|
TicketWorkflowState::Queued => "q",
|
||||||
|
TicketWorkflowState::InProgress => "prog",
|
||||||
|
TicketWorkflowState::Done => "done",
|
||||||
|
TicketWorkflowState::Closed => "cls",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||||
let mut parts = vec![format!(
|
let mut parts = vec![format!(
|
||||||
"{} · {}",
|
"{} · {}",
|
||||||
|
|
@ -2522,7 +2331,7 @@ mod tests {
|
||||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
});
|
});
|
||||||
let ticket_id = backend
|
let ticket_id = backend
|
||||||
.list(TicketFilter::all())
|
.list(TicketListQuery::all())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|ticket| ticket.title == "Ticket With Intake")
|
.find(|ticket| ticket.title == "Ticket With Intake")
|
||||||
|
|
@ -2619,7 +2428,7 @@ mod tests {
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(&backend, "Claimed Planning", |_| {});
|
create_ticket(&backend, "Claimed Planning", |_| {});
|
||||||
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
|
||||||
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
||||||
store
|
store
|
||||||
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")
|
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use llm_engine::EngineError;
|
use llm_engine::EngineError;
|
||||||
use llm_engine::llm_client::client::LlmClient;
|
use llm_engine::llm_client::client::LlmClient;
|
||||||
use manifest::TicketFeatureAccessConfig;
|
|
||||||
use session_store::WorkerMetadataStore;
|
use session_store::WorkerMetadataStore;
|
||||||
use session_store::{LogEntry, Store};
|
use session_store::{LogEntry, Store};
|
||||||
use ticket::LocalTicketBackend;
|
use ticket::LocalTicketBackend;
|
||||||
|
|
@ -547,9 +546,7 @@ fn install_ticket_event_companion_notify_hook<C, St>(
|
||||||
}
|
}
|
||||||
|
|
||||||
let ticket_feature = &worker.manifest().feature.ticket;
|
let ticket_feature = &worker.manifest().feature.ticket;
|
||||||
if !ticket_feature.enabled
|
if !ticket_feature.enabled || !ticket_feature.orchestration_control {
|
||||||
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,14 +671,12 @@ where
|
||||||
if feature_config.task.enabled {
|
if feature_config.task.enabled {
|
||||||
feature_registry.add_module(task_feature);
|
feature_registry.add_module(task_feature);
|
||||||
}
|
}
|
||||||
if feature_config.ticket.enabled || feature_config.ticket_orchestration.enabled {
|
if feature_config.ticket.enabled {
|
||||||
let ticket_access = match feature_config.ticket.access {
|
let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess {
|
||||||
TicketFeatureAccessConfig::ReadOnly => {
|
authoring: feature_config.ticket.authoring,
|
||||||
crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly
|
thread: feature_config.ticket.thread,
|
||||||
}
|
intake: feature_config.ticket.intake,
|
||||||
TicketFeatureAccessConfig::Lifecycle => {
|
orchestration_control: feature_config.ticket.orchestration_control,
|
||||||
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// Ticket tools are typed operations over the current workspace Ticket backend.
|
// Ticket tools are typed operations over the current workspace Ticket backend.
|
||||||
// Runtime-hosted Workers prefer the workspace API URI carried by the
|
// Runtime-hosted Workers prefer the workspace API URI carried by the
|
||||||
|
|
@ -710,10 +705,9 @@ where
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::ticket::ticket_tools_feature_with_options(
|
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
|
||||||
ticket_backend,
|
ticket_backend,
|
||||||
feature_config.ticket.enabled.then_some(ticket_access),
|
ticket_access,
|
||||||
feature_config.ticket_orchestration.enabled,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,5 @@ pub(crate) use session_explore::{
|
||||||
pub use task::{TaskFeature, task_tools_feature};
|
pub use task::{TaskFeature, task_tools_feature};
|
||||||
pub use ticket::{
|
pub use ticket::{
|
||||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
||||||
ticket_tools_feature_with_options,
|
ticket_tools_feature_with_backend,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,11 @@ use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||||
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug,
|
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
|
||||||
TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
|
||||||
TicketReview, TicketStateChange, TicketSummary,
|
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
|
||||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||||
tool::{
|
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||||
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
|
||||||
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
|
|
||||||
TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description,
|
|
||||||
ticket_tools,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
|
|
@ -32,30 +27,146 @@ const FEATURE_NAME: &str = "Ticket tools";
|
||||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
pub enum TicketFeatureAccess {
|
pub struct TicketFeatureAccess {
|
||||||
/// Status/diagnostic access for views such as Companion that must not mutate Tickets.
|
pub authoring: bool,
|
||||||
ReadOnly,
|
pub thread: bool,
|
||||||
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools.
|
pub intake: bool,
|
||||||
Lifecycle,
|
pub orchestration_control: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeatureAccess {
|
impl TicketFeatureAccess {
|
||||||
pub fn base_tool_names(self) -> &'static [&'static str] {
|
pub const fn read_only() -> Self {
|
||||||
match self {
|
Self {
|
||||||
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES,
|
authoring: false,
|
||||||
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES,
|
thread: false,
|
||||||
|
intake: false,
|
||||||
|
orchestration_control: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
|
pub const fn workspace_authoring() -> Self {
|
||||||
match self {
|
Self {
|
||||||
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES,
|
authoring: true,
|
||||||
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
|
thread: true,
|
||||||
|
intake: false,
|
||||||
|
orchestration_control: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const fn intake() -> Self {
|
||||||
|
Self {
|
||||||
|
authoring: true,
|
||||||
|
thread: true,
|
||||||
|
intake: true,
|
||||||
|
orchestration_control: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn orchestration_control() -> Self {
|
||||||
|
Self {
|
||||||
|
authoring: false,
|
||||||
|
thread: true,
|
||||||
|
intake: false,
|
||||||
|
orchestration_control: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn work_report() -> Self {
|
||||||
|
Self {
|
||||||
|
authoring: false,
|
||||||
|
thread: true,
|
||||||
|
intake: false,
|
||||||
|
orchestration_control: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn review() -> Self {
|
||||||
|
Self {
|
||||||
|
authoring: false,
|
||||||
|
thread: true,
|
||||||
|
intake: false,
|
||||||
|
orchestration_control: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_names(self) -> Vec<&'static str> {
|
||||||
|
TICKET_TOOL_NAMES
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|name| self.allows_tool(name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows_tool(self, name: &str) -> bool {
|
||||||
|
READ_ONLY_TOOL_NAMES.contains(&name)
|
||||||
|
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|
||||||
|
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|
||||||
|
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|
||||||
|
|| (self.orchestration_control
|
||||||
|
&& ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
"TicketRelationQuery",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
|
"TicketQueue",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketRelationRecord",
|
||||||
|
];
|
||||||
|
|
||||||
|
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
|
||||||
|
|
||||||
|
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||||
|
|
||||||
|
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketQueue",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationQuery",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketWorkflowState",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
"TicketRelationRecord",
|
||||||
|
"TicketRelationQuery",
|
||||||
|
"TicketOrchestrationPlanRecord",
|
||||||
|
"TicketOrchestrationPlanQuery",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||||
|
"TicketWorkflowState",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketRelationRecord",
|
||||||
|
"TicketOrchestrationPlanRecord",
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum TicketFeatureBackend {
|
pub enum TicketFeatureBackend {
|
||||||
Local {
|
Local {
|
||||||
|
|
@ -96,94 +207,59 @@ pub struct TicketFeature {
|
||||||
record_language: Option<String>,
|
record_language: Option<String>,
|
||||||
config_error: Option<String>,
|
config_error: Option<String>,
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
include_base_tools: bool,
|
|
||||||
include_orchestration_tools: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeature {
|
impl TicketFeature {
|
||||||
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
||||||
Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle)
|
Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
||||||
Self::new_with_options(backend_root, Some(access), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_options(
|
|
||||||
backend_root: impl Into<PathBuf>,
|
|
||||||
access: Option<TicketFeatureAccess>,
|
|
||||||
include_orchestration_tools: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self::with_backend(
|
Self::with_backend(
|
||||||
TicketFeatureBackend::Local {
|
TicketFeatureBackend::Local {
|
||||||
root: backend_root.into(),
|
root: backend_root.into(),
|
||||||
},
|
},
|
||||||
access,
|
access,
|
||||||
include_orchestration_tools,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_backend(
|
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
|
||||||
backend: TicketFeatureBackend,
|
|
||||||
access: Option<TicketFeatureAccess>,
|
|
||||||
include_orchestration_tools: bool,
|
|
||||||
) -> Self {
|
|
||||||
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
|
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
|
||||||
return Self::for_workspace_with_options(
|
return Self::for_workspace_with_access(workspace_root, access);
|
||||||
workspace_root,
|
|
||||||
access,
|
|
||||||
include_orchestration_tools,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
backend,
|
backend,
|
||||||
record_language: None,
|
record_language: None,
|
||||||
config_error: None,
|
config_error: None,
|
||||||
access: access.unwrap_or(TicketFeatureAccess::Lifecycle),
|
access,
|
||||||
include_base_tools: access.is_some(),
|
|
||||||
include_orchestration_tools,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
||||||
Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle)
|
Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_workspace_with_access(
|
pub fn for_workspace_with_access(
|
||||||
workspace: impl AsRef<Path>,
|
workspace: impl AsRef<Path>,
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
) -> Self {
|
|
||||||
Self::for_workspace_with_options(workspace, Some(access), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn for_workspace_with_options(
|
|
||||||
workspace: impl AsRef<Path>,
|
|
||||||
access: Option<TicketFeatureAccess>,
|
|
||||||
include_orchestration_tools: bool,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let workspace = workspace.as_ref();
|
let workspace = workspace.as_ref();
|
||||||
match TicketConfig::load_workspace(workspace) {
|
match TicketConfig::load_workspace(workspace) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
let backend_root = config.backend_root().to_path_buf();
|
let backend_root = config.backend_root().to_path_buf();
|
||||||
let record_language = config.ticket_record_language().map(str::to_string);
|
let record_language = config.ticket_record_language().map(str::to_string);
|
||||||
let mut feature =
|
let mut feature = Self::new_with_access(backend_root, access);
|
||||||
Self::new_with_options(backend_root, access, include_orchestration_tools);
|
|
||||||
feature.record_language = record_language;
|
feature.record_language = record_language;
|
||||||
feature
|
feature
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => Self {
|
||||||
let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle);
|
backend: TicketFeatureBackend::Local {
|
||||||
Self {
|
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||||
backend: TicketFeatureBackend::Local {
|
},
|
||||||
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
record_language: None,
|
||||||
},
|
config_error: Some(error.to_string()),
|
||||||
record_language: None,
|
access,
|
||||||
config_error: Some(error.to_string()),
|
},
|
||||||
access: access_value,
|
|
||||||
include_base_tools: access.is_some(),
|
|
||||||
include_orchestration_tools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,20 +276,7 @@ impl TicketFeature {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enabled_tool_names(&self) -> Vec<&'static str> {
|
fn enabled_tool_names(&self) -> Vec<&'static str> {
|
||||||
if self.include_base_tools && self.include_orchestration_tools {
|
self.access.tool_names()
|
||||||
return match self.access {
|
|
||||||
TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(),
|
|
||||||
TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let mut names = Vec::new();
|
|
||||||
if self.include_base_tools {
|
|
||||||
names.extend_from_slice(self.access.base_tool_names());
|
|
||||||
}
|
|
||||||
if self.include_orchestration_tools {
|
|
||||||
names.extend_from_slice(self.access.orchestration_tool_names());
|
|
||||||
}
|
|
||||||
names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||||
|
|
@ -271,9 +334,9 @@ impl FeatureModule for TicketFeature {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
.with_description(FEATURE_DESCRIPTION);
|
.with_description(FEATURE_DESCRIPTION);
|
||||||
let enabled_tool_names = self.enabled_tool_names();
|
let enabled_tool_names = self.enabled_tool_names();
|
||||||
for name in &enabled_tool_names {
|
for name in enabled_tool_names {
|
||||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
*name,
|
name,
|
||||||
ticket_tool_description(name, self.record_language.as_deref()),
|
ticket_tool_description(name, self.record_language.as_deref()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +463,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, filter: TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
fn list(&self, filter: TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||||
expect_ticket_result!(
|
expect_ticket_result!(
|
||||||
self.invoke(TicketBackendOperation::List { filter }),
|
self.invoke(TicketBackendOperation::List { filter }),
|
||||||
TicketBackendOperationResult::Tickets
|
TicketBackendOperationResult::Tickets
|
||||||
|
|
@ -421,6 +484,20 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> TicketResult<Ticket> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::EditItem { id, edit }),
|
||||||
|
TicketBackendOperationResult::Ticket
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketDependencyCheck> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::DependencyCheck { id }),
|
||||||
|
TicketBackendOperationResult::DependencyCheck
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
||||||
match self.invoke(TicketBackendOperation::AddEvent { id, event })? {
|
match self.invoke(TicketBackendOperation::AddEvent { id, event })? {
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
TicketBackendOperationResult::Unit => Ok(()),
|
||||||
|
|
@ -601,12 +678,11 @@ pub fn ticket_tools_feature_with_access(
|
||||||
TicketFeature::for_workspace_with_access(workspace, access)
|
TicketFeature::for_workspace_with_access(workspace, access)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ticket_tools_feature_with_options(
|
pub fn ticket_tools_feature_with_backend(
|
||||||
backend: impl Into<TicketFeatureBackend>,
|
backend: impl Into<TicketFeatureBackend>,
|
||||||
access: Option<TicketFeatureAccess>,
|
access: TicketFeatureAccess,
|
||||||
include_orchestration_tools: bool,
|
|
||||||
) -> TicketFeature {
|
) -> TicketFeature {
|
||||||
TicketFeature::with_backend(backend.into(), access, include_orchestration_tools)
|
TicketFeature::with_backend(backend.into(), access)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -618,10 +694,6 @@ mod tests {
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use ticket::tool::{
|
|
||||||
TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES,
|
|
||||||
TICKET_TOOL_NAMES,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn make_ticket_root(root: &Path) {
|
fn make_ticket_root(root: &Path) {
|
||||||
std::fs::create_dir_all(root).unwrap();
|
std::fs::create_dir_all(root).unwrap();
|
||||||
|
|
@ -653,66 +725,114 @@ mod tests {
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
||||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||||
assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(descriptor.tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
descriptor
|
descriptor
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
TICKET_TOOL_NAMES
|
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_only_descriptor_declares_only_state_tools() {
|
fn read_only_descriptor_declares_only_state_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
let feature =
|
||||||
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly);
|
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
|
||||||
assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
descriptor
|
descriptor
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
TICKET_READ_ONLY_TOOL_NAMES
|
READ_ONLY_TOOL_NAMES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_can_expose_base_ticket_without_orchestration_tools() {
|
fn orchestration_control_descriptor_declares_orchestration_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let feature = ticket_tools_feature_with_options(
|
let feature = ticket_tools_feature_with_access(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
Some(TicketFeatureAccess::Lifecycle),
|
TicketFeatureAccess::orchestration_control(),
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
|
assert_eq!(
|
||||||
|
feature.access(),
|
||||||
|
TicketFeatureAccess::orchestration_control()
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
descriptor
|
descriptor
|
||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
TICKET_BASE_TOOL_NAMES
|
ORCHESTRATION_CONTROL_TOOL_NAMES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_can_expose_orchestration_only_tools() {
|
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let feature = ticket_tools_feature_with_options(temp.path(), None, true);
|
|
||||||
let descriptor = feature.descriptor();
|
let workspace_authoring = ticket_tools_feature_with_access(
|
||||||
assert_eq!(
|
temp.path(),
|
||||||
descriptor
|
TicketFeatureAccess::workspace_authoring(),
|
||||||
.tools
|
|
||||||
.iter()
|
|
||||||
.map(|tool| tool.name.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
TICKET_ORCHESTRATION_TOOL_NAMES
|
|
||||||
);
|
);
|
||||||
|
let workspace_descriptor = workspace_authoring.descriptor();
|
||||||
|
let workspace_tools = workspace_descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(workspace_tools.contains(&"TicketCreate"));
|
||||||
|
assert!(workspace_tools.contains(&"TicketEditItem"));
|
||||||
|
assert!(workspace_tools.contains(&"TicketQueue"));
|
||||||
|
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
|
let orchestration = ticket_tools_feature_with_access(
|
||||||
|
temp.path(),
|
||||||
|
TicketFeatureAccess::orchestration_control(),
|
||||||
|
);
|
||||||
|
let orchestration_descriptor = orchestration.descriptor();
|
||||||
|
let orchestration_tools = orchestration_descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(orchestration_tools.contains(&"TicketWorkflowState"));
|
||||||
|
assert!(orchestration_tools.contains(&"TicketDependencyCheck"));
|
||||||
|
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
|
||||||
|
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
|
||||||
|
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
||||||
|
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
||||||
|
|
||||||
|
let work_report =
|
||||||
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
|
||||||
|
let work_report_descriptor = work_report.descriptor();
|
||||||
|
let work_report_tools = work_report_descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(work_report_tools.contains(&"TicketComment"));
|
||||||
|
assert!(work_report_tools.contains(&"TicketReview"));
|
||||||
|
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
|
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
||||||
|
let review_descriptor = review.descriptor();
|
||||||
|
let review_tools = review_descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(review_tools.contains(&"TicketReview"));
|
||||||
|
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -724,20 +844,17 @@ mod tests {
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(ticket_tools_feature_with_access(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
TicketFeatureAccess::ReadOnly,
|
TicketFeatureAccess::read_only(),
|
||||||
))
|
))
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||||
assert_eq!(
|
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
||||||
report.reports[0].installed_tools,
|
|
||||||
TICKET_READ_ONLY_TOOL_NAMES
|
|
||||||
);
|
|
||||||
let pending_names = pending_tools
|
let pending_names = pending_tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|definition| definition().0.name)
|
.map(|definition| definition().0.name)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(pending_names, TICKET_READ_ONLY_TOOL_NAMES);
|
assert_eq!(pending_names, READ_ONLY_TOOL_NAMES);
|
||||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
||||||
assert!(
|
assert!(
|
||||||
!report.reports[0]
|
!report.reports[0]
|
||||||
|
|
@ -760,7 +877,8 @@ language = "Japanese"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
let feature =
|
||||||
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
let descriptor_description = descriptor
|
let descriptor_description = descriptor
|
||||||
.tools
|
.tools
|
||||||
|
|
@ -777,11 +895,8 @@ language = "Japanese"
|
||||||
.with_module(feature)
|
.with_module(feature)
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||||
assert_eq!(
|
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
||||||
report.reports[0].installed_tools,
|
|
||||||
TICKET_READ_ONLY_TOOL_NAMES
|
|
||||||
);
|
|
||||||
let description = pending_tool_description(&pending_tools, "TicketShow");
|
let description = pending_tool_description(&pending_tools, "TicketShow");
|
||||||
assert!(description.contains("Ticket record language: Japanese"));
|
assert!(description.contains("Ticket record language: Japanese"));
|
||||||
assert!(description.contains("distinct from worker.language"));
|
assert!(description.contains("distinct from worker.language"));
|
||||||
|
|
@ -789,7 +904,7 @@ language = "Japanese"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifecycle_installation_exposes_lifecycle_tools() {
|
fn workspace_authoring_installation_exposes_authoring_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
|
|
@ -797,36 +912,31 @@ language = "Japanese"
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(ticket_tools_feature_with_access(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
TicketFeatureAccess::Lifecycle,
|
TicketFeatureAccess::workspace_authoring(),
|
||||||
))
|
))
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
let installed = report.reports[0]
|
||||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
.installed_tools
|
||||||
assert!(
|
.iter()
|
||||||
report.reports[0]
|
.map(String::as_str)
|
||||||
.installed_tools
|
.collect::<Vec<_>>();
|
||||||
.iter()
|
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
|
||||||
.any(|tool| tool == name)
|
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
|
||||||
);
|
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
|
||||||
}
|
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
|
||||||
|
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
|
||||||
|
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||||
assert!(
|
assert!(
|
||||||
report.reports[0]
|
!installed
|
||||||
.installed_tools
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tool| tool == "TicketIntakeReady")
|
.any(|tool| *tool == "TicketOrchestrationPlanRecord")
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
report.reports[0]
|
|
||||||
.installed_tools
|
|
||||||
.iter()
|
|
||||||
.any(|tool| tool == "TicketWorkflowState")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() {
|
fn workspace_authoring_context_exposes_ticket_language_guidance() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(
|
write_ticket_config(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
|
|
@ -841,12 +951,15 @@ language = "Japanese"
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ticket_tools_feature_with_access(
|
.with_module(ticket_tools_feature_with_access(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
TicketFeatureAccess::Lifecycle,
|
TicketFeatureAccess::workspace_authoring(),
|
||||||
))
|
))
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
assert_eq!(
|
||||||
|
report.reports[0].installed_tools,
|
||||||
|
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||||
|
);
|
||||||
let description = pending_tool_description(&pending_tools, "TicketComment");
|
let description = pending_tool_description(&pending_tools, "TicketComment");
|
||||||
assert!(description.contains("Ticket record language: Japanese"));
|
assert!(description.contains("Ticket record language: Japanese"));
|
||||||
assert!(description.contains("durable Ticket record and Ticket tool body text"));
|
assert!(description.contains("durable Ticket record and Ticket tool body text"));
|
||||||
|
|
@ -864,10 +977,13 @@ language = "Japanese"
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
.with_module(ticket_tools_feature(temp.path()))
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert_eq!(report.reports.len(), 1);
|
assert_eq!(report.reports.len(), 1);
|
||||||
assert!(report.reports[0].installed);
|
assert!(report.reports[0].installed);
|
||||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
assert_eq!(
|
||||||
|
report.reports[0].installed_tools,
|
||||||
|
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||||
|
);
|
||||||
assert!(report.reports[0].skipped.is_empty());
|
assert!(report.reports[0].skipped.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -899,7 +1015,7 @@ profile = "project:coder"
|
||||||
.with_module(feature)
|
.with_module(feature)
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert!(report.reports[0].diagnostics.is_empty());
|
assert!(report.reports[0].diagnostics.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,8 +1101,11 @@ provider = "github"
|
||||||
.with_module(ticket_tools_feature(temp.path()))
|
.with_module(ticket_tools_feature(temp.path()))
|
||||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
assert_eq!(
|
||||||
|
report.reports[0].installed_tools,
|
||||||
|
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||||
|
);
|
||||||
assert!(report.reports[0].diagnostics.is_empty());
|
assert!(report.reports[0].diagnostics.is_empty());
|
||||||
assert!(!root.join("open").exists());
|
assert!(!root.join("open").exists());
|
||||||
assert!(!root.join("pending").exists());
|
assert!(!root.join("pending").exists());
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||||
use project_record::validate_record_id;
|
use project_record::validate_record_id;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ticket::config::TicketConfig;
|
use ticket::config::TicketConfig;
|
||||||
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||||
|
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ impl LocalProjectRecordReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||||
let partial = self.ticket_backend.list_partial(TicketFilter::all())?;
|
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
|
||||||
let mut items = partial
|
let mut items = partial
|
||||||
.tickets
|
.tickets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ use ticket::config::{
|
||||||
};
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||||
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
|
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||||
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState,
|
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
|
||||||
|
TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
|
|
@ -45,15 +46,11 @@ pub struct CreateOptions {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ListState {
|
pub enum ListState {
|
||||||
Planning,
|
Active,
|
||||||
Ready,
|
|
||||||
Queued,
|
|
||||||
InProgress,
|
|
||||||
Done,
|
|
||||||
Closed,
|
|
||||||
All,
|
All,
|
||||||
|
States(Vec<TicketListState>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -381,13 +378,9 @@ fn list(
|
||||||
options: ListOptions,
|
options: ListOptions,
|
||||||
) -> Result<TicketCliOutput, TicketCliError> {
|
) -> Result<TicketCliOutput, TicketCliError> {
|
||||||
let filter = match options.state {
|
let filter = match options.state {
|
||||||
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning),
|
ListState::Active => TicketListQuery::active(),
|
||||||
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready),
|
ListState::All => TicketListQuery::all(),
|
||||||
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued),
|
ListState::States(states) => TicketListQuery::states(states),
|
||||||
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
|
|
||||||
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
|
|
||||||
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
|
|
||||||
ListState::All => TicketFilter::all(),
|
|
||||||
};
|
};
|
||||||
let tickets = backend.list(filter)?;
|
let tickets = backend.list(filter)?;
|
||||||
let count = tickets.len();
|
let count = tickets.len();
|
||||||
|
|
@ -750,7 +743,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
||||||
let mut state = ListState::All;
|
let mut state = ListState::Active;
|
||||||
let mut limit = None;
|
let mut limit = None;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
|
|
@ -1042,17 +1035,42 @@ fn option_with_value(
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> {
|
fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
|
||||||
match value {
|
let tokens = raw
|
||||||
"planning" => Ok(ListState::Planning),
|
.split(',')
|
||||||
"ready" => Ok(ListState::Ready),
|
.map(str::trim)
|
||||||
"queued" => Ok(ListState::Queued),
|
.filter(|token| !token.is_empty())
|
||||||
"inprogress" => Ok(ListState::InProgress),
|
.collect::<Vec<_>>();
|
||||||
"done" => Ok(ListState::Done),
|
if tokens.is_empty() {
|
||||||
"closed" => Ok(ListState::Closed),
|
return Err(TicketCliError::new("--state must not be empty"));
|
||||||
"all" => Ok(ListState::All),
|
|
||||||
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
|
|
||||||
}
|
}
|
||||||
|
if tokens.len() == 1 {
|
||||||
|
match tokens[0] {
|
||||||
|
"active" => return Ok(ListState::Active),
|
||||||
|
"all" => return Ok(ListState::All),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if tokens
|
||||||
|
.iter()
|
||||||
|
.any(|token| *token == "active" || *token == "all")
|
||||||
|
{
|
||||||
|
return Err(TicketCliError::new(
|
||||||
|
"--state active/all cannot be mixed with workflow states",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut states = Vec::new();
|
||||||
|
for token in tokens {
|
||||||
|
let state = TicketListState::parse(token).ok_or_else(|| {
|
||||||
|
TicketCliError::new(format!(
|
||||||
|
"invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !states.contains(&state) {
|
||||||
|
states.push(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ListState::States(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
|
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
|
||||||
|
|
@ -1148,7 +1166,7 @@ fn default_author() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
fn help_text() -> &'static str {
|
||||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -1533,6 +1551,29 @@ mod tests {
|
||||||
assert!(err.to_string().contains("use `yoi ticket close"));
|
assert!(err.to_string().contains("use `yoi ticket close"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() {
|
||||||
|
let default = parse_ticket_args(&args(&["list"])).unwrap();
|
||||||
|
match default {
|
||||||
|
TicketCli::Command(TicketCommand::List(options)) => {
|
||||||
|
assert_eq!(options.state, ListState::Active)
|
||||||
|
}
|
||||||
|
other => panic!("unexpected command: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap();
|
||||||
|
match explicit {
|
||||||
|
TicketCli::Command(TicketCommand::List(options)) => assert_eq!(
|
||||||
|
options.state,
|
||||||
|
ListState::States(vec![TicketListState::Planning, TicketListState::Closed])
|
||||||
|
),
|
||||||
|
other => panic!("unexpected command: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err();
|
||||||
|
assert!(mixed.to_string().contains("cannot be mixed"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_help_lists_required_commands() {
|
fn ticket_cli_help_lists_required_commands() {
|
||||||
let help = parse_ticket_args(&args(&["--help"])).unwrap();
|
let help = parse_ticket_args(&args(&["--help"])).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = false; };
|
workers = { enabled = false; };
|
||||||
ticket_orchestration = { enabled = false; };
|
ticket = { enabled = true; thread = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = true; };
|
workers = { enabled = true; };
|
||||||
ticket_orchestration = { enabled = false; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ feature = {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = true; };
|
workers = { enabled = true; };
|
||||||
ticket = { enabled = true; access = "lifecycle"; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
ticket_orchestration = { enabled = false; };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
memory = {
|
memory = {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = false; };
|
workers = { enabled = false; };
|
||||||
ticket_orchestration = { enabled = false; };
|
ticket = { enabled = true; authoring = true; thread = true; intake = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = true; };
|
workers = { enabled = true; };
|
||||||
ticket_orchestration = { enabled = true; };
|
ticket = { enabled = true; thread = true; orchestration_control = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ import "./default.dcdl" // {
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = false; };
|
workers = { enabled = false; };
|
||||||
ticket_orchestration = { enabled = false; };
|
ticket = { enabled = true; thread = true; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user