feat: add workspace backend config schema

This commit is contained in:
Keisuke Hirata 2026-07-02 00:48:17 +09:00
parent 52b445e6bc
commit 5a8d18ec27
No known key found for this signature in database
6 changed files with 504 additions and 22 deletions

View File

@ -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'
---
## 背景

View File

@ -20,4 +20,76 @@ Workspace Backend config file schema を定義する Ticket として詳細化
- data 本体、workspace identity、Ticket/Objective、Runtime fs-store、transcript、secret 値は config file に入れない。
---
<!-- event: intake_summary author: hare at: 2026-07-01T15:32:38Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:38Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:38Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:32:39Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-01T15:48:09Z -->
## Implementation report
Implemented Workspace Backend config file schema and resolver.
Changes:
- Added `workspace_server::config` with `WorkspaceBackendConfigFile` serde TOML schema.
- Fixed config path: `<workspace_root>/.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`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-01T15:48:17Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---

View File

@ -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<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
#[serde(default)]
pub remote: Vec<RemoteRuntimeConfigFile>,
}
#[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<String>,
#[serde(default)]
pub token_ref: Option<String>,
}
#[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<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
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<Path>) -> Result<Self> {
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<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
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::<SocketAddr>()
.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::<Result<Vec<_>>>()?;
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<PathBuf>) -> Self {
self.database_path = path.into();
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> 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<RemoteRuntimeConfig> {
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}"
);
}
}

View File

@ -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}`")]

View File

@ -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<PathBuf>,
frontend: Option<PathBuf>,
listen: SocketAddr,
listen: Option<SocketAddr>,
}
#[derive(Debug)]
@ -65,23 +67,30 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
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<ServeOptions, CliError> {
.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::<SocketAddr>().unwrap();
let mut listen = None;
let mut index = 0;
while index < args.len() {
@ -122,7 +131,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
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<ServeOptions, CliError> {
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}`")));

View File

@ -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<PathBuf>,
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<PathBuf>,
workspace_id: impl AsRef<str>,
) -> 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<str>) -> PathBuf {
pub fn default_workspace_backend_data_root(workspace_id: impl AsRef<str>) -> 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<PathBuf>,
workspace_id: impl AsRef<str>,
) -> 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<str>) -> PathBuf {
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
}
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
self.embedded_runtime_store_root = root.into();
self