pod: split ticket feature access levels

This commit is contained in:
Keisuke Hirata 2026-06-07 12:56:13 +09:00
parent 43f273de03
commit 3d662bc40e
No known key found for this signature in database
3 changed files with 207 additions and 7 deletions

View File

@ -8,4 +8,6 @@ pub mod task;
pub mod ticket;
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{TicketFeature, ticket_tools_feature};
pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
};

View File

@ -9,8 +9,7 @@ use std::path::{Path, PathBuf};
use ticket::{
LocalTicketBackend,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::TICKET_TOOL_NAMES,
tool::ticket_tools,
tool::{TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tools},
};
use crate::feature::{
@ -24,27 +23,58 @@ const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority.";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TicketFeatureAccess {
/// Status/diagnostic access for views such as Companion that must not mutate Tickets.
ReadOnly,
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools.
Lifecycle,
}
impl TicketFeatureAccess {
pub fn tool_names(self) -> &'static [&'static str] {
match self {
Self::ReadOnly => &TICKET_READ_ONLY_TOOL_NAMES,
Self::Lifecycle => &TICKET_TOOL_NAMES,
}
}
}
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend_root: PathBuf,
config_error: Option<String>,
access: TicketFeatureAccess,
}
impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle)
}
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
Self {
backend_root: backend_root.into(),
config_error: None,
access,
}
}
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle)
}
pub fn for_workspace_with_access(
workspace: impl AsRef<Path>,
access: TicketFeatureAccess,
) -> Self {
let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) {
Ok(config) => Self::new(config.backend_root().to_path_buf()),
Ok(config) => Self::new_with_access(config.backend_root().to_path_buf(), access),
Err(error) => Self {
backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
config_error: Some(error.to_string()),
access,
},
}
}
@ -53,6 +83,10 @@ impl TicketFeature {
&self.backend_root
}
pub fn access(&self) -> TicketFeatureAccess {
self.access
}
fn authority(&self) -> HostAuthority {
HostAuthority::TicketBackend {
root: self.backend_root.display().to_string(),
@ -87,8 +121,8 @@ impl FeatureModule for TicketFeature {
self.authority(),
AUTHORITY_REASON,
));
for name in TICKET_TOOL_NAMES {
descriptor = descriptor.with_tool(ToolDeclaration::new(name, tool_description(name)));
for name in self.access.tool_names() {
descriptor = descriptor.with_tool(ToolDeclaration::new(*name, tool_description(name)));
}
descriptor
}
@ -116,10 +150,14 @@ impl FeatureModule for TicketFeature {
};
let authority = self.authority();
let backend = LocalTicketBackend::new(usable_root);
let allowed_tool_names = self.access.tool_names();
let mut tools = context.tools();
for definition in ticket_tools(backend) {
let (meta, _) = definition();
let name = meta.name.clone();
if !allowed_tool_names.contains(&name.as_str()) {
continue;
}
tools.register(
ToolContribution::new(name, definition)
.with_required_host_authorities(vec![authority.clone()]),
@ -140,6 +178,12 @@ fn tool_description(name: &str) -> &'static str {
"Append a comment/plan/decision/implementation_report event to a Ticket."
}
"TicketReview" => "Append an approve/request_changes review event to a Ticket.",
"TicketIntakeReady" => {
"Mark an intake Ticket ready and append the typed intake summary/state transition events."
}
"TicketWorkflowState" => {
"Transition Ticket workflow_state; queued -> inprogress is the accepted implementation start, so implementation side effects should happen only after that transition is accepted and recorded."
}
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.",
"TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.",
"TicketDoctor" => "Run typed local Ticket backend consistency checks.",
@ -151,6 +195,13 @@ pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
TicketFeature::for_workspace(workspace)
}
pub fn ticket_tools_feature_with_access(
workspace: impl AsRef<Path>,
access: TicketFeatureAccess,
) -> TicketFeature {
TicketFeature::for_workspace_with_access(workspace, access)
}
#[cfg(test)]
mod tests {
use super::*;
@ -193,6 +244,95 @@ mod tests {
));
}
#[test]
fn read_only_descriptor_declares_only_status_tools() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
let descriptor = feature.descriptor();
assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly);
assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
TICKET_READ_ONLY_TOOL_NAMES
);
assert_eq!(descriptor.requested_host_authorities.len(), 1);
}
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::ReadOnly,
))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
assert_eq!(
report.reports[0].installed_tools,
TICKET_READ_ONLY_TOOL_NAMES
);
let pending_names = pending_tools
.iter()
.map(|definition| definition().0.name)
.collect::<Vec<_>>();
assert_eq!(pending_names, TICKET_READ_ONLY_TOOL_NAMES);
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
assert!(
!report.reports[0]
.installed_tools
.iter()
.any(|tool| tool == name)
);
assert!(!pending_names.iter().any(|tool| tool == name));
}
}
#[test]
fn lifecycle_installation_exposes_lifecycle_tools() {
let temp = TempDir::new().unwrap();
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::Lifecycle,
))
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
assert!(
report.reports[0]
.installed_tools
.iter()
.any(|tool| tool == name)
);
}
assert!(
report.reports[0]
.installed_tools
.iter()
.any(|tool| tool == "TicketIntakeReady")
);
assert!(
report.reports[0]
.installed_tools
.iter()
.any(|tool| tool == "TicketWorkflowState")
);
}
#[test]
fn installs_ticket_tools_when_default_root_is_usable() {
let temp = TempDir::new().unwrap();

View File

@ -42,6 +42,18 @@ pub const TICKET_TOOL_NAMES: [&str; 10] = [
"TicketDoctor",
];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 3] = ["TicketList", "TicketShow", "TicketDoctor"];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
"TicketCreate",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
];
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.";
@ -61,7 +73,9 @@ Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `st
for `workflow_state`, and transitions workflow_state to `ready`.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \
Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \
status; use `TicketStatus` or `TicketClose` for local status changes.";
status; use `TicketStatus` or `TicketClose` for local status changes. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `yoi ticket doctor`.";
@ -989,6 +1003,50 @@ mod tests {
.expect("tool exists")
}
#[test]
fn ticket_tool_name_partitions_are_explicit() {
assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES,
["TicketList", "TicketShow", "TicketDoctor"]
);
assert_eq!(
TICKET_MUTATING_TOOL_NAMES,
[
"TicketCreate",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose"
]
);
for name in TICKET_READ_ONLY_TOOL_NAMES {
assert!(TICKET_TOOL_NAMES.contains(&name));
assert!(!TICKET_MUTATING_TOOL_NAMES.contains(&name));
}
for name in TICKET_MUTATING_TOOL_NAMES {
assert!(TICKET_TOOL_NAMES.contains(&name));
assert!(!TICKET_READ_ONLY_TOOL_NAMES.contains(&name));
}
assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES.len() + TICKET_MUTATING_TOOL_NAMES.len(),
TICKET_TOOL_NAMES.len()
);
}
#[test]
fn workflow_state_tool_description_explains_queued_acceptance() {
let temp = TempDir::new().unwrap();
let definition = ticket_tools(backend(&temp))
.into_iter()
.find(|definition| definition().0.name == "TicketWorkflowState")
.expect("workflow state tool exists");
let (meta, _) = definition();
assert!(meta.description.contains("queued -> inprogress"));
assert!(meta.description.contains("implementation side effects"));
}
#[tokio::test]
async fn ticket_tools_create_list_show_and_doctor() {
let temp = TempDir::new().unwrap();