server: use single XDG server database
This commit is contained in:
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
-33
@@ -1053,12 +1053,6 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
}
|
||||
if !has_workspace_option(rest) {
|
||||
return Err(ParseError(
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Mode::WorkspaceServer {
|
||||
subcommand: "serve".to_string(),
|
||||
args: rest.to_vec(),
|
||||
@@ -1071,11 +1065,6 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_workspace_option(args: &[String]) -> bool {
|
||||
args.iter()
|
||||
.any(|arg| arg == "--workspace" || arg.starts_with("--workspace="))
|
||||
}
|
||||
|
||||
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> ExitCode {
|
||||
let command = match resolve_workspace_server_command() {
|
||||
Ok(command) => command,
|
||||
@@ -1453,13 +1442,13 @@ fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
||||
yoi workspace config <COMMAND> [OPTIONS]
|
||||
yoi workspace serve --workspace <PATH> [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_workspace_help() {
|
||||
println!(
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve --workspace <PATH> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` requires an explicit workspace path and never forwards cwd as an implicit backend workspace.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an explicitly selected Workspace\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --workspace <PATH> Workspace root (required)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1700,34 +1689,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_serve_passthrough() {
|
||||
match parse_args_from([
|
||||
"workspace",
|
||||
"serve",
|
||||
"--workspace",
|
||||
"/tmp/ws",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
{
|
||||
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
|
||||
Mode::WorkspaceServer { subcommand, args } => {
|
||||
assert_eq!(subcommand, "serve");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["--workspace", "/tmp/ws", "--listen", "127.0.0.1:0"]
|
||||
);
|
||||
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
|
||||
}
|
||||
other => panic!("unexpected mode: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_serve_requires_explicit_workspace() {
|
||||
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
);
|
||||
fn parse_workspace_serve_leaves_legacy_flags_to_server() {
|
||||
match parse_args_from(["workspace", "serve", "--workspace", "/tmp/ws"]).unwrap() {
|
||||
Mode::WorkspaceServer { subcommand, args } => {
|
||||
assert_eq!(subcommand, "serve");
|
||||
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
|
||||
}
|
||||
other => panic!("unexpected mode: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -368,7 +368,7 @@ fn workspace_settings_uuid_v7(workspace: &Path) -> String {
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
let db_path = server_database_path(workspace)?;
|
||||
Ok(Box::new(
|
||||
SqliteTicketBackend::new(db_path, workspace_id)
|
||||
.with_record_language(config.ticket_record_language()),
|
||||
@@ -380,7 +380,7 @@ fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let local = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
let db_path = server_database_path(workspace)?;
|
||||
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
|
||||
.with_record_language(config.ticket_record_language());
|
||||
sqlite.import_from_local_backend(&local)?;
|
||||
@@ -391,6 +391,27 @@ fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
)))
|
||||
}
|
||||
|
||||
fn server_database_path(workspace: &Path) -> Result<PathBuf, TicketCliError> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(workspace
|
||||
.join(".test-yoi-data")
|
||||
.join("server")
|
||||
.join("server.db"));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = workspace;
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
|
||||
TicketCliError::new(
|
||||
"could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
)
|
||||
})?;
|
||||
Ok(data_dir.join("server").join("server.db"))
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError> {
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let raw = fs::read_to_string(&settings_path).map_err(|error| {
|
||||
@@ -419,36 +440,6 @@ fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_ticket_database_path(
|
||||
workspace: &Path,
|
||||
workspace_id: &str,
|
||||
) -> Result<PathBuf, TicketCliError> {
|
||||
#[cfg(not(test))]
|
||||
let _ = workspace;
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(workspace
|
||||
.join(".test-yoi-data")
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
|
||||
TicketCliError::new(
|
||||
"could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
)
|
||||
})?;
|
||||
Ok(data_dir
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create(
|
||||
backend: &dyn TicketBackend,
|
||||
options: CreateOptions,
|
||||
|
||||
Reference in New Issue
Block a user