migrate: move workspace data to XDG SQLite stores

This commit is contained in:
2026-07-24 08:45:42 +09:00
parent e975c648c2
commit 19d5396897
21 changed files with 2039 additions and 246 deletions
+167 -7
View File
@@ -11,6 +11,7 @@ use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
@@ -49,6 +50,11 @@ pub struct WorkspaceBackendConfigFile {
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BackendRuntimesConfigFile {
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
}
@@ -198,6 +204,74 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf,
}
impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
match Self::default_path() {
Some(path) => Self::load_from_path(path),
None => Ok(Self::default()),
}
}
pub fn load_from_config_dir(config_dir: impl AsRef<Path>) -> Result<Self> {
Self::load_from_path(Self::path_for_config_dir(config_dir))
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
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 write_default(&self) -> Result<PathBuf> {
let path = Self::default_path().ok_or_else(|| {
Error::Config(
"YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config"
.to_string(),
)
})?;
self.write_to_path(&path)?;
Ok(path)
}
pub fn write_to_config_dir(&self, config_dir: impl AsRef<Path>) -> Result<()> {
self.write_to_path(Self::path_for_config_dir(config_dir))
}
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize Backend runtimes config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
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 Backend runtimes config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
@@ -276,6 +350,19 @@ impl WorkspaceBackendConfigFile {
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> {
let workspace_root = workspace_root.as_ref();
let data_root = self
@@ -312,6 +399,7 @@ impl WorkspaceBackendConfigFile {
})?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.frontend_url = self
.server
.frontend_url
@@ -329,7 +417,7 @@ impl WorkspaceBackendConfigFile {
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = self
server.remote_runtime_sources = runtime_config
.runtimes
.remote
.iter()
@@ -352,7 +440,9 @@ impl WorkspaceBackendConfigFile {
impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
self.database_path = path.into();
let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self
}
@@ -663,15 +753,80 @@ uri = "https://example.com/org/repo.git"
}
#[test]
fn token_value_field_is_not_in_schema() {
fn backend_runtimes_config_loads_from_config_dir() {
let dir = tempfile::tempdir().unwrap();
let config = BackendRuntimesConfigFile {
runtimes: WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "arc".to_string(),
endpoint: "http://127.0.0.1:38800".to_string(),
display_name: Some("arc".to_string()),
token_ref: None,
}],
},
};
config.write_to_config_dir(dir.path()).unwrap();
let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap();
assert_eq!(loaded, config);
assert_eq!(
BackendRuntimesConfigFile::path_for_config_dir(dir.path()),
dir.path().join("runtimes.toml")
);
}
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test]
fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://xdg.example.test"
display_name = "xdg arc"
"#,
"runtimes.toml",
)
.unwrap();
let resolved = workspace_config
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
resolved.server.remote_runtime_sources[0].base_url.as_str(),
"http://xdg.example.test"
);
}
#[test]
fn token_value_field_is_not_in_runtime_schema() {
let error = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token = "secret"
"#,
"test",
"runtimes.toml",
)
.unwrap_err();
assert!(
@@ -683,17 +838,22 @@ token = "secret"
#[test]
fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token_ref = "local:remote-token"
"#,
"test",
"runtimes.toml",
)
.unwrap();
let error = match config.resolve(dir.path(), identity()) {
let error = match workspace_config.resolve_with_runtime_config(
dir.path(),
identity(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
Err(error) => error,
};
+3 -2
View File
@@ -19,8 +19,9 @@ pub mod skills;
pub mod store;
pub use config::{
ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{
+43 -8
View File
@@ -5,8 +5,8 @@ use std::sync::Arc;
use tokio::net::TcpListener;
use yoi_workspace_server::{
SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
WorkspaceIdentity, serve,
BackendRuntimesConfigFile, SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile, WorkspaceIdentity, serve,
};
#[derive(Debug)]
@@ -169,7 +169,9 @@ 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 mut resolved = config_file.resolve(&options.workspace, identity)?;
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);
}
@@ -311,8 +313,7 @@ fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
let mut workspace = None;
let mut db = None;
let mut frontend = None;
let mut listen = None;
@@ -326,7 +327,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let value = args
.get(index)
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
workspace = PathBuf::from(value);
workspace = Some(PathBuf::from(value));
}
"--db" => {
index += 1;
@@ -350,7 +351,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
listen = Some(parse_listen(value)?);
}
_ if arg.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
workspace = Some(PathBuf::from(value_after_equals(arg, "--workspace")?));
}
_ if arg.starts_with("--db=") => {
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
@@ -373,6 +374,8 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
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}",
@@ -431,7 +434,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\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 --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"
);
}
@@ -451,6 +454,38 @@ mod tests {
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
}
#[test]
fn parse_serve_rejects_missing_workspace() {
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() {
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());
}
#[test]
fn init_creates_identity_and_local_config_only() {
let temp = tempfile::tempdir().unwrap();
+31 -51
View File
@@ -3,8 +3,7 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id;
use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery};
use crate::{Error, Result};
@@ -14,19 +13,19 @@ const SUMMARY_BODY_LIMIT: usize = 240;
#[derive(Debug, Clone)]
pub struct LocalProjectRecordReader {
workspace_root: PathBuf,
ticket_backend: LocalTicketBackend,
ticket_backend: SqliteTicketBackend,
}
impl LocalProjectRecordReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> {
pub fn new(
workspace_root: impl Into<PathBuf>,
database_path: impl Into<PathBuf>,
workspace_id: impl Into<String>,
) -> Result<Self> {
let workspace_root = workspace_root.into();
let ticket_config = TicketConfig::load_workspace(&workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf())
.with_record_language(ticket_config.ticket_record_language());
Ok(Self {
workspace_root,
ticket_backend,
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
})
}
@@ -35,9 +34,9 @@ impl LocalProjectRecordReader {
}
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
let mut items = partial
.tickets
let mut items = self
.ticket_backend
.list(TicketListQuery::all())?
.into_iter()
.map(|item| TicketSummary {
id: item.id,
@@ -47,7 +46,7 @@ impl LocalProjectRecordReader {
updated_at: item.updated_at,
queued_by: item.queued_by,
queued_at: item.queued_at,
record_source: "local_yoi_ticket".to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
})
.collect::<Vec<_>>();
items.sort_by(|a, b| {
@@ -58,24 +57,16 @@ impl LocalProjectRecordReader {
items.truncate(limit.min(200));
Ok(ProjectRecordList {
items,
invalid_records: partial
.invalid_records
.into_iter()
.map(|record| InvalidProjectRecord {
label: record.label,
reason: record.reason,
})
.collect(),
invalid_records: Vec::new(),
record_authority: "local_yoi_project_records".to_string(),
})
}
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
validate_project_id(id)?;
let partial = self
let ticket = self
.ticket_backend
.show_partial(TicketIdOrSlug::Id(id.to_string()))?;
let ticket = partial.ticket;
.show(TicketIdOrSlug::Id(id.to_string()))?;
let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
Ok(TicketDetail {
@@ -92,7 +83,7 @@ impl LocalProjectRecordReader {
body_truncated,
event_count: ticket.events.len(),
artifact_count: ticket.artifacts.len(),
record_source: "local_yoi_ticket".to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
})
}
@@ -296,14 +287,21 @@ mod tests {
use super::*;
#[test]
fn reads_local_yoi_ticket_and_objective_records_without_migration() {
fn reads_sqlite_yoi_ticket_and_objective_records_without_migration() {
let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
let db_path = dir.path().join("workspace.db");
SqliteTicketBackend::new(&db_path, "workspace-test")
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
.unwrap();
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready");
@@ -318,34 +316,16 @@ mod tests {
assert!(objective.body.contains("Objective body"));
}
#[test]
fn reads_tickets_from_workspace_settings_backend_root() {
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
dir.path().join(".yoi/workspace.toml"),
r#"
[ticket]
language = "Japanese"
write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready");
let db_path = dir.path().join("workspace.db");
[ticket.backend]
provider = "builtin:yoi_local"
root = "project-records/tickets"
"#,
)
.unwrap();
write_ticket_at(
&dir.path().join("project-records/tickets"),
"00000000001J4",
"Configured root",
"ready",
);
write_ticket(dir.path(), "00000000001J5", "Default root", "ready");
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.items.len(), 1);
assert_eq!(tickets.items[0].id, "00000000001J4");
assert!(tickets.items.is_empty());
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
}
+1 -1
View File
@@ -209,7 +209,7 @@ impl RepositoryRegistryReader {
kind: repository.provider.clone(),
provider: repository.provider.clone(),
default_selector: repository.default_selector.clone(),
record_authority: "workspace-backend-config".to_string(),
record_authority: "workspace-control-plane".to_string(),
git,
diagnostics,
}
+175 -70
View File
@@ -19,7 +19,7 @@ use protocol::stream::{decode_method, encode_event};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ticket::{
LocalTicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
SqliteTicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
execute_ticket_backend_operation,
};
use tokio::net::TcpListener;
@@ -45,7 +45,7 @@ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile, resolve_remote_runtime};
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
@@ -77,8 +77,8 @@ use crate::resource_broker::BackendResourceBroker;
use crate::skills;
use crate::store::{
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, UserRecord,
WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
};
use crate::{Error, Result};
use worker_runtime::catalog::{
@@ -120,6 +120,7 @@ pub struct ServerConfig {
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf,
pub database_path: PathBuf,
pub frontend_url: String,
pub embedded_runtime_store_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
@@ -128,6 +129,7 @@ pub struct ServerConfig {
pub repositories: Vec<ConfiguredRepository>,
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
pub runtime_config_path: Option<PathBuf>,
pub backend_base_url: Option<String>,
}
@@ -136,11 +138,14 @@ 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");
Self {
workspace_id,
workspace_display_name: identity.display_name,
workspace_created_at: identity.created_at,
workspace_root,
database_path,
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
@@ -154,6 +159,7 @@ impl ServerConfig {
repositories: Vec::new(),
runtime_event_sources: Vec::new(),
remote_runtime_sources: Vec::new(),
runtime_config_path: BackendRuntimesConfigFile::default_path(),
backend_base_url: None,
}
}
@@ -246,7 +252,7 @@ impl WorkspaceApi {
}
async fn new_with_execution_backend_and_broker(
config: ServerConfig,
mut config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
resource_broker: BackendResourceBroker,
@@ -261,6 +267,8 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(),
})
.await?;
import_configured_repositories(store.as_ref(), &config)?;
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
let runtime = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
config.workspace_id.clone(),
@@ -298,7 +306,11 @@ impl WorkspaceApi {
let companion = Arc::new(CompanionConsole::disabled());
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
records: LocalProjectRecordReader::new(
config.workspace_root.clone(),
config.database_path.clone(),
config.workspace_id.clone(),
)?,
config,
store,
runtime,
@@ -317,6 +329,76 @@ impl WorkspaceApi {
}
}
fn import_configured_repositories(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
) -> Result<()> {
if config.repositories.is_empty() {
return Ok(());
}
let now = crate::auth::now_rfc3339();
for repository in &config.repositories {
store.upsert_repository(&RepositoryRecord {
workspace_id: config.workspace_id.clone(),
repository_id: repository.id.clone(),
name: repository
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone()),
kind: repository.provider.clone(),
provider: Some(repository.provider.clone()),
uri: repository.uri.clone(),
default_ref: repository.default_selector.clone(),
auth_ref_kind: None,
auth_ref_key: None,
created_at: now.clone(),
updated_at: now.clone(),
})?;
}
Ok(())
}
fn load_configured_repositories_from_store(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
) -> Result<Vec<ConfiguredRepository>> {
store
.list_repositories(&config.workspace_id)?
.into_iter()
.map(|record| configured_repository_from_record(&config.workspace_root, record))
.collect()
}
fn configured_repository_from_record(
workspace_root: &Path,
record: RepositoryRecord,
) -> Result<ConfiguredRepository> {
let provider = record.provider.unwrap_or_else(|| record.kind.clone());
if record.uri.contains("://") {
return Err(Error::Config(format!(
"repository `{}` uses a remote URI, but remote repository materialization is not implemented",
record.repository_id
)));
}
let path = resolve_backend_path(workspace_root, Path::new(&record.uri));
Ok(ConfiguredRepository {
id: record.repository_id,
provider,
uri: record.uri,
path,
display_name: Some(record.name),
default_selector: record.default_ref,
})
}
fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
}
}
pub fn build_router(api: WorkspaceApi) -> Router {
Router::new()
.route("/api/auth/config", get(get_auth_config))
@@ -1316,8 +1398,11 @@ async fn scoped_ticket_backend_operation(
validate_workspace_scope(&api, &path.workspace_id)?;
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
let backend = SqliteTicketBackend::new(
api.config.database_path.clone(),
api.config.workspace_id.clone(),
)
.with_record_language(config.ticket_record_language());
let response = match execute_ticket_backend_operation(&backend, operation) {
Ok(result) => TicketBackendHttpResponse::Ok { result },
Err(error) => TicketBackendHttpResponse::Error {
@@ -3358,7 +3443,7 @@ async fn list_repositories(
Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id,
items,
source: "workspace_backend_config".to_string(),
source: "workspace-control-plane".to_string(),
diagnostics: repository_diagnostics(diagnostics),
}))
}
@@ -3371,7 +3456,7 @@ async fn repository_detail(
Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item,
source: "workspace_backend_config".to_string(),
source: "workspace-control-plane".to_string(),
}))
}
@@ -3466,10 +3551,10 @@ async fn list_workers(
async fn get_runtime_connection_settings(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
let local_config = load_workspace_backend_config_for_settings(&api)?;
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
Ok(Json(runtime_connection_settings_response(
&api,
&local_config,
&runtime_config,
)))
}
@@ -3478,7 +3563,7 @@ async fn add_remote_runtime_connection(
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
validate_runtime_connection_request(&request)?;
let mut local_config = load_workspace_backend_config_for_settings(&api)?;
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
let id = request.runtime_id.trim().to_string();
if id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
@@ -3496,7 +3581,7 @@ async fn add_remote_runtime_connection(
"remote Runtime token_ref persistence is not supported by this v0 browser settings surface",
));
}
if local_config
if runtime_config
.runtimes
.remote
.iter()
@@ -3538,12 +3623,12 @@ async fn add_remote_runtime_connection(
)
.map(|host| host.with_resource_broker(api.resource_broker.clone()))
.map_err(|err| err.into_error())?;
local_config.runtimes.remote.push(remote_config);
write_workspace_backend_config_for_settings(&api, &local_config)?;
runtime_config.runtimes.remote.push(remote_config);
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
api.runtime.register_or_replace(active_runtime);
let mut response = runtime_connection_mutation_response(
&api,
&local_config,
&runtime_config,
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
@@ -3551,9 +3636,9 @@ async fn add_remote_runtime_connection(
)],
);
response.diagnostics.push(settings_diagnostic(
"workspace_backend_config_rewritten",
"backend_runtimes_config_rewritten",
DiagnosticSeverity::Info,
"Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
));
Ok(Json(response))
}
@@ -3568,13 +3653,13 @@ async fn delete_remote_runtime_connection(
"the embedded Runtime is built in and cannot be deleted from remote Runtime config",
));
}
let mut local_config = load_workspace_backend_config_for_settings(&api)?;
let before = local_config.runtimes.remote.len();
local_config
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
let before = runtime_config.runtimes.remote.len();
runtime_config
.runtimes
.remote
.retain(|remote| remote.id != runtime_id);
if before == local_config.runtimes.remote.len() {
if before == runtime_config.runtimes.remote.len() {
return Err(Error::UnknownRuntime(runtime_id).into());
}
match api
@@ -3605,10 +3690,10 @@ async fn delete_remote_runtime_connection(
));
}
}
write_workspace_backend_config_for_settings(&api, &local_config)?;
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
let mut response = runtime_connection_mutation_response(
&api,
&local_config,
&runtime_config,
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
@@ -3616,9 +3701,9 @@ async fn delete_remote_runtime_connection(
)],
);
response.diagnostics.push(settings_diagnostic(
"workspace_backend_config_rewritten",
"backend_runtimes_config_rewritten",
DiagnosticSeverity::Info,
"Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
));
Ok(Json(response))
}
@@ -3627,8 +3712,8 @@ async fn test_remote_runtime_connection(
State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
let local_config = load_workspace_backend_config_for_settings(&api)?;
let remote = local_config
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
let remote = runtime_config
.runtimes
.remote
.iter()
@@ -4483,54 +4568,64 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
})
}
fn load_workspace_backend_config_for_settings(
fn load_backend_runtimes_config_for_settings(
api: &WorkspaceApi,
) -> ApiResult<WorkspaceBackendConfigFile> {
WorkspaceBackendConfigFile::load_for_workspace(&api.config.workspace_root).map_err(|error| {
) -> ApiResult<BackendRuntimesConfigFile> {
api.config
.runtime_config_path
.as_ref()
.map(BackendRuntimesConfigFile::load_from_path)
.transpose()
.map_err(|error| {
Error::Config(format!(
"failed to read Backend runtimes config for Runtime connections: {}",
sanitize_backend_error(&error.to_string())
))
.into()
})
.map(|config| config.unwrap_or_default())
}
fn write_backend_runtimes_config_for_settings(
api: &WorkspaceApi,
runtime_config: &BackendRuntimesConfigFile,
) -> ApiResult<()> {
let path = api.config.runtime_config_path.as_ref().ok_or_else(|| {
Error::Config(
"Backend runtimes config path is unavailable; set YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME"
.to_string(),
)
})?;
runtime_config.write_to_path(path).map_err(|error| {
Error::Config(format!(
"failed to read workspace backend local config for Runtime connections: {}",
"failed to write Backend runtimes config for Runtime connections: {}",
sanitize_backend_error(&error.to_string())
))
.into()
})
}
fn write_workspace_backend_config_for_settings(
api: &WorkspaceApi,
local_config: &WorkspaceBackendConfigFile,
) -> ApiResult<()> {
local_config
.write_for_workspace(&api.config.workspace_root)
.map_err(|error| {
Error::Config(format!(
"failed to write workspace backend local config for Runtime connections: {}",
sanitize_backend_error(&error.to_string())
))
.into()
})
}
fn runtime_connection_settings_response(
api: &WorkspaceApi,
local_config: &WorkspaceBackendConfigFile,
runtime_config: &BackendRuntimesConfigFile,
) -> RuntimeConnectionSettingsResponse {
RuntimeConnectionSettingsResponse {
workspace_id: api.config.workspace_id.clone(),
embedded: embedded_runtime_connection_summary(api),
remotes: remote_runtime_connection_summaries(api, local_config, false),
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
diagnostics: Vec::new(),
}
}
fn runtime_connection_mutation_response(
api: &WorkspaceApi,
local_config: &WorkspaceBackendConfigFile,
runtime_config: &BackendRuntimesConfigFile,
diagnostics: Vec<RuntimeDiagnostic>,
) -> RuntimeConnectionMutationResponse {
RuntimeConnectionMutationResponse {
workspace_id: api.config.workspace_id.clone(),
restart_required: false,
remotes: remote_runtime_connection_summaries(api, local_config, false),
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
diagnostics,
}
}
@@ -4576,14 +4671,14 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
fn remote_runtime_connection_summaries(
api: &WorkspaceApi,
local_config: &WorkspaceBackendConfigFile,
runtime_config: &BackendRuntimesConfigFile,
restart_required: bool,
) -> Vec<RemoteRuntimeConnectionSummary> {
let live_runtimes = api
.runtime
.list_runtimes(api.config.max_records.min(200))
.items;
local_config
runtime_config
.runtimes
.remote
.iter()
@@ -6191,6 +6286,7 @@ impl IntoResponse for ApiError {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WorkspaceBackendRuntimesConfig;
use axum::body::{Body, to_bytes};
use axum::http::Request;
use futures::{SinkExt, StreamExt};
@@ -6750,6 +6846,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.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml"));
config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(),
@@ -6796,7 +6893,7 @@ mod tests {
}
#[tokio::test]
async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() {
async fn ticket_backend_endpoint_uses_workspace_sqlite_backend() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
@@ -6809,7 +6906,7 @@ mod tests {
let api = test_api(dir.path()).await;
let Json(response) = scoped_ticket_backend_operation(
State(api),
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
@@ -6826,12 +6923,13 @@ mod tests {
} => ticket_ref,
other => panic!("unexpected ticket backend response: {other:?}"),
};
assert!(api.config.database_path.is_file());
assert!(
dir.path()
!dir.path()
.join("server-tickets")
.join(&ticket_ref.id)
.join("item.md")
.is_file()
.exists()
);
assert!(
!dir.path()
@@ -7423,7 +7521,10 @@ mod tests {
let projected = serde_json::to_string(&added).unwrap();
assert!(!projected.contains("runtime.example.invalid"));
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let persisted = BackendRuntimesConfigFile::load_from_path(
dir.path().join(".test-config/runtimes.toml"),
)
.unwrap();
assert_eq!(persisted.runtimes.remote.len(), 1);
assert_eq!(persisted.runtimes.remote[0].id, "team-runtime");
assert_eq!(
@@ -7462,7 +7563,10 @@ mod tests {
.iter()
.any(|runtime| runtime["runtime_id"] == "team-runtime")
);
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let persisted = BackendRuntimesConfigFile::load_from_path(
dir.path().join(".test-config/runtimes.toml"),
)
.unwrap();
assert!(persisted.runtimes.remote.is_empty());
}
@@ -7524,7 +7628,10 @@ mod tests {
.iter()
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
);
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let persisted = BackendRuntimesConfigFile::load_from_path(
dir.path().join(".test-config/runtimes.toml"),
)
.unwrap();
assert_eq!(persisted.runtimes.remote.len(), 1);
}
@@ -7545,8 +7652,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let endpoint = format!("http://{runtime_addr}");
WorkspaceBackendConfigFile {
runtimes: crate::config::WorkspaceBackendRuntimesConfig {
BackendRuntimesConfigFile {
runtimes: WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "probe-runtime".to_string(),
endpoint: endpoint.clone(),
@@ -7554,9 +7661,8 @@ mod tests {
token_ref: None,
}],
},
..WorkspaceBackendConfigFile::default()
}
.write_for_workspace(dir.path())
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
.unwrap();
let app = test_app(dir.path()).await;
@@ -7610,8 +7716,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let endpoint = format!("http://{runtime_addr}");
WorkspaceBackendConfigFile {
runtimes: crate::config::WorkspaceBackendRuntimesConfig {
BackendRuntimesConfigFile {
runtimes: WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "control-only-runtime".to_string(),
display_name: Some("Control-only Runtime".to_string()),
@@ -7619,9 +7725,8 @@ mod tests {
token_ref: None,
}],
},
..WorkspaceBackendConfigFile::default()
}
.write_for_workspace(dir.path())
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
.unwrap();
let app = test_app(dir.path()).await;
@@ -7963,7 +8068,7 @@ mod tests {
assert_eq!(repositories["items"][0]["kind"], "git");
assert_eq!(
repositories["items"][0]["record_authority"],
"workspace-backend-config"
"workspace-control-plane"
);
assert!(
repositories
+121
View File
@@ -82,6 +82,21 @@ pub struct WorkspaceRecord {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub provider: Option<String>,
pub uri: String,
pub default_ref: Option<String>,
pub auth_ref_kind: Option<String>,
pub auth_ref_key: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccountRecord {
pub account_id: String,
@@ -211,6 +226,8 @@ 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 upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
@@ -399,6 +416,56 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, default_ref,
auth_ref_kind, auth_ref_key, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(repository_id) DO UPDATE SET
workspace_id = excluded.workspace_id,
name = excluded.name,
kind = excluded.kind,
provider = excluded.provider,
uri = excluded.uri,
default_ref = excluded.default_ref,
auth_ref_kind = excluded.auth_ref_kind,
auth_ref_key = excluded.auth_ref_key,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.repository_id,
record.name,
record.kind,
record.provider,
record.uri,
record.default_ref,
record.auth_ref_kind,
record.auth_ref_key,
record.created_at,
record.updated_at,
],
)?;
Ok(())
})
}
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
auth_ref_kind, auth_ref_key, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1
ORDER BY repository_id ASC"#,
)?;
let rows = stmt.query_map(params![workspace_id], read_repository_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
@@ -1019,6 +1086,22 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
})
}
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
Ok(RepositoryRecord {
workspace_id: row.get(0)?,
repository_id: row.get(1)?,
name: row.get(2)?,
kind: row.get(3)?,
provider: row.get(4)?,
uri: row.get(5)?,
default_ref: row.get(6)?,
auth_ref_kind: row.get(7)?,
auth_ref_key: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
}
fn account_select_sql(where_clause: &str) -> String {
format!(
"SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}"
@@ -2274,6 +2357,44 @@ mod tests {
);
}
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 9);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
display_name: "Local Dev".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
};
store.upsert_workspace(&workspace).await.unwrap();
let repository = RepositoryRecord {
workspace_id: "local-dev".to_string(),
repository_id: "main".to_string(),
name: "Yoi".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: ".".to_string(),
default_ref: Some("HEAD".to_string()),
auth_ref_kind: None,
auth_ref_key: None,
created_at: "2".to_string(),
updated_at: "2".to_string(),
};
store.upsert_repository(&repository).unwrap();
assert_eq!(
store.list_repositories("local-dev").unwrap(),
vec![repository]
);
assert_eq!(
store.list_repositories("other-workspace").unwrap(),
Vec::new()
);
}
#[tokio::test]
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
let temp = tempfile::tempdir().unwrap();