ticket: add workspace ticket config

This commit is contained in:
Keisuke Hirata 2026-06-06 03:33:40 +09:00
parent 3edd68558f
commit 767870a4fb
No known key found for this signature in database
6 changed files with 710 additions and 3 deletions

1
Cargo.lock generated
View File

@ -3632,6 +3632,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]

View File

@ -6,7 +6,9 @@
use std::path::{Path, PathBuf};
use ticket::{LocalTicketBackend, tool::TICKET_TOOL_NAMES, tool::ticket_tools};
use ticket::{
LocalTicketBackend, config::TicketConfig, tool::TICKET_TOOL_NAMES, tool::ticket_tools,
};
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
@ -22,17 +24,26 @@ const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for t
#[derive(Clone, Debug)]
pub struct TicketFeature {
backend_root: PathBuf,
config_error: Option<String>,
}
impl TicketFeature {
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
Self {
backend_root: backend_root.into(),
config_error: None,
}
}
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
Self::new(workspace.as_ref().join("work-items"))
let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) {
Ok(config) => Self::new(config.backend.root),
Err(error) => Self {
backend_root: workspace.join("work-items"),
config_error: Some(error.to_string()),
},
}
}
pub fn backend_root(&self) -> &Path {
@ -80,6 +91,14 @@ impl FeatureModule for TicketFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(error) = &self.config_error {
context
.diagnostics()
.push(FeatureDiagnostic::warning(format!(
"Ticket tools not registered: {error}"
)));
return Ok(());
}
let usable_root = match self.usable_backend_root() {
Ok(root) => root,
Err(reason) => {
@ -142,6 +161,12 @@ mod tests {
std::fs::create_dir_all(root.join("closed")).unwrap();
}
fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap();
}
#[test]
fn descriptor_declares_ticket_tools_and_backend_authority() {
let temp = TempDir::new().unwrap();
@ -182,6 +207,59 @@ mod tests {
assert!(report.reports[0].skipped.is_empty());
}
#[test]
fn installs_ticket_tools_with_configured_backend_root() {
let temp = TempDir::new().unwrap();
write_ticket_config(
temp.path(),
r#"
[backend]
root = "tickets"
[roles.coder]
profile = "project:coder"
"#,
);
make_work_items(&temp.path().join("tickets"));
let feature = ticket_tools_feature(temp.path());
assert_eq!(feature.backend_root(), temp.path().join("tickets"));
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(feature)
.install_into_pending(&mut pending_tools, &mut hooks);
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
assert!(report.reports[0].diagnostics.is_empty());
}
#[test]
fn malformed_ticket_config_fails_closed() {
let temp = TempDir::new().unwrap();
make_work_items(&temp.path().join("work-items"));
write_ticket_config(
temp.path(),
r#"
[roles.operator]
profile = "inherit"
"#,
);
let mut pending_tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(temp.path()))
.install_into_pending(&mut pending_tools, &mut hooks);
assert!(pending_tools.is_empty());
assert!(report.reports[0].installed_tools.is_empty());
assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unknown Ticket role `operator`"));
}
#[test]
fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap();

View File

@ -13,6 +13,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror.workspace = true
toml = { workspace = true }
[dev-dependencies]
tempfile.workspace = true

626
crates/ticket/src/config.rs Normal file
View File

@ -0,0 +1,626 @@
//! Workspace-local Ticket orchestration configuration.
//!
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
//! It intentionally stores lightweight string references for Profile selectors,
//! launch prompts, and workflows so this crate remains independent from `pod`
//! and `manifest` runtime resolution.
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
#[derive(Debug, Error)]
pub enum TicketConfigError {
#[error("failed to read Ticket config {}: {source}", .path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse Ticket config {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid Ticket config {}: {message}", .path.display())]
Invalid { path: PathBuf, message: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketConfig {
pub backend: TicketBackendConfig,
pub roles: TicketRoleProfiles,
}
impl TicketConfig {
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
let workspace_root = workspace_root.as_ref();
Self {
backend: TicketBackendConfig::default_for_workspace(workspace_root),
roles: TicketRoleProfiles::default(),
}
}
pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref();
let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default_for_workspace(workspace_root));
}
Err(source) => return Err(TicketConfigError::Read { path, source }),
};
Self::from_toml(workspace_root, &path, &content)
}
pub fn from_toml(
workspace_root: impl AsRef<Path>,
path: impl AsRef<Path>,
content: &str,
) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref();
let path = path.as_ref();
let raw: RawTicketConfig =
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
raw.resolve(workspace_root, path)
}
pub fn backend_root(&self) -> &Path {
self.backend.root.as_path()
}
pub fn role(&self, role: TicketRole) -> &TicketRoleConfig {
self.roles.get(role)
}
pub fn profile_for(&self, role: TicketRole) -> &ProfileSelectorRef {
&self.role(role).profile
}
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
self.role(role).launch_prompt.as_ref()
}
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
&self.role(role).workflow
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketBackendConfig {
pub kind: TicketBackendKind,
pub root: PathBuf,
}
impl TicketBackendConfig {
pub fn default_for_workspace(workspace_root: &Path) -> Self {
Self {
kind: TicketBackendKind::Local,
root: workspace_root.join("work-items"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketBackendKind {
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketRole {
Intake,
Orchestrator,
Coder,
Reviewer,
Investigator,
}
impl TicketRole {
pub const ALL: [TicketRole; 5] = [
TicketRole::Intake,
TicketRole::Orchestrator,
TicketRole::Coder,
TicketRole::Reviewer,
TicketRole::Investigator,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Intake => "intake",
Self::Orchestrator => "orchestrator",
Self::Coder => "coder",
Self::Reviewer => "reviewer",
Self::Investigator => "investigator",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"intake" => Some(Self::Intake),
"orchestrator" => Some(Self::Orchestrator),
"coder" => Some(Self::Coder),
"reviewer" => Some(Self::Reviewer),
"investigator" => Some(Self::Investigator),
_ => None,
}
}
pub fn default_workflow(self) -> &'static str {
match self {
Self::Intake => "ticket-intake-workflow",
Self::Orchestrator => "ticket-orchestrator-routing",
Self::Coder | Self::Reviewer => "multi-agent-workflow",
Self::Investigator => "ticket-orchestrator-routing",
}
}
}
impl fmt::Display for TicketRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleProfiles {
inner: BTreeMap<TicketRole, TicketRoleConfig>,
}
impl TicketRoleProfiles {
pub fn get(&self, role: TicketRole) -> &TicketRoleConfig {
self.inner
.get(&role)
.expect("TicketRoleProfiles always contains all fixed roles")
}
pub fn iter(&self) -> impl Iterator<Item = (TicketRole, &TicketRoleConfig)> {
TicketRole::ALL
.into_iter()
.map(|role| (role, self.get(role)))
}
}
impl Default for TicketRoleProfiles {
fn default() -> Self {
let inner = TicketRole::ALL
.into_iter()
.map(|role| (role, TicketRoleConfig::default_for_role(role)))
.collect();
Self { inner }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleConfig {
pub profile: ProfileSelectorRef,
pub launch_prompt: Option<PromptRef>,
pub workflow: WorkflowRef,
}
impl TicketRoleConfig {
pub fn default_for_role(role: TicketRole) -> Self {
Self {
profile: ProfileSelectorRef::inherit(),
launch_prompt: None,
workflow: WorkflowRef::from_static(role.default_workflow()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct ProfileSelectorRef(String);
impl ProfileSelectorRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = normalized_non_empty(value, "profile selector")?;
if value.starts_with("path:")
|| value.starts_with('.')
|| value.contains('/')
|| value.ends_with(".lua")
{
return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string());
}
if let Some((source, name)) = value.split_once(':') {
if !matches!(source, "builtin" | "user" | "project") {
return Err(
"profile selector source must be one of `builtin`, `user`, or `project`"
.to_string(),
);
}
if name.trim().is_empty() {
return Err("profile selector registry name must not be empty".to_string());
}
}
Ok(Self(value))
}
pub fn inherit() -> Self {
Self("inherit".to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for ProfileSelectorRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for ProfileSelectorRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for ProfileSelectorRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct PromptRef(String);
impl PromptRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "launch prompt ref").map(Self)
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for PromptRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for PromptRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for PromptRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct WorkflowRef(String);
impl WorkflowRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "workflow ref").map(Self)
}
pub fn from_static(value: &'static str) -> Self {
Self(value.to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for WorkflowRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for WorkflowRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for WorkflowRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
let value = value.into();
let trimmed = value.trim();
if trimmed.is_empty() {
Err(format!("{label} must not be empty"))
} else {
Ok(trimmed.to_string())
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketConfig {
#[serde(default)]
backend: RawBackendConfig,
#[serde(default)]
roles: BTreeMap<String, RawTicketRoleConfig>,
}
impl RawTicketConfig {
fn resolve(
self,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
let mut roles = TicketRoleProfiles::default();
for (name, raw_role) in self.roles {
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
path: path.to_path_buf(),
message: format!("unknown Ticket role `{name}`"),
})?;
roles.inner.insert(role, raw_role.resolve(role));
}
Ok(TicketConfig {
backend: self.backend.resolve(workspace_root),
roles,
})
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBackendConfig {
#[serde(default)]
kind: Option<TicketBackendKind>,
#[serde(default)]
root: Option<PathBuf>,
}
impl RawBackendConfig {
fn resolve(self, workspace_root: &Path) -> TicketBackendConfig {
let root = self.root.unwrap_or_else(|| PathBuf::from("work-items"));
TicketBackendConfig {
kind: self.kind.unwrap_or(TicketBackendKind::Local),
root: join_if_relative(workspace_root, &root),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketRoleConfig {
profile: ProfileSelectorRef,
#[serde(default)]
launch_prompt: Option<PromptRef>,
#[serde(default)]
workflow: Option<WorkflowRef>,
}
impl RawTicketRoleConfig {
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
TicketRoleConfig {
profile: self.profile,
launch_prompt: self.launch_prompt,
workflow: self
.workflow
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
}
}
}
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_config(workspace: &Path, content: &str) {
let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
}
#[test]
fn missing_config_returns_documented_defaults() {
let temp = TempDir::new().unwrap();
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.kind, TicketBackendKind::Local);
assert_eq!(config.backend.root, temp.path().join("work-items"));
for role in TicketRole::ALL {
let role_config = config.role(role);
assert_eq!(role_config.profile.as_str(), "inherit");
assert!(role_config.launch_prompt.is_none());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
#[test]
fn full_config_parses_fixed_role_refs() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
kind = "local"
root = "custom-work-items"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
[roles.investigator]
profile = "default"
launch_prompt = "$workspace/ticket/investigator/launch"
workflow = "ticket-orchestrator-routing"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend.root, temp.path().join("custom-work-items"));
assert_eq!(
config.profile_for(TicketRole::Intake).as_str(),
"project:intake"
);
assert_eq!(
config
.launch_prompt_for(TicketRole::Reviewer)
.unwrap()
.as_str(),
"$workspace/ticket/reviewer/launch"
);
assert_eq!(
config.workflow_for(TicketRole::Investigator).as_str(),
"ticket-orchestrator-routing"
);
}
#[test]
fn partial_role_config_keeps_role_defaults() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "project:coder"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
let coder = config.role(TicketRole::Coder);
assert_eq!(coder.profile.as_str(), "project:coder");
assert!(coder.launch_prompt.is_none());
assert_eq!(coder.workflow.as_str(), "multi-agent-workflow");
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
}
#[test]
fn unknown_roles_are_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.operator]
profile = "inherit"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown Ticket role `operator`"));
}
#[test]
fn unknown_fields_are_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown field"));
assert!(error.to_string().contains("system_instruction"));
}
#[test]
fn unsupported_backend_kind_is_rejected() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
kind = "github"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown variant"));
assert!(error.to_string().contains("github"));
}
#[test]
fn relative_backend_root_resolves_against_workspace() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[backend]
root = "nested/work-items"
"#,
);
let config = TicketConfig::load_workspace(temp.path()).unwrap();
assert_eq!(config.backend_root(), temp.path().join("nested/work-items"));
}
#[test]
fn malformed_refs_are_reported() {
let temp = TempDir::new().unwrap();
write_config(
temp.path(),
r#"
[roles.coder]
profile = "./coder.lua"
"#,
);
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(
error
.to_string()
.contains("path selectors are not supported")
);
}
}

View File

@ -14,6 +14,7 @@ use chrono::Utc;
use fs4::fs_std::FileExt;
use thiserror::Error;
pub mod config;
pub mod tool;
const STATUSES: [TicketStatus; 3] = [

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-UuKaulbFazTojfaCASnLHYMhmDSgX5LQ0ksJRndq+2w=";
cargoHash = "sha256-9/b7tbdMqXbhVpAcCEk+MBoDLhV0M7kIwYn6WUkrD4Y=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,