diff --git a/.yoi/tickets/00001KWF2210A/item.md b/.yoi/tickets/00001KWF2210A/item.md index 6efbad51..ba8cce6b 100644 --- a/.yoi/tickets/00001KWF2210A/item.md +++ b/.yoi/tickets/00001KWF2210A/item.md @@ -1,9 +1,11 @@ --- title: 'Workspace Backend設定ファイルのスキーマを定義する' -state: 'planning' +state: 'done' created_at: '2026-07-01T14:41:48Z' -updated_at: '2026-07-01T14:43:04Z' +updated_at: '2026-07-01T15:48:17Z' assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-01T15:32:38Z' --- ## 背景 diff --git a/.yoi/tickets/00001KWF2210A/thread.md b/.yoi/tickets/00001KWF2210A/thread.md index 131e6a59..fea08131 100644 --- a/.yoi/tickets/00001KWF2210A/thread.md +++ b/.yoi/tickets/00001KWF2210A/thread.md @@ -20,4 +20,76 @@ Workspace Backend config file schema を定義する Ticket として詳細化 - data 本体、workspace identity、Ticket/Objective、Runtime fs-store、transcript、secret 値は config file に入れない。 +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +Implemented Workspace Backend config file schema and resolver. + +Changes: +- Added `workspace_server::config` with `WorkspaceBackendConfigFile` serde TOML schema. +- Fixed config path: `/.yoi/workspace-backend.local.toml`. +- Config file absence resolves to defaults. +- Unknown TOML fields are rejected via `deny_unknown_fields`. +- Relative paths resolve from workspace root; absolute paths are preserved. +- Added resolved config boundary for `ServerConfig`, listen address, and database path. +- Added data root derivation for `workspace.db` and `embedded-runtime`. +- Added remote runtime config schema with `token_ref`, while failing closed until secret ref resolution exists; raw token values are not in schema. +- Updated `yoi-workspace-server serve` to load config before applying existing legacy dev CLI overrides. +- Added focused tests for defaults, unknown fields, path resolution, data root derivation, secret value rejection, and token_ref fail-closed behavior. + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `cargo fmt --check` +- `git diff --check` +- `nix build .#yoi --no-link` + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs new file mode 100644 index 00000000..0e6768c5 --- /dev/null +++ b/crates/workspace-server/src/config.rs @@ -0,0 +1,380 @@ +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use serde::Deserialize; + +use crate::hosts::RemoteRuntimeConfig; +use crate::identity::WorkspaceIdentity; +use crate::server::{AuthConfig, ServerConfig}; +use crate::{Error, Result}; + +pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; +const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; +const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; +const DEFAULT_MAX_RECORDS: usize = 200; + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendConfigFile { + #[serde(default)] + pub server: WorkspaceBackendServerConfig, + #[serde(default)] + pub data: WorkspaceBackendDataConfig, + #[serde(default)] + pub limits: WorkspaceBackendLimitsConfig, + #[serde(default)] + pub runtimes: WorkspaceBackendRuntimesConfig, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendServerConfig { + #[serde(default)] + pub listen: Option, + #[serde(default)] + pub frontend_url: Option, + #[serde(default)] + pub static_assets_dir: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendDataConfig { + #[serde(default)] + pub root: Option, + #[serde(default)] + pub workspace_database_path: Option, + #[serde(default)] + pub embedded_runtime_store_root: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendLimitsConfig { + #[serde(default)] + pub max_records: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendRuntimesConfig { + #[serde(default)] + pub remote: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RemoteRuntimeConfigFile { + pub id: String, + pub endpoint: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub token_ref: Option, +} + +#[derive(Clone)] +pub struct ResolvedWorkspaceBackendConfig { + pub server: ServerConfig, + pub listen: SocketAddr, + pub database_path: PathBuf, +} + +impl WorkspaceBackendConfigFile { + pub fn path_for_workspace(workspace_root: impl AsRef) -> PathBuf { + workspace_root + .as_ref() + .join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH) + } + + pub fn load_for_workspace(workspace_root: impl AsRef) -> Result { + let path = Self::path_for_workspace(workspace_root); + match fs::read_to_string(&path) { + Ok(raw) => Self::parse_str(&raw, &path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(Error::Io(error)), + } + } + + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { + toml::from_str(raw).map_err(|error| { + Error::Config(format!( + "failed to parse workspace backend config `{}`: {error}", + path.as_ref().display() + )) + }) + } + + pub fn resolve( + &self, + workspace_root: impl AsRef, + identity: WorkspaceIdentity, + ) -> Result { + let workspace_root = workspace_root.as_ref(); + let data_root = self + .data + .root + .as_ref() + .map(|path| resolve_workspace_path(workspace_root, path)) + .unwrap_or_else(|| { + ServerConfig::default_workspace_backend_data_root(&identity.workspace_id) + }); + let database_path = self + .data + .workspace_database_path + .as_ref() + .map(|path| resolve_workspace_path(workspace_root, path)) + .unwrap_or_else(|| data_root.join("workspace.db")); + let embedded_runtime_store_root = self + .data + .embedded_runtime_store_root + .as_ref() + .map(|path| resolve_workspace_path(workspace_root, path)) + .unwrap_or_else(|| data_root.join("embedded-runtime")); + let listen = self + .server + .listen + .as_deref() + .unwrap_or(DEFAULT_LISTEN) + .parse::() + .map_err(|_| { + Error::Config(format!( + "invalid workspace backend server.listen `{}`", + self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN) + )) + })?; + + let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); + server.frontend_url = self + .server + .frontend_url + .clone() + .unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string()); + server.static_assets_dir = self + .server + .static_assets_dir + .as_ref() + .map(|path| resolve_workspace_path(workspace_root, path)); + server.embedded_runtime_store_root = embedded_runtime_store_root; + server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS); + server.remote_runtime_sources = self + .runtimes + .remote + .iter() + .map(resolve_remote_runtime) + .collect::>>()?; + server.auth = AuthConfig::LocalDevToken { + token_configured: false, + }; + + Ok(ResolvedWorkspaceBackendConfig { + server, + listen, + database_path, + }) + } +} + +impl ResolvedWorkspaceBackendConfig { + pub fn with_database_path(mut self, path: impl Into) -> Self { + self.database_path = path.into(); + self + } + + pub fn with_static_assets_dir(mut self, path: Option) -> Self { + self.server.static_assets_dir = path; + self + } + + pub fn with_listen(mut self, listen: SocketAddr) -> Self { + self.listen = listen; + self + } +} + +fn resolve_remote_runtime(config: &RemoteRuntimeConfigFile) -> Result { + if let Some(token_ref) = config.token_ref.as_deref() { + return Err(Error::Config(format!( + "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", + config.id + ))); + } + Ok(RemoteRuntimeConfig::new( + config.id.clone(), + config + .display_name + .clone() + .unwrap_or_else(|| config.id.clone()), + config.endpoint.clone(), + None, + )) +} + +fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + workspace_root.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity() -> WorkspaceIdentity { + WorkspaceIdentity { + workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + display_name: "Workspace".to_string(), + } + } + + #[test] + fn missing_config_path_uses_defaults() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + let resolved = config.resolve(dir.path(), identity()).unwrap(); + + assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); + assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); + assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); + assert!(resolved.database_path.ends_with("workspace.db")); + assert!( + resolved + .server + .embedded_runtime_store_root + .ends_with("embedded-runtime") + ); + } + + #[test] + fn rejects_unknown_fields() { + let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") + .unwrap_err(); + assert!( + error.to_string().contains("unknown field"), + "unexpected error: {error}" + ); + } + + #[test] + fn resolves_relative_paths_against_workspace_root() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[server] +static_assets_dir = "web/build" + +[data] +root = ".yoi/backend-data" +workspace_database_path = ".yoi/custom.db" +embedded_runtime_store_root = ".yoi/runtime-store" +"#, + "test", + ) + .unwrap(); + let resolved = config.resolve(dir.path(), identity()).unwrap(); + + assert_eq!( + resolved.server.static_assets_dir, + Some(dir.path().join("web/build")) + ); + assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db")); + assert_eq!( + resolved.server.embedded_runtime_store_root, + dir.path().join(".yoi/runtime-store") + ); + } + + #[test] + fn absolute_paths_are_preserved() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[data] +workspace_database_path = "/tmp/yoi-workspace.db" +embedded_runtime_store_root = "/tmp/yoi-runtime" +"#, + "test", + ) + .unwrap(); + let resolved = config.resolve(dir.path(), identity()).unwrap(); + + assert_eq!( + resolved.database_path, + PathBuf::from("/tmp/yoi-workspace.db") + ); + assert_eq!( + resolved.server.embedded_runtime_store_root, + PathBuf::from("/tmp/yoi-runtime") + ); + } + + #[test] + fn data_root_derives_database_and_runtime_store_paths() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[data] +root = ".local-data" +"#, + "test", + ) + .unwrap(); + let resolved = config.resolve(dir.path(), identity()).unwrap(); + + assert_eq!( + resolved.database_path, + dir.path().join(".local-data/workspace.db") + ); + assert_eq!( + resolved.server.embedded_runtime_store_root, + dir.path().join(".local-data/embedded-runtime") + ); + } + + #[test] + fn token_value_field_is_not_in_schema() { + let error = WorkspaceBackendConfigFile::parse_str( + r#" +[[runtimes.remote]] +id = "remote" +endpoint = "http://127.0.0.1:8790" +token = "secret" +"#, + "test", + ) + .unwrap_err(); + assert!( + error.to_string().contains("unknown field"), + "unexpected error: {error}" + ); + } + + #[test] + fn token_ref_fails_closed_until_secret_resolution_exists() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[[runtimes.remote]] +id = "remote" +endpoint = "http://127.0.0.1:8790" +token_ref = "local:remote-token" +"#, + "test", + ) + .unwrap(); + let error = match config.resolve(dir.path(), identity()) { + Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("secret ref resolution is not implemented"), + "unexpected error: {error}" + ); + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 0692a223..03084929 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -5,6 +5,7 @@ //! remain the canonical project records and are read through bounded bridge APIs. pub mod companion; +pub mod config; pub mod hosts; pub mod identity; pub mod observation; @@ -13,6 +14,10 @@ pub mod repositories; pub mod server; pub mod store; +pub use config::{ + ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, + WorkspaceBackendConfigFile, +}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, @@ -38,6 +43,8 @@ pub enum Error { Yaml(#[from] serde_yaml::Error), #[error("invalid project record id `{0}`")] InvalidRecordId(String), + #[error("workspace backend config error: {0}")] + Config(String), #[error("record `{0}` is missing frontmatter")] MissingFrontmatter(String), #[error("unknown local host `{0}`")] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 4f9fc546..ffc922f5 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -4,14 +4,16 @@ use std::process::ExitCode; use std::sync::Arc; use tokio::net::TcpListener; -use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve}; +use yoi_workspace_server::{ + SqliteWorkspaceStore, WorkspaceBackendConfigFile, WorkspaceIdentity, serve, +}; #[derive(Debug)] struct ServeOptions { workspace: PathBuf, db: Option, frontend: Option, - listen: SocketAddr, + listen: Option, } #[derive(Debug)] @@ -65,23 +67,30 @@ async fn run() -> Result<(), Box> { async fn run_serve(options: ServeOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; - let db = options - .db - .unwrap_or_else(|| options.workspace.join(".yoi/workspace.db")); - if let Some(parent) = db.parent() { + let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; + let mut resolved = config_file.resolve(&options.workspace, identity)?; + if let Some(db) = options.db { + resolved = resolved.with_database_path(db); + } + if let Some(frontend) = options.frontend { + resolved = resolved.with_static_assets_dir(Some(frontend)); + } + if let Some(listen) = options.listen { + resolved = resolved.with_listen(listen); + } + + if let Some(parent) = resolved.database_path.parent() { tokio::fs::create_dir_all(parent).await?; } - let store = Arc::new(SqliteWorkspaceStore::open(&db)?); - let mut config = ServerConfig::local_dev(&options.workspace, identity); - config.static_assets_dir = options.frontend; - let listener = TcpListener::bind(options.listen).await?; + let store = Arc::new(SqliteWorkspaceStore::open(&resolved.database_path)?); + let listener = TcpListener::bind(resolved.listen).await?; eprintln!( "yoi-workspace-server: serving workspace `{}` on http://{}", options.workspace.display(), listener.local_addr()? ); - serve(config, store, listener).await?; + serve(resolved.server, store, listener).await?; Ok(()) } @@ -90,7 +99,7 @@ fn parse_serve_options(args: &[String]) -> Result { .map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?; let mut db = None; let mut frontend = None; - let mut listen = "127.0.0.1:8787".parse::().unwrap(); + let mut listen = None; let mut index = 0; while index < args.len() { @@ -122,7 +131,7 @@ fn parse_serve_options(args: &[String]) -> Result { let value = args .get(index) .ok_or_else(|| CliError("--listen requires a value".to_string()))?; - listen = parse_listen(value)?; + listen = Some(parse_listen(value)?); } _ if arg.starts_with("--workspace=") => { workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); @@ -134,7 +143,7 @@ fn parse_serve_options(args: &[String]) -> Result { frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?)); } _ if arg.starts_with("--listen=") => { - listen = parse_listen(value_after_equals(arg, "--listen")?)?; + listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); } _ if arg.starts_with('-') => { return Err(CliError(format!("unknown serve option `{arg}`"))); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2e2a4773..cec76ffb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -51,6 +51,7 @@ pub struct ServerConfig { pub workspace_display_name: String, pub workspace_created_at: String, pub workspace_root: PathBuf, + pub frontend_url: String, pub embedded_runtime_store_root: PathBuf, pub static_assets_dir: Option, pub auth: AuthConfig, @@ -69,6 +70,7 @@ impl ServerConfig { workspace_display_name: identity.display_name, workspace_created_at: identity.created_at, workspace_root, + frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, auth: AuthConfig::LocalDevToken { @@ -80,7 +82,7 @@ impl ServerConfig { } } - pub fn embedded_runtime_store_root_for_data_dir( + pub fn workspace_backend_data_root_for_data_dir( data_dir: impl Into, workspace_id: impl AsRef, ) -> PathBuf { @@ -88,22 +90,32 @@ impl ServerConfig { .into() .join("workspace-server") .join(workspace_id.as_ref()) - .join("embedded-runtime") } - pub fn default_embedded_runtime_store_root(workspace_id: impl AsRef) -> PathBuf { + pub fn default_workspace_backend_data_root(workspace_id: impl AsRef) -> PathBuf { match manifest::paths::data_dir() { Some(data_dir) => { - Self::embedded_runtime_store_root_for_data_dir(data_dir, workspace_id.as_ref()) + Self::workspace_backend_data_root_for_data_dir(data_dir, workspace_id.as_ref()) } None => std::env::temp_dir() .join("yoi") .join("workspace-server") - .join(workspace_id.as_ref()) - .join("embedded-runtime"), + .join(workspace_id.as_ref()), } } + pub fn embedded_runtime_store_root_for_data_dir( + data_dir: impl Into, + workspace_id: impl AsRef, + ) -> PathBuf { + Self::workspace_backend_data_root_for_data_dir(data_dir, workspace_id) + .join("embedded-runtime") + } + + pub fn default_embedded_runtime_store_root(workspace_id: impl AsRef) -> PathBuf { + Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime") + } + pub fn with_embedded_runtime_store_root(mut self, root: impl Into) -> Self { self.embedded_runtime_store_root = root.into(); self