ticket: split ticket feature access presets
This commit is contained in:
parent
a40d9c2027
commit
2c01e7672b
|
|
@ -128,8 +128,12 @@ impl FeatureFlagConfigPartial {
|
||||||
pub struct TicketFeatureConfigPartial {
|
pub struct TicketFeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
/// Legacy access field. Prefer `preset` for new profile/DCDL authoring.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub access: Option<TicketFeatureAccessConfig>,
|
pub access: Option<TicketFeatureAccessConfig>,
|
||||||
|
/// Semantic Ticket access preset for profile/DCDL authoring.
|
||||||
|
#[serde(default)]
|
||||||
|
pub preset: Option<TicketFeatureAccessConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketFeatureConfigPartial {
|
impl TicketFeatureConfigPartial {
|
||||||
|
|
@ -137,6 +141,7 @@ impl TicketFeatureConfigPartial {
|
||||||
Self {
|
Self {
|
||||||
enabled: other.enabled.or(self.enabled),
|
enabled: other.enabled.or(self.enabled),
|
||||||
access: other.access.or(self.access),
|
access: other.access.or(self.access),
|
||||||
|
preset: other.preset.or(self.preset),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +195,7 @@ 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(),
|
access: value.preset.or(value.access).unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +205,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
||||||
Self {
|
Self {
|
||||||
enabled: Some(value.enabled),
|
enabled: Some(value.enabled),
|
||||||
access: Some(value.access),
|
access: Some(value.access),
|
||||||
|
preset: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,12 @@ impl Default for TicketFeatureConfig {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TicketFeatureAccessConfig {
|
pub enum TicketFeatureAccessConfig {
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
|
WorkspaceAuthoring,
|
||||||
|
Intake,
|
||||||
|
OrchestrationControl,
|
||||||
|
WorkReport,
|
||||||
|
Review,
|
||||||
|
/// Legacy broad mutation preset retained as a migration shim.
|
||||||
Lifecycle,
|
Lifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ use crate::model::{AuthRef, ModelManifest};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
||||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
ScopeConfig, ScopeRule, SkillsConfig, TicketFeatureAccessConfig, WebConfig, WorkerManifest,
|
||||||
WorkerMetaConfig, paths,
|
WorkerManifestConfig, WorkerMetaConfig, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||||
|
|
@ -1012,6 +1012,18 @@ 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 });
|
||||||
|
let ticket_access = match slug {
|
||||||
|
"companion" => TicketFeatureAccessConfig::WorkspaceAuthoring,
|
||||||
|
"intake" => TicketFeatureAccessConfig::Intake,
|
||||||
|
"orchestrator" => TicketFeatureAccessConfig::OrchestrationControl,
|
||||||
|
"coder" => TicketFeatureAccessConfig::WorkReport,
|
||||||
|
"reviewer" => TicketFeatureAccessConfig::Review,
|
||||||
|
_ => TicketFeatureAccessConfig::Lifecycle,
|
||||||
|
};
|
||||||
|
value["feature"]["ticket"] = serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"preset": ticket_access,
|
||||||
|
});
|
||||||
value["feature"]["ticket_orchestration"] =
|
value["feature"]["ticket_orchestration"] =
|
||||||
serde_json::json!({ "enabled": ticket_orchestration });
|
serde_json::json!({ "enabled": ticket_orchestration });
|
||||||
}
|
}
|
||||||
|
|
@ -1439,6 +1451,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_eq!(
|
||||||
|
companion.feature.ticket.access,
|
||||||
|
TicketFeatureAccessConfig::WorkspaceAuthoring
|
||||||
|
);
|
||||||
assert!(!companion.feature.ticket_orchestration.enabled);
|
assert!(!companion.feature.ticket_orchestration.enabled);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
companion.compaction.as_ref().unwrap().threshold,
|
companion.compaction.as_ref().unwrap().threshold,
|
||||||
|
|
@ -1461,6 +1477,10 @@ 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_eq!(
|
||||||
|
intake.feature.ticket.access,
|
||||||
|
TicketFeatureAccessConfig::Intake
|
||||||
|
);
|
||||||
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"));
|
||||||
|
|
@ -1472,6 +1492,10 @@ mod tests {
|
||||||
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_eq!(
|
||||||
|
orchestrator.feature.ticket.access,
|
||||||
|
TicketFeatureAccessConfig::OrchestrationControl
|
||||||
|
);
|
||||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
||||||
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());
|
||||||
|
|
@ -1491,12 +1515,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_eq!(
|
||||||
|
coder.feature.ticket.access,
|
||||||
|
TicketFeatureAccessConfig::WorkReport
|
||||||
|
);
|
||||||
assert!(!coder.feature.ticket_orchestration.enabled);
|
assert!(!coder.feature.ticket_orchestration.enabled);
|
||||||
|
|
||||||
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_eq!(
|
||||||
|
reviewer.feature.ticket.access,
|
||||||
|
TicketFeatureAccessConfig::Review
|
||||||
|
);
|
||||||
assert!(!reviewer.feature.ticket_orchestration.enabled);
|
assert!(!reviewer.feature.ticket_orchestration.enabled);
|
||||||
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());
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,21 @@ impl NewTicket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketItemEdit {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub body: Option<MarkdownText>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketDependencyCheck {
|
||||||
|
pub ticket: TicketSummary,
|
||||||
|
pub blockers: Vec<TicketRelationBlocker>,
|
||||||
|
pub queue_guard: TicketQueueGuard,
|
||||||
|
pub recommended_action: TicketWorkspaceNextAction,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum TicketListState {
|
pub enum TicketListState {
|
||||||
|
|
@ -1391,6 +1406,8 @@ pub trait TicketBackend {
|
||||||
fn list(&self, filter: TicketListQuery) -> 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<()>;
|
||||||
|
|
@ -1449,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,
|
||||||
|
|
@ -1517,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),
|
||||||
|
|
@ -1547,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
|
||||||
|
|
@ -2218,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)?;
|
||||||
|
|
@ -3737,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 {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::{
|
||||||
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
||||||
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
||||||
TicketReview, 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,6 +110,9 @@ 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 query (`active`, `all`, a single workflow \
|
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||||
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||||
|
|
@ -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.",
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -534,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.
|
||||||
|
|
@ -559,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 {
|
||||||
|
|
@ -750,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,
|
||||||
|
|
@ -775,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,
|
||||||
|
|
@ -810,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(
|
||||||
|
|
@ -844,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(
|
||||||
|
|
@ -1015,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(
|
||||||
|
|
@ -1244,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}")))
|
||||||
|
|
@ -1509,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))
|
||||||
}
|
}
|
||||||
|
|
@ -1547,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);
|
||||||
|
|
@ -1559,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>(
|
||||||
|
|
@ -1582,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1627,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",
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,10 @@ 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
|
||||||
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
|
|| !matches!(
|
||||||
|
ticket_feature.access,
|
||||||
|
TicketFeatureAccessConfig::OrchestrationControl | TicketFeatureAccessConfig::Lifecycle
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -679,6 +682,21 @@ where
|
||||||
TicketFeatureAccessConfig::ReadOnly => {
|
TicketFeatureAccessConfig::ReadOnly => {
|
||||||
crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly
|
crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly
|
||||||
}
|
}
|
||||||
|
TicketFeatureAccessConfig::WorkspaceAuthoring => {
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::WorkspaceAuthoring
|
||||||
|
}
|
||||||
|
TicketFeatureAccessConfig::Intake => {
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::Intake
|
||||||
|
}
|
||||||
|
TicketFeatureAccessConfig::OrchestrationControl => {
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::OrchestrationControl
|
||||||
|
}
|
||||||
|
TicketFeatureAccessConfig::WorkReport => {
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::WorkReport
|
||||||
|
}
|
||||||
|
TicketFeatureAccessConfig::Review => {
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::Review
|
||||||
|
}
|
||||||
TicketFeatureAccessConfig::Lifecycle => {
|
TicketFeatureAccessConfig::Lifecycle => {
|
||||||
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
|
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ use ticket::{
|
||||||
tool::{
|
tool::{
|
||||||
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
||||||
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
|
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||||
TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description,
|
TicketToolBackend, ticket_tool_description, ticket_tools,
|
||||||
ticket_tools,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,24 +33,96 @@ The tools operate through the ticket crate backend and do not grant generic file
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum TicketFeatureAccess {
|
pub enum TicketFeatureAccess {
|
||||||
/// Status/diagnostic access for views such as Companion that must not mutate Tickets.
|
/// Status/diagnostic access for views that must not mutate Tickets.
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools.
|
/// User/Companion authoring access for shaping current Ticket specs and queueing work.
|
||||||
|
WorkspaceAuthoring,
|
||||||
|
/// Intake access for creating/refining planning Tickets and marking them ready.
|
||||||
|
Intake,
|
||||||
|
/// Orchestrator control access for state/thread/relation/orchestration operations.
|
||||||
|
OrchestrationControl,
|
||||||
|
/// Coder-style access for reading Tickets and writing implementation reports/comments.
|
||||||
|
WorkReport,
|
||||||
|
/// Reviewer-style access for reading Tickets and writing review/comment thread events.
|
||||||
|
Review,
|
||||||
|
/// Legacy full lifecycle access retained as a migration shim.
|
||||||
Lifecycle,
|
Lifecycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_AUTHORING_BASE_TOOL_NAMES: [&str; 10] = [
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketQueue",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const INTAKE_BASE_TOOL_NAMES: [&str; 8] = [
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketEditItem",
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketIntakeReady",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORCHESTRATION_CONTROL_BASE_TOOL_NAMES: [&str; 8] = [
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketWorkflowState",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORK_REPORT_BASE_TOOL_NAMES: [&str; 5] = [
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const REVIEW_BASE_TOOL_NAMES: [&str; 6] = [
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketDependencyCheck",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RELATION_WRITE_TOOL_NAMES: [&str; 2] = ["TicketRelationRecord", "TicketRelationQuery"];
|
||||||
|
|
||||||
impl TicketFeatureAccess {
|
impl TicketFeatureAccess {
|
||||||
pub fn base_tool_names(self) -> &'static [&'static str] {
|
pub fn base_tool_names(self) -> &'static [&'static str] {
|
||||||
match self {
|
match self {
|
||||||
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES,
|
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES,
|
||||||
|
Self::WorkspaceAuthoring => &WORKSPACE_AUTHORING_BASE_TOOL_NAMES,
|
||||||
|
Self::Intake => &INTAKE_BASE_TOOL_NAMES,
|
||||||
|
Self::OrchestrationControl => &ORCHESTRATION_CONTROL_BASE_TOOL_NAMES,
|
||||||
|
Self::WorkReport => &WORK_REPORT_BASE_TOOL_NAMES,
|
||||||
|
Self::Review => &REVIEW_BASE_TOOL_NAMES,
|
||||||
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES,
|
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
|
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
|
||||||
match self {
|
match self {
|
||||||
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES,
|
Self::ReadOnly | Self::WorkReport | Self::Review => {
|
||||||
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
|
&TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES
|
||||||
|
}
|
||||||
|
Self::WorkspaceAuthoring | Self::Intake => &RELATION_WRITE_TOOL_NAMES,
|
||||||
|
Self::OrchestrationControl | Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,12 +271,6 @@ 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 {
|
|
||||||
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();
|
let mut names = Vec::new();
|
||||||
if self.include_base_tools {
|
if self.include_base_tools {
|
||||||
names.extend_from_slice(self.access.base_tool_names());
|
names.extend_from_slice(self.access.base_tool_names());
|
||||||
|
|
@ -421,6 +486,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(()),
|
||||||
|
|
@ -715,6 +794,74 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_access_presets_expose_role_capability_surfaces() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let workspace_authoring = ticket_tools_feature_with_options(
|
||||||
|
temp.path(),
|
||||||
|
Some(TicketFeatureAccess::WorkspaceAuthoring),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
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_options(
|
||||||
|
temp.path(),
|
||||||
|
Some(TicketFeatureAccess::OrchestrationControl),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
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_options(
|
||||||
|
temp.path(),
|
||||||
|
Some(TicketFeatureAccess::WorkReport),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
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_options(
|
||||||
|
temp.path(),
|
||||||
|
Some(TicketFeatureAccess::Review),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
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]
|
||||||
fn read_only_installation_does_not_expose_mutating_tools() {
|
fn read_only_installation_does_not_expose_mutating_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user