migrate: move workspace data to XDG SQLite stores
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user