ticket: split ticket feature access presets

This commit is contained in:
Keisuke Hirata 2026-07-20 19:05:55 +09:00
parent a40d9c2027
commit 2c01e7672b
No known key found for this signature in database
7 changed files with 533 additions and 27 deletions

View File

@ -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,
} }
} }
} }

View File

@ -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,
} }

View File

@ -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());

View File

@ -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 {

View File

@ -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",

View File

@ -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
} }

View File

@ -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();