ticket: split ticket feature access presets

This commit is contained in:
2026-07-20 19:05:55 +09:00
parent a40d9c2027
commit 2c01e7672b
7 changed files with 533 additions and 27 deletions
+130
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 {
+178 -11
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",