ticket: move ticket settings into workspace config

This commit is contained in:
2026-07-16 02:19:04 +09:00
parent c68ed1fdc5
commit b1e3a2ad33
14 changed files with 630 additions and 213 deletions
+7 -4
View File
@@ -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}"))
+43 -7
View File
@@ -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"),
+52 -6
View File
@@ -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(