server: use single XDG server database

This commit is contained in:
2026-07-24 08:45:51 +09:00
parent 19d5396897
commit 4ff599c011
16 changed files with 293 additions and 485 deletions
+4 -7
View File
@@ -378,7 +378,7 @@ impl WorkspaceBackendConfigFile {
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("workspace.db"));
.unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
@@ -570,7 +570,7 @@ mod tests {
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.database_path.ends_with("server.db"));
assert!(
resolved
.server
@@ -643,7 +643,7 @@ embedded_runtime_store_root = "/tmp/yoi-runtime"
}
#[test]
fn data_root_derives_database_and_runtime_store_paths() {
fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
@@ -655,10 +655,7 @@ root = ".local-data"
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
dir.path().join(".local-data/workspace.db")
);
assert!(resolved.database_path.ends_with("server.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
+159 -103
View File
@@ -4,9 +4,11 @@ use std::process::ExitCode;
use std::sync::Arc;
use tokio::net::TcpListener;
use yoi_workspace_server::store::RepositoryRecord;
use yoi_workspace_server::{
BackendRuntimesConfigFile, SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile, WorkspaceIdentity, serve,
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, SqliteWorkspaceStore,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
WorkspaceRecord, serve,
};
#[derive(Debug)]
@@ -21,9 +23,6 @@ enum Command {
#[derive(Debug)]
struct ServeOptions {
workspace: PathBuf,
db: Option<PathBuf>,
frontend: Option<PathBuf>,
listen: Option<SocketAddr>,
}
@@ -70,7 +69,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options),
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Skills(command) => run_skills(command),
@@ -111,13 +110,50 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
}
}
fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: identity.workspace_id.clone(),
owner_account_id: None,
display_name: identity.display_name.clone(),
state: "active".to_string(),
created_at: identity.created_at.clone(),
updated_at: identity.created_at.clone(),
})
.await?;
store.upsert_repository(&RepositoryRecord {
workspace_id: identity.workspace_id.clone(),
repository_id: "main".to_string(),
name: "Main repository".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: options.workspace.display().to_string(),
default_ref: Some("HEAD".to_string()),
auth_ref_kind: None,
auth_ref_key: None,
created_at: identity.created_at.clone(),
updated_at: identity.created_at.clone(),
})?;
eprintln!(
"yoi-workspace-server: initialized workspace `{}` ({})",
"yoi-workspace-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(),
identity.workspace_id
identity.workspace_id,
database_path.display()
);
Ok(())
}
@@ -167,36 +203,90 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>>
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let workspace = select_serve_workspace(store.as_ref())?;
let workspace_root = infer_workspace_root_from_repositories(store.as_ref(), &workspace)?;
let identity = WorkspaceIdentity {
workspace_id: workspace.workspace_id.clone(),
created_at: workspace.created_at.clone(),
display_name: workspace.display_name.clone(),
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved =
config_file.resolve_with_runtime_config(&options.workspace, identity, &runtime_config)?;
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));
}
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
&workspace_root,
identity,
&runtime_config,
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
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(&resolved.database_path)?);
let listener = TcpListener::bind(resolved.listen).await?;
eprintln!(
"yoi-workspace-server: serving workspace `{}` on http://{}",
options.workspace.display(),
"yoi-workspace-server: serving workspace `{}` from server DB `{}` on http://{}",
workspace.workspace_id,
database_path.display(),
listener.local_addr()?
);
serve(resolved.server, store, listener).await?;
Ok(())
}
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
let workspaces = store
.list_workspaces()
.map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?;
match workspaces.as_slice() {
[] => Err(CliError(
"server DB has no workspace records; run `yoi-workspace-server init --workspace <PATH>`".to_string(),
)),
[workspace] => Ok(workspace.clone()),
_ => Err(CliError(format!(
"server DB contains {} workspaces; serve workspace selection is not implemented yet",
workspaces.len()
))),
}
}
fn infer_workspace_root_from_repositories(
store: &SqliteWorkspaceStore,
workspace: &WorkspaceRecord,
) -> Result<PathBuf, CliError> {
let repositories = store
.list_repositories(&workspace.workspace_id)
.map_err(|error| {
CliError(format!(
"failed to list repositories from server DB: {error}"
))
})?;
let Some(repository) = repositories
.iter()
.find(|repository| repository.repository_id == "main")
.or_else(|| repositories.first())
else {
return Err(CliError(format!(
"workspace `{}` has no repository records; cannot derive a workspace root",
workspace.workspace_id
)));
};
let repository_path = PathBuf::from(&repository.uri);
if !repository_path.is_absolute() {
return Err(CliError(format!(
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
repository.repository_id, repository.uri
)));
}
Ok(repository_path)
}
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
@@ -313,36 +403,12 @@ fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = None;
let mut db = None;
let mut frontend = None;
let mut listen = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--workspace" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
workspace = Some(PathBuf::from(value));
}
"--db" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--db requires a value".to_string()))?;
db = Some(PathBuf::from(value));
}
"--frontend" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--frontend requires a value".to_string()))?;
frontend = Some(PathBuf::from(value));
}
"--listen" => {
index += 1;
let value = args
@@ -350,15 +416,6 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
.ok_or_else(|| CliError("--listen requires a value".to_string()))?;
listen = Some(parse_listen(value)?);
}
_ if arg.starts_with("--workspace=") => {
workspace = Some(PathBuf::from(value_after_equals(arg, "--workspace")?));
}
_ if arg.starts_with("--db=") => {
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
}
_ if arg.starts_with("--frontend=") => {
frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?));
}
_ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
}
@@ -367,28 +424,14 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
}
_ => {
return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --workspace <PATH>"
"unexpected positional argument `{arg}`; serve reads the workspace from the server DB"
)));
}
}
index += 1;
}
let workspace = workspace
.ok_or_else(|| CliError("serve requires --workspace <path>; the current directory is no longer used as an implicit workspace".to_string()))?;
let workspace = workspace.canonicalize().map_err(|error| {
CliError(format!(
"failed to canonicalize workspace `{}`: {error}",
workspace.display()
))
})?;
Ok(ServeOptions {
workspace,
db,
frontend,
listen,
})
Ok(ServeOptions { listen })
}
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@@ -416,7 +459,7 @@ fn print_help() {
fn print_init_help() {
println!(
"yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the packaged Backend config template to .yoi/workspace-backend.local.toml. Does not create Backend data stores.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
"yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
@@ -434,7 +477,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve --workspace <PATH> [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init --workspace <PATH>` first. Backend serve no longer treats the process current directory as an implicit Workspace.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (required)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
);
}
@@ -455,43 +498,42 @@ mod tests {
}
#[test]
fn parse_serve_rejects_missing_workspace() {
fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
let error = parse_serve_options(&args).unwrap_err();
assert_eq!(
error.to_string(),
"serve requires --workspace <path>; the current directory is no longer used as an implicit workspace"
);
}
#[test]
fn parse_serve_requires_explicit_workspace() {
let temp = tempfile::tempdir().unwrap();
let args = vec![
"--workspace".to_string(),
temp.path().display().to_string(),
"--listen".to_string(),
"127.0.0.1:0".to_string(),
];
let options = parse_serve_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
}
#[test]
fn parse_serve_accepts_equals_workspace() {
fn parse_serve_rejects_legacy_workspace_flag() {
let temp = tempfile::tempdir().unwrap();
let args = vec![format!("--workspace={}", temp.path().display())];
let options = parse_serve_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let error = parse_serve_options(&args).unwrap_err();
assert_eq!(error.to_string(), "unknown serve option `--workspace`");
}
#[test]
fn init_creates_identity_and_local_config_only() {
fn parse_serve_rejects_legacy_db_and_frontend_flags() {
let error = parse_serve_options(&["--db=/tmp/yoi.db".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "unknown serve option `--db=/tmp/yoi.db`");
let error = parse_serve_options(&["--frontend=/tmp/web".to_string()]).unwrap_err();
assert_eq!(
error.to_string(),
"unknown serve option `--frontend=/tmp/web`"
);
}
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
run_init(InitOptions {
workspace: temp.path().canonicalize().unwrap(),
})
let database_path = temp.path().join("data").join("server").join("server.db");
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
},
database_path.clone(),
)
.await
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
@@ -509,5 +551,19 @@ mod tests {
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].uri,
temp.path().canonicalize().unwrap().display().to_string()
);
}
}
+46 -28
View File
@@ -138,8 +138,7 @@ impl ServerConfig {
let workspace_root = workspace_root.into();
let workspace_id = identity.workspace_id;
let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id);
let database_path =
Self::default_workspace_backend_data_root(&workspace_id).join("workspace.db");
let database_path = Self::default_server_database_path();
Self {
workspace_id,
workspace_display_name: identity.display_name,
@@ -164,13 +163,31 @@ impl ServerConfig {
}
}
pub fn server_data_root_for_data_dir(data_dir: impl Into<PathBuf>) -> PathBuf {
data_dir.into().join("server")
}
pub fn default_server_data_root() -> PathBuf {
match manifest::paths::data_dir() {
Some(data_dir) => Self::server_data_root_for_data_dir(data_dir),
None => std::env::temp_dir().join("yoi").join("server"),
}
}
pub fn server_database_path_for_data_dir(data_dir: impl Into<PathBuf>) -> PathBuf {
Self::server_data_root_for_data_dir(data_dir).join("server.db")
}
pub fn default_server_database_path() -> PathBuf {
Self::default_server_data_root().join("server.db")
}
pub fn workspace_backend_data_root_for_data_dir(
data_dir: impl Into<PathBuf>,
workspace_id: impl AsRef<str>,
) -> PathBuf {
data_dir
.into()
.join("workspace-server")
Self::server_data_root_for_data_dir(data_dir)
.join("workspaces")
.join(workspace_id.as_ref())
}
@@ -181,7 +198,8 @@ impl ServerConfig {
}
None => std::env::temp_dir()
.join("yoi")
.join("workspace-server")
.join("server")
.join("workspaces")
.join(workspace_id.as_ref()),
}
}
@@ -6846,6 +6864,7 @@ mod tests {
let store_root = workspace_root.join(".test-embedded-runtime-store");
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
.with_embedded_runtime_store_root(store_root);
config.database_path = workspace_root.join(".test-yoi-server.db");
config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml"));
config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(),
@@ -7952,7 +7971,6 @@ mod tests {
#[tokio::test]
async fn serves_bounded_read_apis_and_static_spa_separately() {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "API Ticket", "ready");
write_objective(dir.path(), "00000000001J3", "API Objective", "active");
let static_dir = dir.path().join("static");
std::fs::create_dir_all(static_dir.join("assets")).unwrap();
@@ -7961,6 +7979,12 @@ mod tests {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = test_server_config(dir.path());
write_ticket(
&config.database_path,
TEST_WORKSPACE_ID,
"API Ticket",
ticket::TicketWorkflowState::Ready,
);
config.static_assets_dir = Some(static_dir);
let api = WorkspaceApi::new_with_execution_backend(
config,
@@ -8044,7 +8068,7 @@ mod tests {
);
let tickets = get_json(app.clone(), "/api/tickets").await;
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
assert_eq!(tickets["items"][0]["title"], "API Ticket");
assert_eq!(tickets["items"][0]["state"], "ready");
let objectives = get_json(app.clone(), "/api/objectives").await;
@@ -8098,7 +8122,7 @@ mod tests {
.iter()
.find(|column| column["state"] == "ready")
.unwrap();
assert_eq!(ready_column["items"][0]["id"], "00000000001J2");
assert_eq!(ready_column["items"][0]["title"], "API Ticket");
assert_eq!(
repository_tickets["diagnostics"][0]["code"],
"repository_ticket_target_metadata_absent"
@@ -8456,7 +8480,8 @@ mod tests {
assert_eq!(
default_root,
data_dir
.join("workspace-server")
.join("server")
.join("workspaces")
.join(TEST_WORKSPACE_ID)
.join("embedded-runtime")
);
@@ -8941,25 +8966,18 @@ mod tests {
serde_json::from_slice(&bytes).unwrap()
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = root.join(".yoi/tickets").join(id);
std::fs::create_dir_all(&ticket_dir).unwrap();
std::fs::write(
ticket_dir.join("item.md"),
format!(
r#"---
title: "{title}"
state: "{state}"
created_at: "2026-01-01T00:00:00Z"
updated_at: "2026-01-02T00:00:00Z"
---
fn write_ticket(
database_path: &Path,
workspace_id: &str,
title: &str,
state: ticket::TicketWorkflowState,
) {
use ticket::TicketBackend as _;
Ticket body.
"#,
),
)
.unwrap();
std::fs::write(ticket_dir.join("thread.md"), "").unwrap();
let backend = ticket::SqliteTicketBackend::new(database_path, workspace_id);
let mut input = ticket::NewTicket::new(title);
input.workflow_state = Some(state);
backend.create(input).unwrap();
}
fn write_objective(root: &Path, id: &str, title: &str, state: &str) {
+14
View File
@@ -226,6 +226,7 @@ pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>>;
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
@@ -416,6 +417,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at
FROM workspaces
ORDER BY workspace_id ASC"#,
)?;
let rows = stmt.query_map([], read_workspace_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(