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 {
#[serde(default)]
pub enabled: Option<bool>,
/// Legacy access field. Prefer `preset` for new profile/DCDL authoring.
#[serde(default)]
pub access: Option<TicketFeatureAccessConfig>,
/// Semantic Ticket access preset for profile/DCDL authoring.
#[serde(default)]
pub preset: Option<TicketFeatureAccessConfig>,
}
impl TicketFeatureConfigPartial {
@ -137,6 +141,7 @@ impl TicketFeatureConfigPartial {
Self {
enabled: other.enabled.or(self.enabled),
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 {
Self {
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 {
enabled: Some(value.enabled),
access: Some(value.access),
preset: None,
}
}
}

View File

@ -180,6 +180,12 @@ impl Default for TicketFeatureConfig {
#[serde(rename_all = "snake_case")]
pub enum TicketFeatureAccessConfig {
ReadOnly,
WorkspaceAuthoring,
Intake,
OrchestrationControl,
WorkReport,
Review,
/// Legacy broad mutation preset retained as a migration shim.
Lifecycle,
}

View File

@ -16,8 +16,8 @@ use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, paths,
ScopeConfig, ScopeRule, SkillsConfig, TicketFeatureAccessConfig, WebConfig, WorkerManifest,
WorkerManifestConfig, WorkerMetaConfig, paths,
};
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"]["web"] = serde_json::json!({ "enabled": web });
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"] =
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!(companion.web.is_some());
assert!(companion.feature.ticket.enabled);
assert_eq!(
companion.feature.ticket.access,
TicketFeatureAccessConfig::WorkspaceAuthoring
);
assert!(!companion.feature.ticket_orchestration.enabled);
assert_eq!(
companion.compaction.as_ref().unwrap().threshold,
@ -1461,6 +1477,10 @@ mod tests {
assert!(intake.feature.task.enabled);
assert!(!intake.feature.workers.enabled);
assert!(intake.feature.ticket.enabled);
assert_eq!(
intake.feature.ticket.access,
TicketFeatureAccessConfig::Intake
);
assert!(intake.scope.allow.is_empty());
assert!(intake.delegation_scope.allow.is_empty());
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@ -1472,6 +1492,10 @@ mod tests {
assert!(orchestrator.feature.task.enabled);
assert!(orchestrator.feature.workers.enabled);
assert!(orchestrator.feature.ticket.enabled);
assert_eq!(
orchestrator.feature.ticket.access,
TicketFeatureAccessConfig::OrchestrationControl
);
assert!(orchestrator.feature.ticket_orchestration.enabled);
assert!(orchestrator.scope.allow.is_empty());
assert!(orchestrator.delegation_scope.allow.is_empty());
@ -1491,12 +1515,20 @@ mod tests {
assert!(coder.web.is_some());
assert!(coder.compaction.is_some());
assert!(coder.feature.ticket.enabled);
assert_eq!(
coder.feature.ticket.access,
TicketFeatureAccessConfig::WorkReport
);
assert!(!coder.feature.ticket_orchestration.enabled);
let reviewer = resolve("reviewer");
assert!(reviewer.feature.task.enabled);
assert!(!reviewer.feature.workers.enabled);
assert!(reviewer.feature.ticket.enabled);
assert_eq!(
reviewer.feature.ticket.access,
TicketFeatureAccessConfig::Review
);
assert!(!reviewer.feature.ticket_orchestration.enabled);
assert!(reviewer.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)]
#[serde(rename_all = "lowercase")]
pub enum TicketListState {
@ -1391,6 +1406,8 @@ pub trait TicketBackend {
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>;
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket>;
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck>;
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
@ -1449,6 +1466,13 @@ pub enum TicketBackendOperation {
Create {
input: NewTicket,
},
EditItem {
id: TicketIdOrSlug,
edit: TicketItemEdit,
},
DependencyCheck {
id: TicketIdOrSlug,
},
AddEvent {
id: TicketIdOrSlug,
event: NewTicketEvent,
@ -1517,6 +1541,7 @@ pub enum TicketBackendOperationResult {
Tickets(Vec<TicketSummary>),
Ticket(Ticket),
TicketRef(TicketRef),
DependencyCheck(TicketDependencyCheck),
Relation(TicketRelation),
Relations(Vec<TicketRelation>),
RelationView(TicketRelationView),
@ -1547,6 +1572,12 @@ where
TicketBackendOperation::Create { input } => {
TicketBackendOperationResult::TicketRef(backend.create(input)?)
}
TicketBackendOperation::EditItem { id, edit } => {
TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?)
}
TicketBackendOperation::DependencyCheck { id } => {
TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?)
}
TicketBackendOperation::AddEvent { id, event } => {
backend.add_event(id, event)?;
TicketBackendOperationResult::Unit
@ -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<()> {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
@ -3737,6 +3841,32 @@ fn replace_frontmatter_fields(
Ok(out)
}
fn replace_item_body(content: &str, body: &str) -> std::result::Result<String, String> {
let mut lines = content.lines();
if lines.next() != Some("---") {
return Err("item.md missing frontmatter opener".to_string());
}
let mut frontmatter = vec!["---".to_string()];
let mut found_close = false;
for line in lines.by_ref() {
frontmatter.push(line.to_string());
if line == "---" {
found_close = true;
break;
}
}
if !found_close {
return Err("item.md missing frontmatter closer".to_string());
}
let mut out = frontmatter.join("\n");
out.push_str("\n");
out.push_str(body);
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
let mut out = String::from("<!--");
for (key, value) in attrs {

View File

@ -18,6 +18,7 @@ use crate::{
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@ -33,20 +34,27 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [
pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] =
["TicketList", "TicketShow", "TicketDoctor"];
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
];
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
"TicketRelationRecord",
@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 13] = [
pub const TICKET_TOOL_NAMES: [&str; 16] = [
"TicketCreate",
"TicketEditItem",
"TicketList",
"TicketShow",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
@ -96,6 +110,9 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
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 \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
@ -131,21 +151,26 @@ Ticket id and/or relation kind. This is read-only planning context; Orchestrator
explicit state decisions.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics through the typed backend without shelling out to external commands.";
const DEPENDENCY_CHECK_DESCRIPTION: &str = "Return a structured Ticket dependency / queue readiness \
check through the typed Ticket backend. This read-only guard does not queue or transition the Ticket.";
fn base_tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketList" => LIST_DESCRIPTION,
"TicketShow" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION,
"TicketReview" => REVIEW_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
"TicketClose" => CLOSE_DESCRIPTION,
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
"TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION,
"TicketDoctor" => DOCTOR_DESCRIPTION,
_ => "Ticket backend tool.",
}
@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend {
self.backend.create(input)
}
fn edit_item(&self, id: TicketIdOrSlug, edit: crate::TicketItemEdit) -> TicketResult<Ticket> {
self.backend.edit_item(id, edit)
}
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketDependencyCheck> {
self.backend.dependency_check(id)
}
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
self.backend.add_event(id, event)
}
@ -351,6 +384,21 @@ struct TicketCreateParams {
queued_at: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketEditItemParams {
/// Ticket id.
ticket: String,
/// Optional replacement title.
#[serde(default)]
title: Option<String>,
/// Optional replacement Markdown body.
#[serde(default)]
body: Option<String>,
/// Optional thread author for the audited item_edit event.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketWorkflowStateParam {
@ -534,6 +582,15 @@ struct TicketIntakeReadyParams {
state_change_body: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketQueueParams {
/// Ticket id.
ticket: String,
/// Optional queued_by frontmatter value. Defaults to the backend/user default.
#[serde(default)]
queued_by: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams {
/// Ticket id.
@ -559,6 +616,12 @@ struct TicketCloseParams {
resolution: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams {
/// Ticket id.
ticket: String,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketRelationKindParam {
@ -750,6 +813,11 @@ struct TicketCreateTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketEditItemTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketListTool {
backend: TicketToolBackend,
@ -775,6 +843,11 @@ struct TicketIntakeReadyTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketQueueTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketWorkflowStateTool {
backend: TicketToolBackend,
@ -810,6 +883,11 @@ struct TicketDoctorTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketDependencyCheckTool {
backend: TicketToolBackend,
}
#[async_trait]
impl Tool for TicketCreateTool {
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]
impl Tool for TicketListTool {
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]
impl Tool for TicketWorkflowStateTool {
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> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@ -1509,15 +1662,20 @@ where
fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketDependencyCheck" => {
serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams))
}
"TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
}
@ -1547,11 +1705,13 @@ macro_rules! impl_from_backend {
}
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketListTool);
impl_from_backend!(TicketShowTool);
impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool);
@ -1559,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool);
impl_from_backend!(TicketDependencyCheckTool);
/// Build all MVP Ticket tool definitions over the supplied backend.
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
let backend = backend.into();
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketListTool>("TicketList", backend.clone()),
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
@ -1582,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketOrchestrationPlanQuery",
backend.clone(),
),
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
]
}
@ -1627,18 +1791,21 @@ mod tests {
[
"TicketList",
"TicketShow",
"TicketDependencyCheck",
"TicketDoctor",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery",
"TicketDoctor"
"TicketOrchestrationPlanQuery"
]
);
assert_eq!(
TICKET_MUTATING_TOOL_NAMES,
[
"TicketCreate",
"TicketEditItem",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",

View File

@ -548,7 +548,10 @@ fn install_ticket_event_companion_notify_hook<C, St>(
let ticket_feature = &worker.manifest().feature.ticket;
if !ticket_feature.enabled
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
|| !matches!(
ticket_feature.access,
TicketFeatureAccessConfig::OrchestrationControl | TicketFeatureAccessConfig::Lifecycle
)
{
return;
}
@ -679,6 +682,21 @@ where
TicketFeatureAccessConfig::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 => {
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
}

View File

@ -17,8 +17,7 @@ use ticket::{
tool::{
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description,
ticket_tools,
TicketToolBackend, ticket_tool_description, 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)]
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,
/// 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,
}
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 {
pub fn base_tool_names(self) -> &'static [&'static str] {
match self {
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,
}
}
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
match self {
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES,
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
Self::ReadOnly | Self::WorkReport | Self::Review => {
&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> {
if self.include_base_tools && self.include_orchestration_tools {
return match self.access {
TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(),
TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(),
};
}
let mut names = Vec::new();
if self.include_base_tools {
names.extend_from_slice(self.access.base_tool_names());
@ -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<()> {
match self.invoke(TicketBackendOperation::AddEvent { id, event })? {
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]
fn read_only_installation_does_not_expose_mutating_tools() {
let temp = TempDir::new().unwrap();