merge: workspace ticket settings
This commit is contained in:
commit
45783b7488
|
|
@ -1,22 +0,0 @@
|
|||
[backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = ".yoi/tickets"
|
||||
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[roles.intake]
|
||||
profile = "builtin:intake"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[roles.orchestrator]
|
||||
profile = "builtin:orchestrator"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[roles.coder]
|
||||
profile = "builtin:coder"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.reviewer]
|
||||
profile = "builtin:reviewer"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
|
@ -1,3 +1,26 @@
|
|||
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
|
||||
created_at = "2026-06-23T00:00:00Z"
|
||||
display_name = "yoi"
|
||||
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = ".yoi/tickets"
|
||||
|
||||
[ticket.roles.intake]
|
||||
profile = "builtin:intake"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "builtin:orchestrator"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[ticket.roles.coder]
|
||||
profile = "builtin:coder"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[ticket.roles.reviewer]
|
||||
profile = "builtin:reviewer"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ pub enum TicketRoleLaunchError {
|
|||
#[error(transparent)]
|
||||
LaunchConfig(#[from] TicketRoleLaunchConfigError),
|
||||
#[error(
|
||||
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
|
||||
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
|
||||
)]
|
||||
ProfileResolution {
|
||||
role: TicketRole,
|
||||
|
|
@ -287,7 +287,7 @@ pub enum TicketRoleLaunchError {
|
|||
RunAcceptanceTimeout,
|
||||
}
|
||||
|
||||
/// Load `.yoi/ticket.config.toml` from the workspace and construct a launch plan.
|
||||
/// Load Ticket policy from `.yoi/workspace.toml` and construct a launch plan.
|
||||
pub fn plan_ticket_role_launch(
|
||||
context: TicketRoleLaunchContext,
|
||||
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
|
||||
|
|
@ -700,14 +700,14 @@ mod tests {
|
|||
fn write_config(workspace: &std::path::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();
|
||||
std::fs::write(dir.join("workspace.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_builtin_role_config(workspace: &std::path::Path, roles: &[TicketRole]) {
|
||||
let mut config = String::new();
|
||||
let mut config = String::from("[ticket]\n");
|
||||
for role in roles {
|
||||
config.push_str(&format!(
|
||||
"\n[roles.{role}]\nprofile = \"builtin:default\"\n"
|
||||
"\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n"
|
||||
));
|
||||
}
|
||||
write_config(workspace, &config);
|
||||
|
|
@ -883,7 +883,7 @@ mod tests {
|
|||
err.to_string()
|
||||
.contains("Ticket role `coder` is not launch-configured")
|
||||
);
|
||||
assert!(err.to_string().contains("[roles.coder]"));
|
||||
assert!(err.to_string().contains("[ticket.roles.coder]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -913,7 +913,7 @@ root = ".yoi/tickets"
|
|||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "inherit"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -931,7 +931,7 @@ profile = "inherit"
|
|||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "project:no-such-ticket-role-profile"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -944,7 +944,7 @@ profile = "project:no-such-ticket-role-profile"
|
|||
"profile selector `project:no-such-ticket-role-profile` is not resolvable"
|
||||
)
|
||||
);
|
||||
assert!(err.to_string().contains("[roles.intake].profile"));
|
||||
assert!(err.to_string().contains("[ticket.roles.intake].profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -968,7 +968,7 @@ profile = "project:no-such-ticket-role-profile"
|
|||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "builtin:default"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -1035,7 +1035,7 @@ profile = "builtin:default"
|
|||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.reviewer]
|
||||
[ticket.roles.reviewer]
|
||||
profile = "builtin:default"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
workflow = "ticket-review-workflow"
|
||||
|
|
@ -1226,7 +1226,7 @@ workflow = "ticket-review-workflow"
|
|||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "./coder.toml"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -1243,7 +1243,7 @@ profile = "./coder.toml"
|
|||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "inherit"
|
||||
system_instruction = "$workspace/not-supported"
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
//! 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 `worker`
|
||||
//! and `manifest` runtime resolution.
|
||||
//! Durable Ticket policy lives under the `[ticket]` table in tracked
|
||||
//! `.yoi/workspace.toml` workspace settings. The legacy
|
||||
//! `.yoi/ticket.config.toml` file is only a read-only migration fallback when
|
||||
//! workspace settings do not contain any Ticket policy. The config intentionally
|
||||
//! stores lightweight string references for Profile selectors, launch prompts,
|
||||
//! and workflows so this crate remains independent from `worker` and `manifest`
|
||||
//! runtime resolution.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
|
@ -13,6 +16,8 @@ use std::path::{Component, Path, PathBuf};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub const WORKSPACE_SETTINGS_RELATIVE_PATH: &str = ".yoi/workspace.toml";
|
||||
/// Legacy Ticket config path. This is kept as a narrow read-only migration fallback only.
|
||||
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
|
||||
/// Workspace-relative default root for the built-in local Ticket backend.
|
||||
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets";
|
||||
|
|
@ -20,13 +25,17 @@ const DEFAULT_ORCHESTRATION_BRANCH: &str = "orchestration";
|
|||
const DEFAULT_ORCHESTRATION_WORKTREE_DIR: &str = ".worktree";
|
||||
const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration";
|
||||
|
||||
/// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`.
|
||||
/// Return the explicit Workspace settings Ticket policy scaffold written by `yoi ticket init`.
|
||||
///
|
||||
/// The scaffold intentionally configures every fixed Ticket role with a concrete
|
||||
/// profile so strict role launch planning can validate the config without runtime
|
||||
/// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`.
|
||||
/// It intentionally configures every fixed Ticket role with a concrete profile
|
||||
/// so strict role launch planning can validate the config without runtime
|
||||
/// fallback.
|
||||
pub fn ticket_config_scaffold() -> String {
|
||||
let mut out = String::from("[backend]\n");
|
||||
let mut out = String::from(
|
||||
"[ticket]\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# language = \"Japanese\"\n",
|
||||
);
|
||||
out.push_str("\n[ticket.backend]\n");
|
||||
out.push_str(&format!(
|
||||
"provider = \"{}\"\n",
|
||||
TicketBackendProvider::BuiltinYoiLocal.as_str()
|
||||
|
|
@ -36,14 +45,11 @@ pub fn ticket_config_scaffold() -> String {
|
|||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
));
|
||||
out.push_str(
|
||||
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
|
||||
);
|
||||
out.push_str(
|
||||
"\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n",
|
||||
"\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n",
|
||||
);
|
||||
for role in TicketRole::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
||||
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
||||
role.default_profile(),
|
||||
role.default_workflow()
|
||||
));
|
||||
|
|
@ -222,15 +228,81 @@ impl TicketConfig {
|
|||
|
||||
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) {
|
||||
let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
match fs::read_to_string(&workspace_settings_path) {
|
||||
Ok(content) => {
|
||||
if let Some(config) = Self::from_workspace_settings_toml(
|
||||
workspace_root,
|
||||
&workspace_settings_path,
|
||||
&content,
|
||||
)? {
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(source) => {
|
||||
return Err(TicketConfigError::Read {
|
||||
path: workspace_settings_path,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow read-only migration fallback for pre-workspace-settings projects.
|
||||
// As soon as `.yoi/workspace.toml` contains any `[ticket]` table, the
|
||||
// workspace settings authority wins and this legacy file is ignored.
|
||||
let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
let legacy_content = match fs::read_to_string(&legacy_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 }),
|
||||
Err(source) => {
|
||||
return Err(TicketConfigError::Read {
|
||||
path: legacy_path,
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
Self::from_toml(workspace_root, &path, &content)
|
||||
Self::from_toml(workspace_root, &legacy_path, &legacy_content)
|
||||
}
|
||||
|
||||
pub fn from_workspace_settings_toml(
|
||||
workspace_root: impl AsRef<Path>,
|
||||
path: impl AsRef<Path>,
|
||||
content: &str,
|
||||
) -> Result<Option<Self>, TicketConfigError> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let path = path.as_ref();
|
||||
let value: toml::Value =
|
||||
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let Some(ticket_value) = value.get("ticket").cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw: RawWorkspaceTicketConfig =
|
||||
ticket_value
|
||||
.try_into()
|
||||
.map_err(|source| TicketConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
raw.resolve(workspace_root, path).map(Some)
|
||||
}
|
||||
|
||||
pub fn workspace_settings_has_ticket_config(
|
||||
path: impl AsRef<Path>,
|
||||
content: &str,
|
||||
) -> Result<bool, TicketConfigError> {
|
||||
let path = path.as_ref();
|
||||
let value: toml::Value =
|
||||
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(value.get("ticket").is_some())
|
||||
}
|
||||
|
||||
pub fn from_toml(
|
||||
|
|
@ -487,11 +559,11 @@ impl Default for TicketRoleProfiles {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum TicketRoleLaunchConfigError {
|
||||
#[error(
|
||||
"Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with the role builtin profile or another executable concrete profile selector"
|
||||
"Ticket role `{role}` is not launch-configured; add `[ticket.roles.{role}]` with the role builtin profile or another executable concrete profile selector"
|
||||
)]
|
||||
MissingRoleTable { role: TicketRole },
|
||||
#[error(
|
||||
"Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to the role builtin profile or another executable concrete profile selector"
|
||||
"Ticket role `{role}` has no launch profile; set `[ticket.roles.{role}].profile` to the role builtin profile or another executable concrete profile selector"
|
||||
)]
|
||||
MissingProfile { role: TicketRole },
|
||||
#[error(
|
||||
|
|
@ -675,6 +747,19 @@ struct RawTicketConfig {
|
|||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawWorkspaceTicketConfig {
|
||||
#[serde(default)]
|
||||
backend: RawBackendConfig,
|
||||
#[serde(default)]
|
||||
language: Option<TicketRecordLanguage>,
|
||||
#[serde(default)]
|
||||
orchestration: RawTicketOrchestrationConfig,
|
||||
#[serde(default)]
|
||||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawTicketOrchestrationConfig {
|
||||
|
|
@ -722,9 +807,47 @@ impl RawTicketConfig {
|
|||
self,
|
||||
workspace_root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<TicketConfig, TicketConfigError> {
|
||||
resolve_ticket_config_parts(
|
||||
self.backend,
|
||||
self.ticket.resolve(),
|
||||
self.orchestration,
|
||||
self.roles,
|
||||
workspace_root,
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl RawWorkspaceTicketConfig {
|
||||
fn resolve(
|
||||
self,
|
||||
workspace_root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<TicketConfig, TicketConfigError> {
|
||||
resolve_ticket_config_parts(
|
||||
self.backend,
|
||||
TicketRecordConfig {
|
||||
language: self.language,
|
||||
},
|
||||
self.orchestration,
|
||||
self.roles,
|
||||
workspace_root,
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_ticket_config_parts(
|
||||
backend: RawBackendConfig,
|
||||
ticket: TicketRecordConfig,
|
||||
orchestration: RawTicketOrchestrationConfig,
|
||||
raw_roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||
workspace_root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<TicketConfig, TicketConfigError> {
|
||||
let mut roles = TicketRoleProfiles::default();
|
||||
for (name, raw_role) in self.roles {
|
||||
for (name, raw_role) in raw_roles {
|
||||
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
|
||||
path: path.to_path_buf(),
|
||||
message: format!(
|
||||
|
|
@ -740,23 +863,22 @@ impl RawTicketConfig {
|
|||
}
|
||||
}
|
||||
Ok(TicketConfig {
|
||||
backend: self.backend.resolve(workspace_root).map_err(|message| {
|
||||
TicketConfigError::Invalid {
|
||||
backend: backend
|
||||
.resolve(workspace_root)
|
||||
.map_err(|message| TicketConfigError::Invalid {
|
||||
path: path.to_path_buf(),
|
||||
message,
|
||||
}
|
||||
})?,
|
||||
ticket: self.ticket.resolve(),
|
||||
orchestration: self.orchestration.resolve().map_err(|message| {
|
||||
TicketConfigError::Invalid {
|
||||
ticket,
|
||||
orchestration: orchestration
|
||||
.resolve()
|
||||
.map_err(|message| TicketConfigError::Invalid {
|
||||
path: path.to_path_buf(),
|
||||
message,
|
||||
}
|
||||
})?,
|
||||
roles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
|
@ -843,6 +965,12 @@ mod tests {
|
|||
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_workspace_settings(workspace: &Path, content: &str) {
|
||||
let dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("workspace.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_returns_documented_defaults() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -876,39 +1004,123 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn full_config_parses_fixed_role_refs() {
|
||||
fn workspace_settings_take_precedence_over_legacy_ticket_config() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_workspace_settings(
|
||||
temp.path(),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "workspace-tickets"
|
||||
"#,
|
||||
);
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "custom-tickets"
|
||||
root = "legacy-tickets"
|
||||
|
||||
[ticket]
|
||||
language = "English"
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
assert_eq!(config.backend.root, temp.path().join("workspace-tickets"));
|
||||
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_ticket_config_is_read_only_migration_fallback() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_workspace_settings(
|
||||
temp.path(),
|
||||
r#"
|
||||
workspace_id = "00000000-0000-7000-8000-000000000000"
|
||||
display_name = "legacy-fallback"
|
||||
"#,
|
||||
);
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "legacy-tickets"
|
||||
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
"#,
|
||||
);
|
||||
|
||||
[orchestration]
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
assert_eq!(config.backend.root, temp.path().join("legacy-tickets"));
|
||||
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_workspace_ticket_table_uses_workspace_defaults_and_ignores_legacy() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_workspace_settings(
|
||||
temp.path(),
|
||||
r#"
|
||||
[ticket]
|
||||
"#,
|
||||
);
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "legacy-tickets"
|
||||
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
assert_eq!(config.backend.root, temp.path().join(".yoi/tickets"));
|
||||
assert_eq!(config.ticket_record_language(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_config_parses_fixed_role_refs() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_workspace_settings(
|
||||
temp.path(),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "custom-tickets"
|
||||
|
||||
[ticket.orchestration]
|
||||
branch = "orchestration/custom-panel"
|
||||
worktree_dir = "custom-worktrees"
|
||||
worktree_name = "custom-orchestrator"
|
||||
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[roles.orchestrator]
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "inherit"
|
||||
launch_prompt = "$workspace/ticket/coder/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.reviewer]
|
||||
[ticket.roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
|
@ -960,28 +1172,30 @@ workflow = "multi-agent-workflow"
|
|||
let temp = TempDir::new().unwrap();
|
||||
let scaffold = ticket_config_scaffold();
|
||||
|
||||
assert!(scaffold.contains("[backend]\n"));
|
||||
assert!(scaffold.contains("[ticket]\n"));
|
||||
assert!(scaffold.contains("[ticket.backend]\n"));
|
||||
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
||||
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
||||
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
|
||||
assert!(scaffold.contains("# language = \"Japanese\""));
|
||||
assert!(scaffold.contains(
|
||||
"# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\""
|
||||
"# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\""
|
||||
));
|
||||
for role in TicketRole::ALL {
|
||||
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
||||
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
|
||||
assert!(scaffold.contains(&format!(
|
||||
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
||||
role.default_profile(),
|
||||
role.default_workflow()
|
||||
)));
|
||||
}
|
||||
assert!(!scaffold.contains("[roles.investigator]"));
|
||||
assert!(!scaffold.contains("[ticket.roles.investigator]"));
|
||||
|
||||
let config = TicketConfig::from_toml(
|
||||
let config = TicketConfig::from_workspace_settings_toml(
|
||||
temp.path(),
|
||||
temp.path().join(TICKET_CONFIG_RELATIVE_PATH),
|
||||
temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH),
|
||||
&scaffold,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
|
||||
assert_eq!(config.orchestration.branch_name(), None);
|
||||
|
|
|
|||
|
|
@ -94,13 +94,13 @@ fn ensure_and_restore_use_configured_orchestration_layout() {
|
|||
write_test_ticket_config(
|
||||
&root,
|
||||
r#"
|
||||
[orchestration]
|
||||
[ticket.orchestration]
|
||||
branch = "orchestration/custom-panel"
|
||||
worktree_dir = "custom-worktrees"
|
||||
worktree_name = "panel"
|
||||
"#,
|
||||
);
|
||||
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
|
||||
run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap();
|
||||
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
|
||||
|
||||
let resolved = resolved_orchestration_worktree_layout(&root).unwrap();
|
||||
|
|
@ -126,7 +126,7 @@ fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_opera
|
|||
write_test_ticket_config(
|
||||
&root,
|
||||
r#"
|
||||
[orchestration]
|
||||
[ticket.orchestration]
|
||||
branch = "orchestration/bad:branch"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -145,11 +145,11 @@ fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout()
|
|||
write_test_ticket_config(
|
||||
&root,
|
||||
r#"
|
||||
[orchestration]
|
||||
[ticket.orchestration]
|
||||
branch = "orchestration/custom-panel"
|
||||
"#,
|
||||
);
|
||||
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
|
||||
run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap();
|
||||
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
|
||||
let layout = resolved_orchestration_worktree_layout(&root).unwrap();
|
||||
run_test_git(
|
||||
|
|
@ -259,7 +259,7 @@ fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() {
|
|||
fn write_test_ticket_config(root: &Path, content: &str) {
|
||||
let config_dir = root.join(".yoi");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap();
|
||||
std::fs::write(config_dir.join("workspace.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
fn init_test_repo(root: &Path) {
|
||||
|
|
@ -337,8 +337,8 @@ fn ticket_workspace(
|
|||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".yoi/ticket.config.toml"),
|
||||
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
||||
temp.path().join(".yoi/workspace.toml"),
|
||||
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
|
|
@ -712,7 +712,7 @@ async fn ticket_close_action_blocks_non_done_ticket_without_mutation() {
|
|||
#[tokio::test]
|
||||
async fn ticket_action_rejects_stale_absent_config_without_mutation() {
|
||||
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config");
|
||||
fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap();
|
||||
fs::remove_file(temp.path().join(".yoi/workspace.toml")).unwrap();
|
||||
|
||||
let error =
|
||||
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
||||
|
|
@ -1059,8 +1059,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
|||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".yoi/ticket.config.toml"),
|
||||
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
||||
temp.path().join(".yoi/workspace.toml"),
|
||||
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use std::time::Instant;
|
|||
|
||||
use protocol::WorkerStatus;
|
||||
use ticket::config::{
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||
TicketOrchestrationConfig,
|
||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TicketOrchestrationConfig,
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||
|
|
@ -567,8 +567,41 @@ pub(crate) fn decide_orchestrator_lifecycle(
|
|||
}
|
||||
|
||||
pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
|
||||
let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
match config_path.symlink_metadata() {
|
||||
let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
match settings_path.symlink_metadata() {
|
||||
Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
|
||||
"{} exists but is not a regular file",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)),
|
||||
Ok(_) => match std::fs::read_to_string(&settings_path) {
|
||||
Ok(content) => {
|
||||
match TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content) {
|
||||
Ok(true) => match TicketConfig::load_workspace(workspace_root) {
|
||||
Ok(_) => TicketConfigAvailability::Usable,
|
||||
Err(error) => TicketConfigAvailability::Unusable(error.to_string()),
|
||||
},
|
||||
Ok(false) => legacy_ticket_config_availability(workspace_root),
|
||||
Err(error) => TicketConfigAvailability::Unusable(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err(error) => TicketConfigAvailability::Unusable(format!(
|
||||
"could not read {}: {error}",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)),
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
legacy_ticket_config_availability(workspace_root)
|
||||
}
|
||||
Err(error) => TicketConfigAvailability::Unusable(format!(
|
||||
"could not inspect {}: {error}",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
|
||||
let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
match legacy_path.symlink_metadata() {
|
||||
Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
|
||||
"{} exists but is not a regular file",
|
||||
TICKET_CONFIG_RELATIVE_PATH
|
||||
|
|
@ -608,12 +641,13 @@ fn load_orchestration_ticket_overlay(
|
|||
}
|
||||
match validate_orchestration_overlay_source(workspace_root, &layout) {
|
||||
Ok(()) => {
|
||||
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language())
|
||||
.unwrap_or_else(|message| OrchestrationTicketOverlay {
|
||||
load_orchestration_ticket_overlay_states(&layout.path).unwrap_or_else(|message| {
|
||||
OrchestrationTicketOverlay {
|
||||
states: BTreeMap::new(),
|
||||
diagnostics: vec![bounded_panel_diagnostic(format!(
|
||||
"Orchestration Ticket overlay unavailable: {message}"
|
||||
))],
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(message) => OrchestrationTicketOverlay {
|
||||
|
|
@ -639,19 +673,25 @@ fn orchestration_worktree_layout(
|
|||
|
||||
fn load_orchestration_ticket_overlay_states(
|
||||
worktree_root: &Path,
|
||||
record_language: Option<&str>,
|
||||
) -> Result<OrchestrationTicketOverlay, String> {
|
||||
let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
|
||||
let overlay_config = TicketConfig::load_workspace(worktree_root).map_err(|error| {
|
||||
format!(
|
||||
"orchestration worktree {} has unusable Ticket settings: {error}",
|
||||
worktree_root.display()
|
||||
)
|
||||
})?;
|
||||
let ticket_root = overlay_config.backend_root();
|
||||
if !ticket_root.is_dir() {
|
||||
return Ok(OrchestrationTicketOverlay {
|
||||
states: BTreeMap::new(),
|
||||
diagnostics: vec![bounded_panel_diagnostic(format!(
|
||||
"Orchestration worktree has no {} directory",
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
"Orchestration worktree configured Ticket backend root {} is not a directory",
|
||||
ticket_root.display()
|
||||
))],
|
||||
});
|
||||
}
|
||||
let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language);
|
||||
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
|
||||
.with_record_language(overlay_config.ticket_record_language());
|
||||
let partial = backend
|
||||
.list_partial(TicketFilter::all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
|
@ -1763,11 +1803,17 @@ mod tests {
|
|||
}
|
||||
|
||||
fn write_ticket_config(workspace_root: &Path) {
|
||||
write_ticket_config_with_root(workspace_root, ".yoi/tickets");
|
||||
}
|
||||
|
||||
fn write_ticket_config_with_root(workspace_root: &Path, root: &str) {
|
||||
let config_dir = workspace_root.join(".yoi");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
config_dir.join("ticket.config.toml"),
|
||||
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
||||
config_dir.join("workspace.toml"),
|
||||
format!(
|
||||
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"{root}\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
|
@ -1815,8 +1861,12 @@ mod tests {
|
|||
}
|
||||
|
||||
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) {
|
||||
copy_ticket_to_overlay_root(workspace_root, &orchestration_root.join(".yoi/tickets"), id);
|
||||
}
|
||||
|
||||
fn copy_ticket_to_overlay_root(workspace_root: &Path, overlay_root: &Path, id: &str) {
|
||||
let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id);
|
||||
let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id);
|
||||
let overlay_ticket_dir = overlay_root.join(id);
|
||||
fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(&overlay_ticket_dir).unwrap();
|
||||
fs::copy(
|
||||
|
|
@ -1987,6 +2037,40 @@ mod tests {
|
|||
assert!(model.rows.iter().all(|row| row.title != "Overlay Only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_panel_orchestration_overlay_uses_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
init_git_repo(temp.path());
|
||||
write_ticket_config(temp.path());
|
||||
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
|
||||
write_ticket_config_with_root(&orchestration_root, "configured-overlay-tickets");
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let id = create_ticket_with_id(&backend, "Overlay Custom Root", |input| {
|
||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
});
|
||||
let configured_overlay_root = orchestration_root.join("configured-overlay-tickets");
|
||||
copy_ticket_to_overlay_root(temp.path(), &configured_overlay_root, &id);
|
||||
let overlay_backend = LocalTicketBackend::new(&configured_overlay_root);
|
||||
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
|
||||
|
||||
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||
|
||||
assert!(model.header.diagnostics.is_empty());
|
||||
let row = ticket_row_by_title(&model, "Overlay Custom Root");
|
||||
assert_eq!(row.status, "q→prog");
|
||||
assert_eq!(
|
||||
row.ticket
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.orchestration_overlay
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.workflow_state,
|
||||
TicketWorkflowState::InProgress
|
||||
);
|
||||
assert!(!orchestration_root.join(".yoi/tickets").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket()
|
||||
{
|
||||
|
|
@ -2233,8 +2317,8 @@ mod tests {
|
|||
let config_dir = temp.path().join(".yoi");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
config_dir.join("ticket.config.toml"),
|
||||
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
|
||||
config_dir.join("workspace.toml"),
|
||||
"[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -2678,11 +2762,11 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn existing_non_file_ticket_config_is_unusable_not_absent() {
|
||||
fn existing_non_file_workspace_settings_is_unusable_not_absent() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let config_parent = temp.path().join(".yoi");
|
||||
fs::create_dir_all(&config_parent).unwrap();
|
||||
fs::create_dir(config_parent.join("ticket.config.toml")).unwrap();
|
||||
fs::create_dir(config_parent.join("workspace.toml")).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
ticket_config_availability(temp.path()),
|
||||
|
|
|
|||
|
|
@ -736,8 +736,8 @@ where
|
|||
"ticket tools require local Worker filesystem authority",
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::ticket::TicketFeatureBackend::Local {
|
||||
root: ticket_cwd.clone(),
|
||||
crate::feature::builtin::ticket::TicketFeatureBackend::LocalWorkspace {
|
||||
workspace_root: ticket_cwd.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ pub enum TicketFeatureBackend {
|
|||
Local {
|
||||
root: PathBuf,
|
||||
},
|
||||
LocalWorkspace {
|
||||
workspace_root: PathBuf,
|
||||
},
|
||||
WorkspaceHttp {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
|
|
@ -125,6 +128,13 @@ impl TicketFeature {
|
|||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
|
||||
return Self::for_workspace_with_options(
|
||||
workspace_root,
|
||||
access,
|
||||
include_orchestration_tools,
|
||||
);
|
||||
}
|
||||
Self {
|
||||
backend,
|
||||
record_language: None,
|
||||
|
|
@ -180,6 +190,7 @@ impl TicketFeature {
|
|||
pub fn backend_root(&self) -> Option<&Path> {
|
||||
match &self.backend {
|
||||
TicketFeatureBackend::Local { root } => Some(root),
|
||||
TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root),
|
||||
TicketFeatureBackend::WorkspaceHttp { .. } => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -219,7 +230,8 @@ impl TicketFeature {
|
|||
}
|
||||
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
|
||||
match &self.backend {
|
||||
TicketFeatureBackend::Local { root: _ } => {
|
||||
TicketFeatureBackend::Local { root: _ }
|
||||
| TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => {
|
||||
let usable_root = match self.usable_backend_root() {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
|
|
@ -606,7 +618,7 @@ mod tests {
|
|||
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();
|
||||
std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
fn pending_tool_description(
|
||||
|
|
@ -853,11 +865,11 @@ language = "Japanese"
|
|||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "tickets"
|
||||
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "project:coder"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -886,7 +898,7 @@ profile = "project:coder"
|
|||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.operator]
|
||||
[ticket.roles.operator]
|
||||
profile = "inherit"
|
||||
"#,
|
||||
);
|
||||
|
|
@ -911,7 +923,7 @@ profile = "inherit"
|
|||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
[ticket.backend]
|
||||
provider = "github"
|
||||
"#,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
|
|||
|
||||
/// Stable local Workspace identity persisted as a tracked, safe project record.
|
||||
///
|
||||
/// The v0 TOML schema intentionally contains identity metadata only:
|
||||
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected
|
||||
/// instead of preserved because this loader cannot safely round-trip future local
|
||||
/// runtime settings without risking accidental path or secret persistence.
|
||||
/// The v0 TOML schema contains identity metadata plus optional tracked project
|
||||
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
|
||||
/// here because this loader cannot safely round-trip future local runtime settings
|
||||
/// without risking accidental path or secret persistence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceIdentity {
|
||||
pub workspace_id: String,
|
||||
|
|
@ -29,6 +29,8 @@ struct WorkspaceIdentityFile {
|
|||
workspace_id: String,
|
||||
created_at: String,
|
||||
display_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ticket: Option<toml::Value>,
|
||||
}
|
||||
|
||||
impl WorkspaceIdentity {
|
||||
|
|
@ -112,6 +114,7 @@ impl WorkspaceIdentity {
|
|||
workspace_id: self.workspace_id.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
ticket: None,
|
||||
})
|
||||
.map_err(|error| {
|
||||
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::config::TicketConfig;
|
||||
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
|
@ -17,13 +18,16 @@ pub struct LocalProjectRecordReader {
|
|||
}
|
||||
|
||||
impl LocalProjectRecordReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> {
|
||||
let workspace_root = workspace_root.into();
|
||||
let ticket_root = workspace_root.join(".yoi/tickets");
|
||||
Self {
|
||||
let ticket_config = TicketConfig::load_workspace(&workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf())
|
||||
.with_record_language(ticket_config.ticket_record_language());
|
||||
Ok(Self {
|
||||
workspace_root,
|
||||
ticket_backend: LocalTicketBackend::new(ticket_root),
|
||||
}
|
||||
ticket_backend,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
|
|
@ -297,7 +301,7 @@ mod tests {
|
|||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path());
|
||||
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.record_authority, "local_yoi_project_records");
|
||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||
|
|
@ -313,9 +317,41 @@ mod tests {
|
|||
let objective = reader.objective("00000000001J3").unwrap();
|
||||
assert!(objective.body.contains("Objective body"));
|
||||
}
|
||||
#[test]
|
||||
fn reads_tickets_from_workspace_settings_backend_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/workspace.toml"),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "project-records/tickets"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_ticket_at(
|
||||
&dir.path().join("project-records/tickets"),
|
||||
"00000000001J4",
|
||||
"Configured root",
|
||||
"ready",
|
||||
);
|
||||
write_ticket(dir.path(), "00000000001J5", "Default root", "ready");
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.items.len(), 1);
|
||||
assert_eq!(tickets.items[0].id, "00000000001J4");
|
||||
}
|
||||
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
|
||||
let ticket_dir = root.join(".yoi/tickets").join(id);
|
||||
write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
|
||||
}
|
||||
|
||||
fn write_ticket_at(ticket_root: &Path, id: &str, title: &str, state: &str) {
|
||||
let ticket_dir = ticket_root.join(id);
|
||||
fs::create_dir_all(&ticket_dir).unwrap();
|
||||
fs::write(
|
||||
ticket_dir.join("item.md"),
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ impl WorkspaceApi {
|
|||
let companion = Arc::new(CompanionConsole::disabled());
|
||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
|
|
@ -1248,11 +1248,10 @@ async fn scoped_ticket_backend_operation(
|
|||
Json(operation): Json<TicketBackendOperation>,
|
||||
) -> ApiResult<Json<TicketBackendHttpResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let backend = LocalTicketBackend::new(
|
||||
api.config
|
||||
.workspace_root
|
||||
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||
);
|
||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let response = match execute_ticket_backend_operation(&backend, operation) {
|
||||
Ok(result) => TicketBackendHttpResponse::Ok { result },
|
||||
Err(error) => TicketBackendHttpResponse::Error {
|
||||
|
|
@ -5800,6 +5799,53 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/workspace.toml"),
|
||||
format!(
|
||||
"workspace_id = \"{TEST_WORKSPACE_ID}\"\ncreated_at = \"{TEST_CREATED_AT}\"\ndisplay_name = \"Endpoint Test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"server-tickets\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
|
||||
let Json(response) = scoped_ticket_backend_operation(
|
||||
State(api),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(TicketBackendOperation::Create {
|
||||
input: ticket::NewTicket::new("Endpoint configured root"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("ticket backend operation failed: {}", error.error));
|
||||
|
||||
let ticket_ref = match response {
|
||||
TicketBackendHttpResponse::Ok {
|
||||
result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref),
|
||||
} => ticket_ref,
|
||||
other => panic!("unexpected ticket backend response: {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
dir.path()
|
||||
.join("server-tickets")
|
||||
.join(&ticket_ref.id)
|
||||
.join("item.md")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join(".yoi/tickets")
|
||||
.join(&ticket_ref.id)
|
||||
.join("item.md")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
WorkspaceApi::new_with_execution_backend(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use ticket::config::{
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||
ticket_config_scaffold,
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
|
|
@ -252,11 +254,12 @@ fn run_command(
|
|||
}
|
||||
|
||||
fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
if config_path.exists() {
|
||||
let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
if legacy_config_path.exists() {
|
||||
return Err(TicketCliError::new(format!(
|
||||
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.",
|
||||
config_path.display()
|
||||
"legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.",
|
||||
legacy_config_path.display(),
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -266,28 +269,96 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||
fs::create_dir_all(&tickets_dir)?;
|
||||
fs::write(tickets_dir.join(".gitkeep"), b"")?;
|
||||
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let scaffold = ticket_config_scaffold();
|
||||
let created_settings = if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)?;
|
||||
if TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content)? {
|
||||
return Err(TicketCliError::new(format!(
|
||||
"workspace Ticket settings already exist at {}; refusing to overwrite. Edit the [ticket] table manually before running `yoi ticket init`.",
|
||||
settings_path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&settings_path)?;
|
||||
if !content.ends_with('\n') {
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
file.write_all(b"\n")?;
|
||||
file.write_all(scaffold.as_bytes())?;
|
||||
false
|
||||
} else {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&config_path)
|
||||
.open(&settings_path)
|
||||
.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
TicketCliError::new(format!(
|
||||
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.",
|
||||
config_path.display()
|
||||
"workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.",
|
||||
settings_path.display()
|
||||
))
|
||||
} else {
|
||||
TicketCliError::from(error)
|
||||
}
|
||||
})?;
|
||||
file.write_all(ticket_config_scaffold().as_bytes())?;
|
||||
let identity = workspace_settings_identity_header(workspace)?;
|
||||
file.write_all(identity.as_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
file.write_all(scaffold.as_bytes())?;
|
||||
true
|
||||
};
|
||||
|
||||
let verb = if created_settings {
|
||||
"created"
|
||||
} else {
|
||||
"updated"
|
||||
};
|
||||
Ok(success(format!(
|
||||
"created\t{}\nensured\t{}\n",
|
||||
TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
"{verb}\t{}\nensured\t{}\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
)))
|
||||
}
|
||||
|
||||
fn workspace_settings_identity_header(workspace: &Path) -> Result<String, TicketCliError> {
|
||||
let display_name = workspace
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or("workspace");
|
||||
if display_name.contains('\0') || display_name.chars().any(|ch| ch.is_control()) {
|
||||
return Err(TicketCliError::new(
|
||||
"workspace display name derived from path must not contain control characters",
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"workspace_id = \"{}\"\ncreated_at = \"{}\"\ndisplay_name = \"{}\"\n",
|
||||
workspace_settings_uuid_v7(workspace),
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
display_name.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
))
|
||||
}
|
||||
|
||||
fn workspace_settings_uuid_v7(workspace: &Path) -> String {
|
||||
let now = Utc::now();
|
||||
let timestamp_ms = (now.timestamp_millis().max(0) as u64) & 0xffff_ffff_ffff;
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
workspace.hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
now.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
.hash(&mut hasher);
|
||||
let random = hasher.finish();
|
||||
|
||||
let time_low = (timestamp_ms >> 16) as u32;
|
||||
let time_mid = (timestamp_ms & 0xffff) as u16;
|
||||
let version_and_rand = 0x7000 | (((random >> 52) as u16) & 0x0fff);
|
||||
let variant_and_rand = 0x8000 | (((random >> 38) as u16) & 0x3fff);
|
||||
let node = random & 0xffff_ffff_ffff;
|
||||
format!(
|
||||
"{time_low:08x}-{time_mid:04x}-{version_and_rand:04x}-{variant_and_rand:04x}-{node:012x}"
|
||||
)
|
||||
}
|
||||
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
|
|
@ -1077,7 +1148,7 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1106,33 +1177,31 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_init_writes_explicit_ticket_config_scaffold() {
|
||||
fn ticket_cli_init_writes_explicit_workspace_ticket_settings() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
assert!(
|
||||
initialized
|
||||
.stdout
|
||||
.contains("created\t.yoi/ticket.config.toml")
|
||||
);
|
||||
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
|
||||
assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
|
||||
assert!(temp.path().join(".yoi/tickets").exists());
|
||||
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
|
||||
|
||||
let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap();
|
||||
assert!(config.contains("[backend]\n"));
|
||||
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
|
||||
assert!(config.contains("workspace_id = \""));
|
||||
assert!(config.contains("[ticket]\n"));
|
||||
assert!(config.contains("[ticket.backend]\n"));
|
||||
assert!(config.contains("provider = \"builtin:yoi_local\""));
|
||||
assert!(config.contains("root = \".yoi/tickets\""));
|
||||
assert!(config.contains("# [ticket]\n# language = \"Japanese\""));
|
||||
assert!(config.contains("# language = \"Japanese\""));
|
||||
for role in TicketRole::ALL {
|
||||
assert!(config.contains(&format!(
|
||||
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
||||
role.default_profile(),
|
||||
role.default_workflow()
|
||||
)));
|
||||
}
|
||||
assert!(!config.contains("[roles.investigator]"));
|
||||
assert!(!config.contains("[ticket.roles.investigator]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1148,9 +1217,9 @@ mod tests {
|
|||
|
||||
let cli = parse_ticket_args(&args(&["init"])).unwrap();
|
||||
let err = run_in_workspace(cli, temp.path()).unwrap_err();
|
||||
assert!(err.to_string().contains("already exists"));
|
||||
assert!(err.to_string().contains("refusing to overwrite"));
|
||||
assert!(err.to_string().contains("yoi ticket init"));
|
||||
assert!(err.to_string().contains("legacy ticket config exists"));
|
||||
assert!(err.to_string().contains("obsolete and read-only"));
|
||||
assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path).unwrap(),
|
||||
"[backend]\nprovider = \"builtin:yoi_local\"\n"
|
||||
|
|
@ -1414,8 +1483,8 @@ mod tests {
|
|||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".yoi/ticket.config.toml"),
|
||||
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
temp.path().join(".yoi/workspace.toml"),
|
||||
"[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ This document records the design/audit decision for Ticket `00001KTRKZ14C`. It d
|
|||
| `multi-agent-workflow` | Partly. The sibling coder/reviewer loop, intent packet, branch-local review, and merge-ready dossier are useful product concepts. | Heavy Yoi dogfood specifics: Git worktrees, commits, cargo/nix validation, branch cleanup, docs/report conventions, and parent/child policy. | Split into builtin `multi-agent-workflow` for role loop + dossier, and workspace dogfood extension for Git worktree/cargo/nix mechanics. |
|
||||
| `worktree-workflow` | Not resident public core. | Almost entirely this repository's Git worktree mechanics and `.yoi` path exclusions. | Keep workspace-local dogfood workflow. Optionally create a non-resident builtin `git-worktree-isolation` later, disabled unless explicitly selected. |
|
||||
|
||||
## Slug and `.yoi/ticket.config.toml` migration decision
|
||||
## Slug and workspace Ticket settings migration decision
|
||||
|
||||
Use explicit dogfood slugs for workflows whose semantics differ from the public builtin workflow. Same-slug workspace overrides are allowed for local wording/policy tweaks of the same public contract, but they should not be used to hide Yoi repository Git/worktree/cargo/nix/merge semantics behind a public slug. This avoids accidental shadowing when a user expects `multi-agent-workflow` to mean the generic builtin role loop.
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ Planned selector mapping for this repository after builtin workflow loading exis
|
|||
|
||||
| Role/config surface | Workflow selector | Source intent | Rationale |
|
||||
| --- | --- | --- | --- |
|
||||
| `[roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
|
||||
| `[roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
|
||||
| `[roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
|
||||
| `[roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
|
||||
| `[ticket.roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
|
||||
| `[ticket.roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
|
||||
| `[ticket.roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
|
||||
| `[ticket.roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
|
||||
| compatibility references | `ticket-preflight-workflow` | builtin compatibility alias or workspace compatibility file, non-resident | Do not keep it as a role default. It should point to planning/requirements sync language only. |
|
||||
| dogfood worktree helper | `yoi-dogfood-worktree-workflow` | workspace-local helper, not a role default by itself | Referenced from `yoi-dogfood-multi-agent-workflow`; keeps Git worktree mechanics out of generic builtin workflows. |
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ Builtin workflows and active workspace workflows should remove or replace the fo
|
|||
|
||||
3. **Dogfood workflow retention**
|
||||
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics.
|
||||
- Ensure `.yoi/ticket.config.toml` role workflow selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
|
||||
- Ensure `.yoi/workspace.toml` `[ticket.roles.*].workflow` selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
|
||||
|
||||
4. **Vocabulary migration**
|
||||
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language.
|
||||
|
|
|
|||
|
|
@ -107,31 +107,34 @@ The first version intentionally does not implement roadmap scheduling, milestone
|
|||
|
||||
## Ticket configuration
|
||||
|
||||
Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present.
|
||||
Workspace Ticket policy is configured by the tracked workspace settings file `.yoi/workspace.toml` under the `[ticket]` table. The old `.yoi/ticket.config.toml` file is obsolete: current code only reads it as a narrow read-only migration fallback when `.yoi/workspace.toml` has no `[ticket]` table. Workspace settings take precedence as soon as `[ticket]` exists.
|
||||
|
||||
MVP shape:
|
||||
|
||||
```toml
|
||||
[backend]
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = ".yoi/tickets"
|
||||
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[roles.orchestrator]
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "project:coder"
|
||||
launch_prompt = "$workspace/ticket/coder/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.reviewer]
|
||||
[ticket.roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
|
@ -145,10 +148,10 @@ Fixed roles are:
|
|||
- `reviewer`
|
||||
|
||||
This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration.
|
||||
Stale `[roles.investigator]` config is rejected as an unsupported fixed role; remove it and,
|
||||
Stale `[ticket.roles.investigator]` config is rejected as an unsupported fixed role; remove it and,
|
||||
when a spike is useful, let the Orchestrator create an ordinary task-specific read-only helper Worker.
|
||||
|
||||
`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. `ticket.config.toml` does not have a role-level `system_instruction` field.
|
||||
`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. Workspace Ticket settings do not have a role-level `system_instruction` field.
|
||||
|
||||
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
|
||||
|
||||
|
|
@ -156,7 +159,7 @@ when a spike is useful, let the Orchestrator create an ordinary task-specific re
|
|||
|
||||
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
|
||||
|
||||
If `.yoi/ticket.config.toml` is missing, defaults are:
|
||||
If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exists, defaults are:
|
||||
|
||||
- backend provider: `builtin:yoi_local`
|
||||
- backend root: `<workspace>/.yoi/tickets`
|
||||
|
|
@ -168,7 +171,7 @@ If `.yoi/ticket.config.toml` is missing, defaults are:
|
|||
- coder: `multi-agent-workflow`
|
||||
- reviewer: `multi-agent-workflow`
|
||||
|
||||
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/ticket.config.toml` before using `yoi panel` role-launch actions.
|
||||
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
|
||||
|
||||
## Workflow lifecycle
|
||||
|
||||
|
|
@ -272,7 +275,7 @@ Close with a resolution that summarizes what changed, key commits, validation, r
|
|||
|
||||
`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
|
||||
|
||||
Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`:
|
||||
Role actions map to the same fixed roles configured in `.yoi/workspace.toml` under `[ticket.roles]`:
|
||||
|
||||
- intake launches the intake role without an existing Ticket and requires freeform context.
|
||||
- route launches the orchestrator role for an existing Ticket.
|
||||
|
|
@ -288,7 +291,7 @@ The role-launch path is:
|
|||
```text
|
||||
User triggers a Ticket action in yoi panel
|
||||
-> Dashboard builds a TicketRoleLaunchContext
|
||||
-> client Ticket role launcher reads .yoi/ticket.config.toml
|
||||
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
|
||||
-> launcher selects the role Profile and workflow
|
||||
-> launcher spawns the role Worker
|
||||
-> launcher sends Method::Run with WorkflowInvoke + Text segments
|
||||
|
|
@ -301,37 +304,37 @@ The launched Worker receives dynamic Ticket/action context as its first committe
|
|||
The first run input contains:
|
||||
|
||||
- the selected fixed role;
|
||||
- the workflow canonical id from `.yoi/ticket.config.toml`;
|
||||
- the workflow canonical id from workspace `[ticket.roles.<role>]` settings;
|
||||
- Ticket id when the action targets an existing Ticket;
|
||||
- freeform user instruction/context from the action;
|
||||
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
|
||||
|
||||
The selected Profile supplies durable system/role behavior. `ticket.config.toml` does not override system instruction.
|
||||
The selected Profile supplies durable system/role behavior. Workspace Ticket settings do not override system instruction.
|
||||
|
||||
### Dashboard setup
|
||||
|
||||
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
|
||||
|
||||
```toml
|
||||
# .yoi/ticket.config.toml
|
||||
# .yoi/workspace.toml
|
||||
|
||||
[backend]
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = ".yoi/tickets"
|
||||
|
||||
[roles.intake]
|
||||
[ticket.roles.intake]
|
||||
profile = "project:intake"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[roles.orchestrator]
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[roles.coder]
|
||||
[ticket.roles.coder]
|
||||
profile = "project:coder"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.reviewer]
|
||||
[ticket.roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
workflow = "multi-agent-workflow"
|
||||
```
|
||||
|
|
@ -340,8 +343,8 @@ If a role still uses `profile = "inherit"`, the Dashboard fails closed with a di
|
|||
|
||||
### Dashboard troubleshooting
|
||||
|
||||
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`.
|
||||
- malformed `.yoi/ticket.config.toml`: fix the config and retry.
|
||||
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`.
|
||||
- malformed workspace Ticket settings: fix the `[ticket]` table in `.yoi/workspace.toml` and retry.
|
||||
- missing Ticket id for route, implement, or review actions: provide the target Ticket.
|
||||
- launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user