Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
|
||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
WorkspaceSummary,
|
||||
};
|
||||
|
||||
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
pub type BackendWorkspace = WorkspaceSummary;
|
||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
||||
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateBackendWorkspaceRequest {
|
||||
pub operation_key: String,
|
||||
pub display_name: String,
|
||||
pub repository: CreateBackendWorkspaceRepository,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateBackendWorkspaceRepository {
|
||||
pub uri: String,
|
||||
pub display_name: Option<String>,
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
|
||||
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendWorkspaceCatalogTarget {
|
||||
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_backend_workspaces_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let response = client
|
||||
.blocking_request(
|
||||
Method::GET,
|
||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||
)?
|
||||
.send()?;
|
||||
client.check_status(response.status())?;
|
||||
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
|
||||
}
|
||||
|
||||
pub fn list_backend_workspace_repositories_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||
if workspace_id.is_empty()
|
||||
|| workspace_id.len() > 200
|
||||
|| !workspace_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Workspace id returned by Backend is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let response = client
|
||||
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||
.send()?;
|
||||
client.check_status(response.status())?;
|
||||
let response = response.json::<RepositoryListResponse>()?;
|
||||
if response.workspace_id != workspace_id {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(response.items)
|
||||
}
|
||||
|
||||
pub async fn list_backend_workspaces(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
@@ -149,8 +176,8 @@ mod tests {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
display_name: Some("Main".to_string()),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,9 @@ pub use backend_runtime::{
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||
CreateBackendWorkspaceResponse, create_backend_workspace,
|
||||
list_backend_workspace_repositories_blocking, list_backend_workspaces,
|
||||
list_backend_workspaces_blocking,
|
||||
};
|
||||
pub use client::{Client, ClientError};
|
||||
pub use target::{
|
||||
|
||||
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
display_name: Some("Main".to_string()),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(request.clone(), request);
|
||||
assert_eq!(request.operation_key, "workspace-create-1");
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||
assert_eq!(json["repository"]["repository_key"], "main");
|
||||
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
|
||||
assert!(json.get("operation_id").is_none());
|
||||
assert!(json["repository"].get("display_name").is_none());
|
||||
assert!(json["repository"].get("source").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
||||
println!("Repository path/URI is required.");
|
||||
return Ok(None);
|
||||
}
|
||||
let repository_name = prompt_line("Repository display name [Main]: ")?;
|
||||
let repository_key = prompt_line("Repository key [main]: ")?;
|
||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||
let operation_key = format!(
|
||||
"tui-workspace-create-{}-{}",
|
||||
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
||||
display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
display_name: Some(if repository_name.is_empty() {
|
||||
"Main".to_string()
|
||||
repository_key: if repository_key.is_empty() {
|
||||
"main".to_string()
|
||||
} else {
|
||||
repository_name
|
||||
}),
|
||||
repository_key
|
||||
},
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -520,6 +520,25 @@ pub struct WorkspaceRepositoryRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Initial Repository registration intent for Workspace creation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InitialRepositoryIntent {
|
||||
pub repository_key: String,
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
/// Request for atomically creating a Workspace and its initial Repository.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceCreateRequest {
|
||||
pub operation_key: String,
|
||||
pub display_name: String,
|
||||
pub repository: InitialRepositoryIntent,
|
||||
}
|
||||
|
||||
/// Response returned after atomically creating a Workspace and its first Repository.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -590,7 +609,7 @@ pub struct WorkspaceResponse {
|
||||
pub extension_points: WorkspaceExtensionPoints,
|
||||
}
|
||||
|
||||
/// Workspace identity metadata exposed by the current settings resource.
|
||||
/// Workspace display metadata exposed from the Server DB settings authority.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -3899,6 +3918,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_request_has_one_closed_shared_wire_shape() {
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repositories/main".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
|
||||
assert!(json.get("operation_id").is_none());
|
||||
assert!(json["repository"].get("source").is_none());
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceCreateRequest>(serde_json::json!({
|
||||
"operation_id": "workspace-create-1",
|
||||
"display_name": "Workspace",
|
||||
"repository": {
|
||||
"repository_key": "main",
|
||||
"source": "/srv/repositories/main"
|
||||
}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_and_repository_response_shapes_round_trip() {
|
||||
let workspace = serde_json::json!({
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::{fs, io};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::server::{AuthConfig, ServerConfig};
|
||||
use crate::store::WorkspaceRecord;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||
@@ -100,15 +100,15 @@ impl ServerHostConfigFile {
|
||||
impl ResolvedWorkspaceBackendConfig {
|
||||
pub fn local_dev(
|
||||
workspace_root: impl AsRef<Path>,
|
||||
identity: WorkspaceIdentity,
|
||||
workspace: WorkspaceRecord,
|
||||
host_config: &ServerHostConfigFile,
|
||||
) -> Result<Self> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
||||
let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.workspace_id);
|
||||
let database_path = ServerConfig::default_server_database_path();
|
||||
let (browser_public_url, browser_rp_id) =
|
||||
resolve_browser_public_url(&host_config.browser.public_url)?;
|
||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace);
|
||||
server.database_path = database_path.clone();
|
||||
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||
server.max_records = DEFAULT_MAX_RECORDS;
|
||||
@@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity() -> WorkspaceIdentity {
|
||||
WorkspaceIdentity {
|
||||
fn workspace() -> WorkspaceRecord {
|
||||
WorkspaceRecord {
|
||||
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
|
||||
owner_account_id: "018f6a2c-1111-7000-8000-000000000002".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
display_name: "Workspace".to_string(),
|
||||
state: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +200,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ResolvedWorkspaceBackendConfig::local_dev(
|
||||
dir.path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&ServerHostConfigFile::default(),
|
||||
)
|
||||
.unwrap()
|
||||
@@ -250,7 +253,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
tempfile::tempdir().unwrap().path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&host_config,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -280,7 +283,7 @@ mod tests {
|
||||
};
|
||||
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
tempfile::tempdir().unwrap().path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&host_config,
|
||||
);
|
||||
let error = match result {
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
|
||||
|
||||
/// Stable local Workspace identity persisted as a tracked, safe project record.
|
||||
///
|
||||
/// The v0 TOML schema contains identity metadata plus optional tracked project
|
||||
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
|
||||
/// here because this loader cannot safely round-trip future local runtime settings
|
||||
/// without risking accidental path or secret persistence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceIdentity {
|
||||
pub workspace_id: String,
|
||||
pub created_at: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkspaceIdentityFile {
|
||||
workspace_id: String,
|
||||
created_at: String,
|
||||
display_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ticket: Option<toml::Value>,
|
||||
}
|
||||
|
||||
impl WorkspaceIdentity {
|
||||
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_required(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = Self::path(workspace_root.as_ref());
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(raw) => Self::parse_str(&raw, &path),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
Err(Error::WorkspaceIdentity(format!(
|
||||
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
|
||||
workspace_root.as_ref().display()
|
||||
)))
|
||||
}
|
||||
Err(error) => Err(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
|
||||
workspace_root
|
||||
.as_ref()
|
||||
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
|
||||
}
|
||||
|
||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
|
||||
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
|
||||
})?;
|
||||
Self::from_file(parsed, path)
|
||||
}
|
||||
|
||||
fn load_or_init_with_clock(
|
||||
workspace_root: &Path,
|
||||
now_utc_rfc3339: impl FnOnce() -> String,
|
||||
) -> Result<Self> {
|
||||
let path = Self::path(workspace_root);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(raw) => Self::parse_str(&raw, &path),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
Self::init(workspace_root, &path, now_utc_rfc3339())
|
||||
}
|
||||
Err(error) => Err(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
|
||||
validate_created_at(&created_at, path)?;
|
||||
let display_name = workspace_display_name_from_root(workspace_root, path)?;
|
||||
let workspace_id = Uuid::now_v7().to_string();
|
||||
let identity = Self {
|
||||
workspace_id,
|
||||
created_at,
|
||||
display_name,
|
||||
};
|
||||
identity.write_new_or_read_existing(path)
|
||||
}
|
||||
|
||||
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
|
||||
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
|
||||
validate_created_at(&parsed.created_at, path)?;
|
||||
validate_display_name(&parsed.display_name, path)?;
|
||||
Ok(Self {
|
||||
workspace_id,
|
||||
created_at: parsed.created_at,
|
||||
display_name: parsed.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
ticket: None,
|
||||
})
|
||||
.map_err(|error| {
|
||||
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
|
||||
})?;
|
||||
|
||||
match OpenOptions::new().write(true).create_new(true).open(path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(raw.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
Ok(self.clone())
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
Self::parse_str(&raw, path)
|
||||
}
|
||||
Err(error) => Err(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
|
||||
let uuid = Uuid::parse_str(value).map_err(|error| {
|
||||
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
|
||||
})?;
|
||||
if uuid.get_version_num() != 7 {
|
||||
return Err(workspace_identity_error(
|
||||
path,
|
||||
"workspace_id must be a UUIDv7 canonical string".to_string(),
|
||||
));
|
||||
}
|
||||
let canonical = uuid.to_string();
|
||||
if value != canonical {
|
||||
return Err(workspace_identity_error(
|
||||
path,
|
||||
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
|
||||
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
|
||||
})?;
|
||||
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
|
||||
return Err(workspace_identity_error(
|
||||
path,
|
||||
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(workspace_identity_error(
|
||||
path,
|
||||
"display_name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
|
||||
return Err(workspace_identity_error(
|
||||
path,
|
||||
"display_name must not contain control characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
|
||||
let display_name = workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| {
|
||||
workspace_identity_error(
|
||||
path,
|
||||
"workspace root must have a UTF-8 final path component".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
validate_display_name(&display_name, path)?;
|
||||
Ok(display_name)
|
||||
}
|
||||
|
||||
fn workspace_identity_error(path: &Path, message: String) -> Error {
|
||||
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
||||
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
||||
|
||||
#[test]
|
||||
fn load_required_rejects_uninitialized_workspace_without_creating_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace_root = temp.path().join("uninitialized-workspace");
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("workspace identity is missing"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_identity_file_is_created_with_safe_fields() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace_root = temp.path().join("example-workspace");
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||
FIXED_CREATED_AT.to_string()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(identity.display_name, "example-workspace");
|
||||
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
||||
validate_workspace_id(
|
||||
&identity.workspace_id,
|
||||
&WorkspaceIdentity::path(&workspace_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
||||
assert!(raw.contains("workspace_id"));
|
||||
assert!(raw.contains("display_name"));
|
||||
assert!(raw.contains("created_at"));
|
||||
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
|
||||
|
||||
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||
"2026-06-24T00:00:00Z".to_string()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(reloaded, identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_identity_file_is_stable() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace_root = temp.path().join("moved-workspace");
|
||||
let yoi_dir = workspace_root.join(".yoi");
|
||||
fs::create_dir_all(&yoi_dir).unwrap();
|
||||
let path = yoi_dir.join("workspace.toml");
|
||||
let raw = format!(
|
||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
|
||||
);
|
||||
fs::write(&path, &raw).unwrap();
|
||||
|
||||
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||
"2026-06-24T00:00:00Z".to_string()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
|
||||
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
||||
assert_eq!(identity.display_name, "Stable Project");
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_new_race_returns_existing_persisted_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".yoi/workspace.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let persisted_raw = format!(
|
||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
|
||||
);
|
||||
fs::write(&path, &persisted_raw).unwrap();
|
||||
let generated = WorkspaceIdentity {
|
||||
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
|
||||
created_at: "2026-06-24T00:00:00Z".to_string(),
|
||||
display_name: "Generated Project".to_string(),
|
||||
};
|
||||
|
||||
let returned = generated.write_new_or_read_existing(&path).unwrap();
|
||||
|
||||
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
|
||||
assert_eq!(returned.created_at, FIXED_CREATED_AT);
|
||||
assert_eq!(returned.display_name, "Persisted Project");
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_identity_file_fails_closed_without_rewriting() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace_root = temp.path().join("invalid-workspace");
|
||||
let yoi_dir = workspace_root.join(".yoi");
|
||||
fs::create_dir_all(&yoi_dir).unwrap();
|
||||
let path = yoi_dir.join("workspace.toml");
|
||||
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
|
||||
fs::write(&path, raw).unwrap();
|
||||
|
||||
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||
FIXED_CREATED_AT.to_string()
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("workspace_id is not a UUID"));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_identity_does_not_leak_parent_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let secret_parent = temp.path().join("user-secret-parent");
|
||||
let workspace_root = secret_parent.join("public-project-name");
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
||||
FIXED_CREATED_AT.to_string()
|
||||
})
|
||||
.unwrap();
|
||||
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
||||
|
||||
assert!(raw.contains("public-project-name"));
|
||||
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
|
||||
assert!(!raw.contains("user-secret-parent"));
|
||||
assert!(!raw.contains("/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_rejected() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("workspace.toml");
|
||||
let raw = format!(
|
||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
|
||||
);
|
||||
|
||||
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("unknown field"));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_source;
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
@@ -43,7 +42,6 @@ pub use authority::{
|
||||
WorkspaceAuthority,
|
||||
};
|
||||
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
||||
pub use server::{
|
||||
@@ -137,8 +135,6 @@ pub enum Error {
|
||||
RegistryInconsistency(String),
|
||||
#[error("Worker source identity is invalid: {0}")]
|
||||
WorkerSourceIdentity(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("Workspace signing identity error ({code}): {message}")]
|
||||
WorkspaceSigningIdentity { code: String, message: String },
|
||||
#[error("store error: {0}")]
|
||||
|
||||
@@ -14,7 +14,7 @@ use yoi_workspace_server::store::{
|
||||
};
|
||||
use yoi_workspace_server::{
|
||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||
WorkspaceRecord, serve_workspace_catalog,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -235,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
||||
let workspaces = store.list_workspaces()?;
|
||||
let (identity, workspace_root) = if let Some(workspace) = workspaces.first() {
|
||||
let (workspace, workspace_execution_root) = if let Some(workspace) = workspaces.first() {
|
||||
(
|
||||
WorkspaceIdentity {
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
created_at: workspace.created_at.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
},
|
||||
workspace_root_from_server_data(workspace)?,
|
||||
workspace.clone(),
|
||||
workspace_execution_root_from_server_data(workspace)?,
|
||||
)
|
||||
} else {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
(
|
||||
WorkspaceIdentity {
|
||||
WorkspaceRecord {
|
||||
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
owner_account_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
display_name: "Server bootstrap".to_string(),
|
||||
state: "bootstrap".to_string(),
|
||||
},
|
||||
database_path
|
||||
.parent()
|
||||
@@ -261,8 +261,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||
None => ServerHostConfigFile::load_default()?,
|
||||
};
|
||||
let mut resolved =
|
||||
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
|
||||
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
&workspace_execution_root,
|
||||
workspace,
|
||||
&host_config,
|
||||
)?;
|
||||
resolved.database_path = database_path.clone();
|
||||
resolved.server.database_path = database_path.clone();
|
||||
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
||||
@@ -322,7 +325,9 @@ fn append_workspace_runtime_sources(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBuf, CliError> {
|
||||
fn workspace_execution_root_from_server_data(
|
||||
workspace: &WorkspaceRecord,
|
||||
) -> Result<PathBuf, CliError> {
|
||||
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||
&workspace.workspace_id,
|
||||
))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
||||
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker::EffectivePromptCatalog;
|
||||
use worker_runtime::config_bundle::{
|
||||
@@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
use workspace_api::{
|
||||
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
|
||||
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||
ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
|
||||
};
|
||||
|
||||
use crate::config_source::{
|
||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||
};
|
||||
use crate::store::WorkspaceRecord;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const PROFILE_SCHEMA_SOURCE: &str = r#"{
|
||||
@@ -467,103 +465,29 @@ fn build_virtual_profile_archive(
|
||||
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkspaceIdentityFile {
|
||||
workspace_id: String,
|
||||
created_at: String,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
pub fn workspace_metadata_settings(
|
||||
workspace_root: &Path,
|
||||
fallback_workspace_id: &str,
|
||||
fallback_created_at: &str,
|
||||
fallback_display_name: &str,
|
||||
workspace: &WorkspaceRecord,
|
||||
) -> WorkspaceMetadataSettingsResponse {
|
||||
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
||||
let mut diagnostics = Vec::new();
|
||||
let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) {
|
||||
Ok(raw) => match toml::from_str::<WorkspaceIdentityFile>(&raw) {
|
||||
Ok(file) => (file.workspace_id, file.created_at, file.display_name),
|
||||
Err(err) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"workspace_identity_parse_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Workspace identity could not be parsed: {err}"),
|
||||
));
|
||||
(
|
||||
fallback_workspace_id.to_string(),
|
||||
fallback_created_at.to_string(),
|
||||
fallback_display_name.to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
diagnostics.push(diagnostic(
|
||||
"workspace_identity_missing",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Workspace identity record is missing; showing active backend metadata.",
|
||||
));
|
||||
(
|
||||
fallback_workspace_id.to_string(),
|
||||
fallback_created_at.to_string(),
|
||||
fallback_display_name.to_string(),
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"workspace_identity_read_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!(
|
||||
"Workspace identity could not be read: {}",
|
||||
sanitize_error(&err.to_string())
|
||||
),
|
||||
));
|
||||
(
|
||||
fallback_workspace_id.to_string(),
|
||||
fallback_created_at.to_string(),
|
||||
fallback_display_name.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
WorkspaceMetadataSettingsResponse {
|
||||
workspace_id,
|
||||
display_name,
|
||||
created_at,
|
||||
revision: file_revision(&path),
|
||||
source: "workspace_identity".to_string(),
|
||||
diagnostics,
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
created_at: workspace.created_at.clone(),
|
||||
revision: workspace.updated_at.clone(),
|
||||
source: "server_db".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_workspace_metadata(
|
||||
workspace_root: &Path,
|
||||
request: UpdateWorkspaceMetadataRequest,
|
||||
) -> Result<WorkspaceMetadataSettingsResponse> {
|
||||
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
||||
let current_revision = file_revision(&path);
|
||||
if request.revision != current_revision {
|
||||
pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "workspace_metadata_revision_conflict".to_string(),
|
||||
message: "Workspace metadata changed before this update was applied".to_string(),
|
||||
code: "workspace_display_name_invalid".to_string(),
|
||||
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
|
||||
});
|
||||
}
|
||||
let raw = fs::read_to_string(&path)?;
|
||||
let mut file: WorkspaceIdentityFile = toml::from_str(&raw)
|
||||
.map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?;
|
||||
let display_name = sanitize_display_name(&request.display_name)?;
|
||||
file.display_name = display_name;
|
||||
let encoded = toml::to_string_pretty(&file)
|
||||
.map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?;
|
||||
fs::write(&path, encoded)?;
|
||||
Ok(workspace_metadata_settings(
|
||||
workspace_root,
|
||||
&file.workspace_id,
|
||||
&file.created_at,
|
||||
&file.display_name,
|
||||
))
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||
@@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
|
||||
specifiers
|
||||
}
|
||||
|
||||
fn sanitize_display_name(value: &str) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "workspace_display_name_invalid".to_string(),
|
||||
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
pub fn selector_for_builtin_candidate(
|
||||
id: &str,
|
||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||
@@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate(
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn file_revision(path: &Path) -> String {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return "missing".to_string();
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or_default();
|
||||
format!("rev:{modified}:{}", metadata.len())
|
||||
}
|
||||
fn diagnostic(
|
||||
code: impl Into<String>,
|
||||
severity: DiagnosticSeverity,
|
||||
message: impl Into<String>,
|
||||
) -> Diagnostic {
|
||||
Diagnostic {
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn sanitize_error(value: &str) -> String {
|
||||
value
|
||||
.split_whitespace()
|
||||
.map(|token| {
|
||||
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
|
||||
{
|
||||
"<redacted-path>"
|
||||
} else {
|
||||
token
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
owner_account_id: "owner-account".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-02T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let settings = workspace_metadata_settings(&workspace);
|
||||
|
||||
assert_eq!(settings.workspace_id, workspace.workspace_id);
|
||||
assert_eq!(settings.display_name, workspace.display_name);
|
||||
assert_eq!(settings.created_at, workspace.created_at);
|
||||
assert_eq!(settings.revision, workspace.updated_at);
|
||||
assert_eq!(settings.source, "server_db");
|
||||
assert!(settings.diagnostics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_display_name_validation_is_bounded() {
|
||||
assert_eq!(
|
||||
sanitize_workspace_display_name(" Workspace A ").unwrap(),
|
||||
"Workspace A"
|
||||
);
|
||||
assert!(sanitize_workspace_display_name("\n").is_err());
|
||||
assert!(sanitize_workspace_display_name(&"a".repeat(121)).is_err());
|
||||
}
|
||||
|
||||
fn valid_decodal(slug: &str) -> String {
|
||||
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ use crate::hosts::{
|
||||
WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
|
||||
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
|
||||
};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||
use crate::memory_staging::{
|
||||
list_memory_staging_from_authority, memory_staging_backlog_from_authority,
|
||||
@@ -211,7 +210,7 @@ pub struct ServerConfig {
|
||||
pub workspace_id: String,
|
||||
pub workspace_display_name: String,
|
||||
pub workspace_created_at: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub workspace_execution_root: PathBuf,
|
||||
pub database_path: PathBuf,
|
||||
pub embedded_runtime_store_root: PathBuf,
|
||||
pub static_assets_dir: Option<PathBuf>,
|
||||
@@ -224,16 +223,18 @@ pub struct ServerConfig {
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
|
||||
let workspace_root = workspace_root.into();
|
||||
let workspace_id = identity.workspace_id;
|
||||
pub fn local_dev(
|
||||
workspace_execution_root: impl Into<PathBuf>,
|
||||
workspace: WorkspaceRecord,
|
||||
) -> Self {
|
||||
let workspace_id = workspace.workspace_id.clone();
|
||||
let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id);
|
||||
let database_path = Self::default_server_database_path();
|
||||
Self {
|
||||
workspace_id,
|
||||
workspace_display_name: identity.display_name,
|
||||
workspace_created_at: identity.created_at,
|
||||
workspace_root,
|
||||
workspace_id: workspace.workspace_id,
|
||||
workspace_display_name: workspace.display_name,
|
||||
workspace_created_at: workspace.created_at,
|
||||
workspace_execution_root: workspace_execution_root.into(),
|
||||
database_path,
|
||||
embedded_runtime_store_root,
|
||||
static_assets_dir: None,
|
||||
@@ -320,9 +321,8 @@ impl ServerConfig {
|
||||
workspace.workspace_id
|
||||
)));
|
||||
}
|
||||
let workspace_data_root =
|
||||
let workspace_execution_root =
|
||||
Self::default_workspace_backend_data_root(&workspace.workspace_id);
|
||||
let workspace_root = workspace_data_root.clone();
|
||||
let repositories = repositories
|
||||
.into_iter()
|
||||
.map(|repository| ConfiguredRepository {
|
||||
@@ -346,7 +346,7 @@ impl ServerConfig {
|
||||
scoped
|
||||
.workspace_created_at
|
||||
.clone_from(&workspace.created_at);
|
||||
scoped.workspace_root = workspace_root;
|
||||
scoped.workspace_execution_root = workspace_execution_root;
|
||||
scoped.embedded_runtime_store_root =
|
||||
Self::default_embedded_runtime_store_root(&workspace.workspace_id);
|
||||
scoped.repositories = repositories;
|
||||
@@ -2138,7 +2138,7 @@ impl WorkspaceApi {
|
||||
),
|
||||
);
|
||||
let execution_backend = WorkerRuntimeExecutionBackend::new(
|
||||
ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
|
||||
ProfileRuntimeWorkerFactory::new(config.workspace_execution_root.clone())
|
||||
.with_embedded_worker_mutation_dispatcher(
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
worker_remove_dispatcher.clone(),
|
||||
@@ -2985,14 +2985,11 @@ fn load_configured_repositories_from_store(
|
||||
store
|
||||
.list_repositories(&config.workspace_id)?
|
||||
.into_iter()
|
||||
.map(|record| configured_repository_from_record(&config.workspace_root, record))
|
||||
.map(configured_repository_from_record)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configured_repository_from_record(
|
||||
_workspace_root: &Path,
|
||||
record: RepositoryRecord,
|
||||
) -> Result<ConfiguredRepository> {
|
||||
fn configured_repository_from_record(record: RepositoryRecord) -> Result<ConfiguredRepository> {
|
||||
let provider = record.provider.unwrap_or_else(|| record.kind.clone());
|
||||
let path = repository_local_path(&record.source);
|
||||
Ok(ConfiguredRepository {
|
||||
@@ -3983,7 +3980,7 @@ struct TranscriptQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ScopedWorkspacePath {
|
||||
workspace_id: String,
|
||||
}
|
||||
@@ -4212,11 +4209,13 @@ async fn scoped_get_workspace_settings(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<WorkspaceMetadataSettingsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(&path.workspace_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InvalidRecordId(path.workspace_id))?;
|
||||
Ok(Json(crate::profile_settings::workspace_metadata_settings(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&api.config.workspace_display_name,
|
||||
&workspace,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -4226,8 +4225,31 @@ async fn scoped_update_workspace_settings(
|
||||
Json(request): Json<UpdateWorkspaceMetadataRequest>,
|
||||
) -> ApiResult<Json<WorkspaceMetadataMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace =
|
||||
crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
|
||||
let display_name =
|
||||
crate::profile_settings::sanitize_workspace_display_name(&request.display_name)?;
|
||||
let current = api
|
||||
.store
|
||||
.get_workspace(&path.workspace_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InvalidRecordId(path.workspace_id.clone()))?;
|
||||
if request.revision != current.updated_at {
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "workspace_metadata_revision_conflict".to_string(),
|
||||
message: "Workspace metadata changed before this update was applied".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let workspace = api
|
||||
.store
|
||||
.update_workspace_display_name(&path.workspace_id, ¤t.updated_at, &display_name)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "workspace_metadata_revision_conflict".to_string(),
|
||||
message: "Workspace metadata changed before this update was applied".to_string(),
|
||||
})?;
|
||||
let workspace = crate::profile_settings::workspace_metadata_settings(&workspace);
|
||||
Ok(Json(WorkspaceMetadataMutationResponse {
|
||||
workspace,
|
||||
diagnostics: vec![workspace_api::Diagnostic {
|
||||
@@ -13541,22 +13563,20 @@ async fn get_workspace(
|
||||
let cookie_name = auth_public_config(&api.config).cookie_name;
|
||||
let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?;
|
||||
let schema_version = api.store.schema_version().await?;
|
||||
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
||||
let is_owner = actor.as_ref().is_some_and(|actor| {
|
||||
stored
|
||||
.as_ref()
|
||||
.is_some_and(|workspace| workspace.owner_account_id == actor.account_id)
|
||||
});
|
||||
let display_name = stored
|
||||
let stored = api
|
||||
.store
|
||||
.get_workspace(api.workspace_id())
|
||||
.await?
|
||||
.ok_or_else(|| Error::InvalidRecordId(api.workspace_id().to_string()))?;
|
||||
let is_owner = actor
|
||||
.as_ref()
|
||||
.map(|record| record.display_name.clone())
|
||||
.unwrap_or_else(|| api.config.workspace_display_name.clone());
|
||||
.is_some_and(|actor| stored.owner_account_id == actor.account_id);
|
||||
let companion_status = api.companion.status();
|
||||
let companion_console = companion_console_extension_point(&companion_status);
|
||||
Ok(Json(WorkspaceResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
display_name,
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
workspace_id: stored.workspace_id,
|
||||
display_name: stored.display_name,
|
||||
record_authority: "server_db".to_string(),
|
||||
schema_version,
|
||||
auth: api.config.auth.clone(),
|
||||
permissions: WorkspacePermissionSummary {
|
||||
@@ -21955,11 +21975,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_identity() -> WorkspaceIdentity {
|
||||
WorkspaceIdentity {
|
||||
fn test_workspace() -> WorkspaceRecord {
|
||||
WorkspaceRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
owner_account_id: "owner-account".to_string(),
|
||||
display_name: "Test Workspace".to_string(),
|
||||
created_at: TEST_CREATED_AT.to_string(),
|
||||
updated_at: TEST_CREATED_AT.to_string(),
|
||||
state: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22003,7 +22026,7 @@ mod tests {
|
||||
workspace_api::RepositorySourceKind::Https
|
||||
);
|
||||
assert_eq!(
|
||||
scoped.workspace_root,
|
||||
scoped.workspace_execution_root,
|
||||
ServerConfig::default_workspace_backend_data_root("remote-workspace")
|
||||
);
|
||||
}
|
||||
@@ -22058,7 +22081,7 @@ mod tests {
|
||||
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
|
||||
let workspace_root = workspace_root.into();
|
||||
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_workspace())
|
||||
.with_embedded_runtime_store_root(store_root);
|
||||
config.database_path = workspace_root.join(".test-yoi-server.db");
|
||||
config.backend_base_url = Some("http://127.0.0.1:8787".to_string());
|
||||
@@ -25949,6 +25972,69 @@ mod tests {
|
||||
assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_metadata_settings_ignore_repository_identity_file_and_update_server_db() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let local_identity_path = temp.path().join(".yoi/workspace.toml");
|
||||
fs::create_dir_all(local_identity_path.parent().unwrap()).unwrap();
|
||||
fs::write(&local_identity_path, "not valid toml = [").unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let path = ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
};
|
||||
|
||||
let current = scoped_get_workspace_settings(State(api.clone()), AxumPath(path.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(current.workspace_id, TEST_WORKSPACE_ID);
|
||||
assert_eq!(current.display_name, "Test Workspace");
|
||||
assert_eq!(current.source, "server_db");
|
||||
assert!(current.diagnostics.is_empty());
|
||||
|
||||
let updated = scoped_update_workspace_settings(
|
||||
State(api.clone()),
|
||||
AxumPath(path),
|
||||
Json(UpdateWorkspaceMetadataRequest {
|
||||
display_name: " Renamed Workspace ".to_string(),
|
||||
revision: current.revision.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.workspace;
|
||||
assert_eq!(updated.display_name, "Renamed Workspace");
|
||||
assert_ne!(updated.revision, current.revision);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&local_identity_path).unwrap(),
|
||||
"not valid toml = ["
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_workspace(TEST_WORKSPACE_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.display_name,
|
||||
"Renamed Workspace"
|
||||
);
|
||||
|
||||
let stale = scoped_update_workspace_settings(
|
||||
State(api),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(UpdateWorkspaceMetadataRequest {
|
||||
display_name: "Stale Workspace".to_string(),
|
||||
revision: current.revision,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(stale.into_response().status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
async fn test_api_with_recording_backend(
|
||||
workspace_root: impl Into<PathBuf>,
|
||||
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
||||
@@ -26918,7 +27004,7 @@ mod tests {
|
||||
provider: Some("git".to_string()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: api.config.workspace_root.display().to_string(),
|
||||
uri: api.config.workspace_execution_root.display().to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
source_revision: 1,
|
||||
@@ -30168,7 +30254,7 @@ mod tests {
|
||||
let typed_workspace: workspace_api::WorkspaceResponse =
|
||||
serde_json::from_value(workspace.clone()).unwrap();
|
||||
assert!(!typed_workspace.permissions.manage_repositories);
|
||||
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
|
||||
assert_eq!(workspace["record_authority"], "server_db");
|
||||
assert_eq!(
|
||||
workspace["extension_points"]["host_worker_bridge"]["status"],
|
||||
"runtime_registry"
|
||||
@@ -30784,7 +30870,7 @@ mod tests {
|
||||
);
|
||||
assert!(!default_root.starts_with(workspace_root.join(".yoi")));
|
||||
|
||||
let mut config = ServerConfig::local_dev(workspace_root, test_identity())
|
||||
let mut config = ServerConfig::local_dev(workspace_root, test_workspace())
|
||||
.with_embedded_runtime_store_root(default_root.clone());
|
||||
config.database_path = ServerConfig::server_database_path_for_data_dir(&data_dir);
|
||||
let store = test_control_store(&config);
|
||||
@@ -31506,7 +31592,7 @@ mod tests {
|
||||
async fn scoped_flow_source_route_persists_compiled_dcdl() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let app = test_app(temp.path()).await;
|
||||
let workspace_id = test_identity().workspace_id;
|
||||
let workspace_id = test_workspace().workspace_id;
|
||||
let source = r#"{
|
||||
schema_version = 1;
|
||||
name = "route-flow";
|
||||
|
||||
@@ -840,6 +840,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
) -> Result<Option<String>>;
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
||||
async fn update_workspace_display_name(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
expected_updated_at: &str,
|
||||
display_name: &str,
|
||||
) -> Result<Option<WorkspaceRecord>>;
|
||||
fn create_workspace_bootstrap(
|
||||
&self,
|
||||
record: &WorkspaceBootstrapRecord,
|
||||
@@ -3305,6 +3311,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_workspace_display_name(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
expected_updated_at: &str,
|
||||
display_name: &str,
|
||||
) -> Result<Option<WorkspaceRecord>> {
|
||||
validate_identifier("workspace_id", workspace_id)?;
|
||||
validate_non_empty("expected_updated_at", expected_updated_at)?;
|
||||
validate_non_empty("display_name", display_name)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at
|
||||
FROM workspaces WHERE workspace_id = ?1"#,
|
||||
params![workspace_id],
|
||||
read_workspace_record,
|
||||
)
|
||||
.optional()?;
|
||||
let Some(current) = current else {
|
||||
tx.commit()?;
|
||||
return Ok(None);
|
||||
};
|
||||
if current.updated_at != expected_updated_at {
|
||||
tx.commit()?;
|
||||
return Ok(None);
|
||||
}
|
||||
if current.display_name == display_name {
|
||||
tx.commit()?;
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let updated_at = chrono::DateTime::parse_from_rfc3339(¤t.updated_at)
|
||||
.ok()
|
||||
.map(|previous| previous.with_timezone(&chrono::Utc))
|
||||
.filter(|previous| *previous >= now)
|
||||
.map(|previous| previous + chrono::Duration::nanoseconds(1))
|
||||
.unwrap_or(now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
|
||||
let changed = tx.execute(
|
||||
r#"UPDATE workspaces
|
||||
SET display_name = ?3, updated_at = ?4
|
||||
WHERE workspace_id = ?1 AND updated_at = ?2"#,
|
||||
params![workspace_id, expected_updated_at, display_name, updated_at],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
tx.commit()?;
|
||||
return Ok(None);
|
||||
}
|
||||
let updated = WorkspaceRecord {
|
||||
display_name: display_name.to_string(),
|
||||
updated_at,
|
||||
..current
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(Some(updated))
|
||||
})
|
||||
}
|
||||
|
||||
fn create_workspace_bootstrap(
|
||||
&self,
|
||||
record: &WorkspaceBootstrapRecord,
|
||||
@@ -12336,6 +12402,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database_path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
||||
let record = WorkspaceRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
owner_account_id: "owner-account".to_string(),
|
||||
display_name: "Before".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let updated = store
|
||||
.update_workspace_display_name(&record.workspace_id, &record.updated_at, "After")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.workspace_id, record.workspace_id);
|
||||
assert_eq!(updated.owner_account_id, record.owner_account_id);
|
||||
assert_eq!(updated.created_at, record.created_at);
|
||||
assert_eq!(updated.state, record.state);
|
||||
assert_eq!(updated.display_name, "After");
|
||||
assert_ne!(updated.updated_at, record.updated_at);
|
||||
|
||||
assert!(
|
||||
store
|
||||
.update_workspace_display_name(&record.workspace_id, &record.updated_at, "Stale",)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace(&record.workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
updated
|
||||
);
|
||||
drop(store);
|
||||
let reopened = SqliteWorkspaceStore::open(database_path).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.get_workspace(&record.workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objective_creation_allocates_and_resolves_workspace_resource_key() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use workspace_api::{InitialRepositoryIntent, WorkspaceCreateRequest};
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
|
||||
@@ -19,23 +19,6 @@ use crate::{Error, Result};
|
||||
const MAX_DISPLAY_NAME_BYTES: usize = 200;
|
||||
const MAX_OPERATION_KEY_BYTES: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InitialRepositoryIntent {
|
||||
pub repository_key: String,
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceCreateRequest {
|
||||
pub operation_key: String,
|
||||
pub display_name: String,
|
||||
pub repository: InitialRepositoryIntent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceCreateResult {
|
||||
pub workspace: WorkspaceRecord,
|
||||
|
||||
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
toml = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::Path;
|
||||
|
||||
use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -103,6 +105,16 @@ pub(crate) trait CliConnectionResolver {
|
||||
command: CliCommand,
|
||||
input: CliConnectionInput<'_>,
|
||||
) -> Result<Box<dyn Target>, ParseError>;
|
||||
|
||||
fn select_workspace_for_repository(
|
||||
&self,
|
||||
_explicit_backend_url: Option<&str>,
|
||||
_repository_path: &Path,
|
||||
) -> Result<(String, String), ParseError> {
|
||||
Err(ParseError(
|
||||
"repository-based Workspace selection is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
@@ -177,6 +189,17 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn select_workspace_for_repository(
|
||||
&self,
|
||||
explicit_backend_url: Option<&str>,
|
||||
repository_path: &Path,
|
||||
) -> Result<(String, String), ParseError> {
|
||||
let base_url = resolve_backend_url(explicit_backend_url.map(str::to_string), None)?;
|
||||
let workspace_id =
|
||||
super::select_backend_workspace_for_repository(&base_url, repository_path)?;
|
||||
Ok((base_url, workspace_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
|
||||
|
||||
+252
-56
@@ -6,6 +6,7 @@ mod plugin_cli;
|
||||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
mod worker_cleanup_cli;
|
||||
mod workspace_bootstrap;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
@@ -24,6 +25,9 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
|
||||
use memory_lint::{LintCliOptions, LintStatus};
|
||||
use serde::Deserialize;
|
||||
use tui::{LaunchMode, LaunchOptions};
|
||||
use workspace_bootstrap::{
|
||||
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
@@ -48,6 +52,7 @@ enum Mode {
|
||||
backend_url: String,
|
||||
no_wait: bool,
|
||||
},
|
||||
Init(InitOptions),
|
||||
WorkerRuntime(Vec<String>),
|
||||
Keys,
|
||||
SetupModel,
|
||||
@@ -107,6 +112,19 @@ async fn main() -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Init(options) => match run_init(options).await {
|
||||
Ok(workspace) => {
|
||||
println!(
|
||||
"Initialized Workspace '{}' for repository '{}'",
|
||||
workspace.workspace.display_name, workspace.repository.repository_key
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("yoi init: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
||||
@@ -232,24 +250,37 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
||||
);
|
||||
}
|
||||
|
||||
if selection.backend_url.is_none()
|
||||
&& let Ok(target) =
|
||||
resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None)
|
||||
&& target.kind() == TargetKind::Standalone
|
||||
{
|
||||
return Ok(target);
|
||||
if selection.workspace_id.is_none() {
|
||||
if selection.backend_url.is_none()
|
||||
&& let Ok(target) = resolve_connection_aware_cli_connection(
|
||||
connection_resolver,
|
||||
command,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
&& target.kind() == TargetKind::Standalone
|
||||
{
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
let (base_url, workspace_id) = connection_resolver
|
||||
.select_workspace_for_repository(selection.backend_url.as_deref(), workspace_root)?;
|
||||
return resolve_connection_aware_cli_connection(
|
||||
connection_resolver,
|
||||
command,
|
||||
false,
|
||||
Some(base_url),
|
||||
Some(&workspace_id),
|
||||
);
|
||||
}
|
||||
|
||||
let workspace_id = match selection.workspace_id.clone() {
|
||||
Some(workspace_id) => Some(workspace_id),
|
||||
None => resolve_workspace_id_from_root(workspace_root)?,
|
||||
};
|
||||
resolve_connection_aware_cli_connection(
|
||||
connection_resolver,
|
||||
command,
|
||||
selection.explicit_local,
|
||||
selection.backend_url.clone(),
|
||||
workspace_id.as_deref(),
|
||||
selection.workspace_id.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,6 +470,22 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
return Ok(Mode::Ticket { cli, target });
|
||||
}
|
||||
"init" => {
|
||||
if target_selection.explicit_local {
|
||||
return Err(ParseError(
|
||||
"yoi init requires a Backend target and cannot use --local".to_string(),
|
||||
));
|
||||
}
|
||||
if target_selection.workspace_id.is_some() {
|
||||
return Err(ParseError(
|
||||
"yoi init creates a Workspace and does not accept --workspace-id".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(Mode::Init(parse_init_args(
|
||||
&args[1..],
|
||||
target_selection.backend_url,
|
||||
)?));
|
||||
}
|
||||
"plugin" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
|
||||
let plugin_cli = parse_plugin_args(&args[1..])?;
|
||||
@@ -556,6 +603,68 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
parse_console_options(args, &target_selection, connection_resolver)
|
||||
}
|
||||
|
||||
fn parse_init_args(
|
||||
args: &[String],
|
||||
explicit_backend_url: Option<String>,
|
||||
) -> Result<InitOptions, ParseError> {
|
||||
let mut display_name = None;
|
||||
let mut repository_key = None;
|
||||
let mut repository_root = current_dir()?;
|
||||
let mut default_ref = None;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let option = args[index].as_str();
|
||||
let (slot, label) = match option {
|
||||
"--display-name" => (&mut display_name, "--display-name"),
|
||||
"--repository-key" => (&mut repository_key, "--repository-key"),
|
||||
"--repository" => {
|
||||
let value = required_option_value(args, index, "--repository")?;
|
||||
repository_root = PathBuf::from(value);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
"--default-ref" => (&mut default_ref, "--default-ref"),
|
||||
"--help" | "-h" => {
|
||||
return Err(ParseError(
|
||||
"usage: yoi [--backend URL] init --display-name NAME --repository-key KEY [--repository PATH] [--default-ref REF]"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
unknown => {
|
||||
return Err(ParseError(format!("unknown yoi init option `{unknown}`")));
|
||||
}
|
||||
};
|
||||
if slot.is_some() {
|
||||
return Err(ParseError(format!("{label} may only be provided once")));
|
||||
}
|
||||
*slot = Some(required_option_value(args, index, label)?.to_string());
|
||||
index += 2;
|
||||
}
|
||||
|
||||
let repository_root = discover_repository_root(&repository_root)?;
|
||||
Ok(InitOptions {
|
||||
backend_url: resolve_backend_url(explicit_backend_url, None)?,
|
||||
display_name: display_name
|
||||
.ok_or_else(|| ParseError("yoi init requires --display-name NAME".to_string()))?,
|
||||
repository_key: repository_key
|
||||
.ok_or_else(|| ParseError("yoi init requires --repository-key KEY".to_string()))?,
|
||||
repository_root,
|
||||
default_ref,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_option_value<'a>(
|
||||
args: &'a [String],
|
||||
index: usize,
|
||||
option: &str,
|
||||
) -> Result<&'a str, ParseError> {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.filter(|value| !value.is_empty() && !value.starts_with('-'))
|
||||
.ok_or_else(|| ParseError(format!("{option} requires a value")))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
args: &[String],
|
||||
target_selection: &TargetSelection,
|
||||
@@ -1050,13 +1159,7 @@ fn current_dir() -> Result<PathBuf, ParseError> {
|
||||
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkspaceIdentityFile {
|
||||
#[serde(alias = "workspace_id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct ClientConfigFile {
|
||||
default_backend: Option<String>,
|
||||
default_connection: ClientDefaultConnection,
|
||||
@@ -1074,7 +1177,7 @@ struct ClientConfigOverlay {
|
||||
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct ClientBackendConfig {
|
||||
url: Option<String>,
|
||||
}
|
||||
@@ -1084,7 +1187,7 @@ struct ClientBackendConfigOverlay {
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct ClientWorkspaceConfig {
|
||||
backend: Option<String>,
|
||||
}
|
||||
@@ -1117,34 +1220,6 @@ impl ClientConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
|
||||
let mut current = if workspace_root.is_absolute() {
|
||||
workspace_root.to_path_buf()
|
||||
} else {
|
||||
current_dir()?.join(workspace_root)
|
||||
};
|
||||
loop {
|
||||
let path = current.join(".yoi").join("workspace.toml");
|
||||
if path.is_file() {
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
|
||||
let identity: WorkspaceIdentityFile = toml::from_str(&contents)
|
||||
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))?;
|
||||
let id = identity.id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(ParseError(format!(
|
||||
"{} must contain a non-empty workspace id",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Some(id.to_string()));
|
||||
}
|
||||
if !current.pop() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_backend_url(
|
||||
explicit_backend_url: Option<String>,
|
||||
workspace_id: Option<&str>,
|
||||
@@ -1219,13 +1294,13 @@ fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>
|
||||
}
|
||||
|
||||
fn client_global_config_path() -> Option<PathBuf> {
|
||||
manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml"))
|
||||
manifest::paths::config_dir().map(|dir| dir.join("client.toml"))
|
||||
}
|
||||
|
||||
fn client_config_location_message() -> String {
|
||||
match client_global_config_path() {
|
||||
Some(path) => path.display().to_string(),
|
||||
None => "<data_dir>/client/config.toml".to_string(),
|
||||
None => "<config_dir>/client.toml".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1649,6 +1724,7 @@ Usage:
|
||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||
yoi [--backend <URL>] login [--no-wait]
|
||||
yoi <HOST_COMMAND> [OPTIONS]
|
||||
|
||||
@@ -1674,6 +1750,7 @@ Console options:
|
||||
--worker-id <ID> Backend Worker id; requires --runtime-id
|
||||
|
||||
Host commands:
|
||||
yoi init Register the current Git repository as a new Backend Workspace.
|
||||
keys Manage local model/API keys
|
||||
setup-model Configure a local model provider
|
||||
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
|
||||
@@ -1708,7 +1785,7 @@ Authority:
|
||||
Options:
|
||||
--backend <URL> Use this Workspace Backend
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
--workspace <PATH> Resolve Backend Workspace identity from this repository root
|
||||
--workspace <PATH> Match this Git repository against Server DB Repository records
|
||||
-r, --stopped List stopped Backend Workers
|
||||
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
||||
-h, --help Print help
|
||||
@@ -1750,6 +1827,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::cli_connection::CliConnectionInput;
|
||||
use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
|
||||
use std::process::Command;
|
||||
|
||||
struct FixedCliConnectionResolver {
|
||||
backend_url: &'static str,
|
||||
@@ -1798,6 +1876,42 @@ mod tests {
|
||||
workspace_id.map(str::to_string),
|
||||
)))
|
||||
}
|
||||
|
||||
fn select_workspace_for_repository(
|
||||
&self,
|
||||
_explicit_backend_url: Option<&str>,
|
||||
_repository_path: &Path,
|
||||
) -> Result<(String, String), ParseError> {
|
||||
Ok((
|
||||
self.backend_url.to_string(),
|
||||
"workspace-from-backend".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct OfflineBackendCliConnectionResolver;
|
||||
|
||||
impl CliConnectionResolver for OfflineBackendCliConnectionResolver {
|
||||
fn resolve_connection(
|
||||
&self,
|
||||
_command: CliCommand,
|
||||
_input: cli_connection::CliConnectionInput<'_>,
|
||||
) -> Result<Box<dyn Target>, ParseError> {
|
||||
Ok(Box::new(BackendTarget::new(
|
||||
"http://offline.example",
|
||||
None::<String>,
|
||||
)))
|
||||
}
|
||||
|
||||
fn select_workspace_for_repository(
|
||||
&self,
|
||||
_explicit_backend_url: Option<&str>,
|
||||
_repository_path: &Path,
|
||||
) -> Result<(String, String), ParseError> {
|
||||
Err(ParseError(
|
||||
"failed to query Backend Workspace catalog: Backend is offline".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1982,6 +2096,8 @@ backend = "shared"
|
||||
match parse_args_from([
|
||||
"--backend",
|
||||
"http://127.0.0.1:8787",
|
||||
"--workspace-id",
|
||||
"workspace-a",
|
||||
"--runtime-id",
|
||||
"runtime-a",
|
||||
"--worker-id",
|
||||
@@ -2044,7 +2160,15 @@ backend = "shared"
|
||||
|
||||
#[test]
|
||||
fn parse_backend_runtime_picker_target_mode() {
|
||||
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap()
|
||||
match parse_args_from([
|
||||
"--backend",
|
||||
"http://127.0.0.1:8787",
|
||||
"--workspace-id",
|
||||
"workspace-a",
|
||||
"--runtime-id",
|
||||
"r",
|
||||
])
|
||||
.unwrap()
|
||||
{
|
||||
Mode::Tui {
|
||||
target,
|
||||
@@ -2401,7 +2525,71 @@ backend = "shared"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_backend_target_inherits_workspace_identity_from_workspace_root() {
|
||||
fn backend_workspace_selection_reports_offline_without_local_fallback() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
workspace.path().join(".yoi/workspace.toml"),
|
||||
"workspace_id = \"stale-local-workspace\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = resolve_tui_target(
|
||||
&OfflineBackendCliConnectionResolver,
|
||||
CliCommand::Ticket,
|
||||
&TargetSelection::default(),
|
||||
workspace.path(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("Backend is offline"));
|
||||
assert!(!error.contains("stale-local-workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
|
||||
let repository = tempfile::tempdir().unwrap();
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(repository.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://unused.example",
|
||||
};
|
||||
let args = vec![
|
||||
"--backend".to_string(),
|
||||
"http://backend.example".to_string(),
|
||||
"init".to_string(),
|
||||
"--display-name".to_string(),
|
||||
"Workspace A".to_string(),
|
||||
"--repository-key".to_string(),
|
||||
"main".to_string(),
|
||||
"--repository".to_string(),
|
||||
repository.path().display().to_string(),
|
||||
"--default-ref".to_string(),
|
||||
"develop".to_string(),
|
||||
];
|
||||
|
||||
let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap();
|
||||
|
||||
let Mode::Init(options) = mode else {
|
||||
panic!("expected init mode");
|
||||
};
|
||||
assert_eq!(options.backend_url, "http://backend.example");
|
||||
assert_eq!(options.display_name, "Workspace A");
|
||||
assert_eq!(options.repository_key, "main");
|
||||
assert_eq!(options.default_ref.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
options.repository_root,
|
||||
fs::canonicalize(repository.path()).unwrap()
|
||||
);
|
||||
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
||||
std::fs::write(
|
||||
@@ -2421,11 +2609,12 @@ backend = "shared"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(target.kind(), TargetKind::Backend);
|
||||
assert_eq!(
|
||||
target.resolve().unwrap(),
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url: "http://default-backend.example".to_string(),
|
||||
workspace_id: "workspace-from-root".to_string(),
|
||||
workspace_id: "workspace-from-backend".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2867,8 +3056,15 @@ backend = "shared"
|
||||
|
||||
#[test]
|
||||
fn parse_panel_rejects_removed_host_local_restore_path() {
|
||||
let err =
|
||||
parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err();
|
||||
let err = parse_args_from([
|
||||
"--backend",
|
||||
"http://127.0.0.1:8787",
|
||||
"--workspace-id",
|
||||
"workspace-a",
|
||||
"panel",
|
||||
"-r",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("removed host-local Worker path"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use client::{
|
||||
BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||
create_backend_workspace, list_backend_workspace_repositories_blocking,
|
||||
list_backend_workspaces_blocking,
|
||||
};
|
||||
|
||||
use super::{ParseError, client_global_config_path};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct InitOptions {
|
||||
pub(crate) backend_url: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) repository_key: String,
|
||||
pub(crate) repository_root: PathBuf,
|
||||
pub(crate) default_ref: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_init(
|
||||
options: InitOptions,
|
||||
) -> Result<workspace_api::WorkspaceCreateResponse, ParseError> {
|
||||
let repository_uri = options
|
||||
.repository_root
|
||||
.to_str()
|
||||
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
|
||||
let target = BackendWorkspaceCatalogTarget {
|
||||
base_url: options.backend_url.clone(),
|
||||
};
|
||||
let response = create_backend_workspace(
|
||||
&target,
|
||||
&CreateBackendWorkspaceRequest {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
display_name: options.display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: options.repository_key,
|
||||
uri: repository_uri.to_string(),
|
||||
default_ref: options.default_ref,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
|
||||
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) fn select_backend_workspace_for_repository(
|
||||
base_url: &str,
|
||||
repository_path: &Path,
|
||||
) -> Result<String, ParseError> {
|
||||
let repository = discover_open_repository(repository_path)?;
|
||||
let target = BackendWorkspaceCatalogTarget {
|
||||
base_url: base_url.to_string(),
|
||||
};
|
||||
let workspaces = list_backend_workspaces_blocking(&target).map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to query Backend Workspace catalog: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut catalog = Vec::with_capacity(workspaces.len());
|
||||
for workspace in workspaces {
|
||||
let repositories =
|
||||
list_backend_workspace_repositories_blocking(&target, &workspace.workspace_id)
|
||||
.map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to query Repository catalog for Workspace '{}': {error}",
|
||||
workspace.display_name
|
||||
))
|
||||
})?;
|
||||
catalog.push((workspace, repositories));
|
||||
}
|
||||
select_workspace_from_repository_catalog(&repository, &catalog)
|
||||
}
|
||||
|
||||
pub(crate) fn discover_repository_root(path: &Path) -> Result<PathBuf, ParseError> {
|
||||
Ok(discover_open_repository(path)?.root)
|
||||
}
|
||||
|
||||
fn select_workspace_from_repository_catalog(
|
||||
repository: &OpenRepositoryIdentity,
|
||||
catalog: &[(
|
||||
workspace_api::WorkspaceSummary,
|
||||
Vec<workspace_api::RepositorySummary>,
|
||||
)],
|
||||
) -> Result<String, ParseError> {
|
||||
let matches = catalog
|
||||
.iter()
|
||||
.filter(|(_, repositories)| {
|
||||
repositories
|
||||
.iter()
|
||||
.any(|candidate| repository.matches(&candidate.source))
|
||||
})
|
||||
.map(|(workspace, _)| workspace)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match matches.as_slice() {
|
||||
[workspace] => Ok(workspace.workspace_id.clone()),
|
||||
[] => Err(ParseError(
|
||||
"the current Git repository is not registered in any accessible Workspace; run `yoi init` or pass `--workspace-id` explicitly"
|
||||
.to_string(),
|
||||
)),
|
||||
matches => {
|
||||
let names = matches
|
||||
.iter()
|
||||
.map(|workspace| workspace.display_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(ParseError(format!(
|
||||
"the current Git repository matches multiple accessible Workspaces ({names}); pass `--workspace-id` explicitly"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OpenRepositoryIdentity {
|
||||
root: PathBuf,
|
||||
remote_uris: Vec<String>,
|
||||
}
|
||||
|
||||
impl OpenRepositoryIdentity {
|
||||
fn matches(&self, source: &workspace_api::RepositorySource) -> bool {
|
||||
match source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri)
|
||||
.ok()
|
||||
.is_some_and(|path| path == self.root),
|
||||
workspace_api::RepositorySourceKind::File => source
|
||||
.uri
|
||||
.strip_prefix("file://")
|
||||
.and_then(|path| fs::canonicalize(path).ok())
|
||||
.is_some_and(|path| path == self.root),
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Https => self
|
||||
.remote_uris
|
||||
.iter()
|
||||
.any(|uri| normalize_git_uri(uri) == normalize_git_uri(&source.uri)),
|
||||
workspace_api::RepositorySourceKind::Invalid => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_open_repository(path: &Path) -> Result<OpenRepositoryIdentity, ParseError> {
|
||||
let root = git_stdout(path, ["rev-parse", "--show-toplevel"])?;
|
||||
let root = fs::canonicalize(root.trim()).map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to resolve current Git repository root: {error}"
|
||||
))
|
||||
})?;
|
||||
let remote_uris = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&root)
|
||||
.args(["remote", "get-url", "--all", "origin"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(OpenRepositoryIdentity { root, remote_uris })
|
||||
}
|
||||
|
||||
fn git_stdout<const N: usize>(path: &Path, args: [&str; N]) -> Result<String, ParseError> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| ParseError(format!("failed to execute Git: {error}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(ParseError(
|
||||
"the current directory is not inside a readable Git repository".to_string(),
|
||||
));
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.map_err(|_| ParseError("Git returned a non-UTF-8 repository path".to_string()))
|
||||
}
|
||||
|
||||
fn normalize_git_uri(uri: &str) -> String {
|
||||
let uri = uri.trim().trim_end_matches('/');
|
||||
uri.strip_suffix(".git").unwrap_or(uri).to_string()
|
||||
}
|
||||
|
||||
fn record_workspace_backend_routing(
|
||||
workspace_id: &str,
|
||||
backend_url: &str,
|
||||
) -> Result<(), ParseError> {
|
||||
let path = client_global_config_path().ok_or_else(|| {
|
||||
ParseError("unable to resolve the global client configuration directory".to_string())
|
||||
})?;
|
||||
record_workspace_backend_routing_at(&path, workspace_id, backend_url)
|
||||
}
|
||||
|
||||
fn record_workspace_backend_routing_at(
|
||||
path: &Path,
|
||||
workspace_id: &str,
|
||||
backend_url: &str,
|
||||
) -> Result<(), ParseError> {
|
||||
let mut config = match fs::read_to_string(path) {
|
||||
Ok(raw) => toml::from_str::<toml::Value>(&raw).map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to parse global client config {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
toml::Value::Table(toml::map::Map::new())
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(ParseError(format!(
|
||||
"failed to read global client config {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let table = config
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ParseError("global client config must be a TOML table".to_string()))?;
|
||||
let backends = table
|
||||
.entry("backends")
|
||||
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ParseError("global client config `backends` must be a table".to_string()))?;
|
||||
let existing_name = backends.iter().find_map(|(name, value)| {
|
||||
value
|
||||
.get("url")
|
||||
.and_then(toml::Value::as_str)
|
||||
.filter(|url| *url == backend_url)
|
||||
.map(|_| name.clone())
|
||||
});
|
||||
let backend_name = existing_name.unwrap_or_else(|| {
|
||||
let mut candidate = "init".to_string();
|
||||
let mut suffix = 2_u32;
|
||||
while backends.contains_key(&candidate) {
|
||||
candidate = format!("init-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
backends.insert(
|
||||
candidate.clone(),
|
||||
toml::Value::Table(toml::map::Map::from_iter([(
|
||||
"url".to_string(),
|
||||
toml::Value::String(backend_url.to_string()),
|
||||
)])),
|
||||
);
|
||||
candidate
|
||||
});
|
||||
table
|
||||
.entry("default_connection")
|
||||
.or_insert_with(|| toml::Value::String("backend".to_string()));
|
||||
table
|
||||
.entry("default_backend")
|
||||
.or_insert_with(|| toml::Value::String(backend_name.clone()));
|
||||
let workspaces = table
|
||||
.entry("workspaces")
|
||||
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| {
|
||||
ParseError("global client config `workspaces` must be a table".to_string())
|
||||
})?;
|
||||
workspaces.insert(
|
||||
workspace_id.to_string(),
|
||||
toml::Value::Table(toml::map::Map::from_iter([(
|
||||
"backend".to_string(),
|
||||
toml::Value::String(backend_name),
|
||||
)])),
|
||||
);
|
||||
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
ParseError("global client config path has no parent directory".to_string())
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to create global client config directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
let encoded = toml::to_string_pretty(&config)
|
||||
.map_err(|error| ParseError(format!("failed to encode global client config: {error}")))?;
|
||||
let temporary = path.with_extension(format!("toml.tmp-{}", uuid::Uuid::now_v7()));
|
||||
fs::write(&temporary, encoded).map_err(|error| {
|
||||
ParseError(format!(
|
||||
"failed to write global client config {}: {error}",
|
||||
temporary.display()
|
||||
))
|
||||
})?;
|
||||
fs::rename(&temporary, path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
ParseError(format!(
|
||||
"failed to publish global client config {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ClientDefaultConnection, read_client_config_from_global_path};
|
||||
|
||||
fn workspace_summary(id: &str, name: &str) -> workspace_api::WorkspaceSummary {
|
||||
workspace_api::WorkspaceSummary {
|
||||
workspace_id: id.to_string(),
|
||||
owner_account_id: "owner-account".to_string(),
|
||||
display_name: name.to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_summary(
|
||||
source: workspace_api::RepositorySource,
|
||||
) -> workspace_api::RepositorySummary {
|
||||
workspace_api::RepositorySummary {
|
||||
repository_key: "main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: "builtin:git".to_string(),
|
||||
source,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "fingerprint".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
default_selector: Some("develop".to_string()),
|
||||
record_authority: "server_db".to_string(),
|
||||
git: None,
|
||||
diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
|
||||
let repository = tempfile::tempdir().unwrap();
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(repository.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
let identity = discover_open_repository(repository.path()).unwrap();
|
||||
let source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: identity.root.display().to_string(),
|
||||
};
|
||||
let matching_repository = repository_summary(source);
|
||||
let workspace_a = workspace_summary("workspace-a", "Workspace A");
|
||||
let workspace_b = workspace_summary("workspace-b", "Workspace B");
|
||||
|
||||
assert_eq!(
|
||||
select_workspace_from_repository_catalog(
|
||||
&identity,
|
||||
&[(workspace_a.clone(), vec![matching_repository.clone()])],
|
||||
)
|
||||
.unwrap(),
|
||||
"workspace-a"
|
||||
);
|
||||
assert!(
|
||||
select_workspace_from_repository_catalog(&identity, &[])
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not registered in any accessible Workspace")
|
||||
);
|
||||
let multiple = select_workspace_from_repository_catalog(
|
||||
&identity,
|
||||
&[
|
||||
(workspace_a, vec![matching_repository.clone()]),
|
||||
(workspace_b, vec![matching_repository]),
|
||||
],
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(multiple.contains("matches multiple accessible Workspaces"));
|
||||
assert!(multiple.contains("--workspace-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_routing_config_survives_reload_without_repository_local_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("client.toml");
|
||||
|
||||
record_workspace_backend_routing_at(&path, "workspace-a", "http://backend.example")
|
||||
.unwrap();
|
||||
let first = read_client_config_from_global_path(Some(&path))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let second = read_client_config_from_global_path(Some(&path))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.default_connection, ClientDefaultConnection::Backend);
|
||||
assert_eq!(first.default_backend.as_deref(), Some("init"));
|
||||
assert_eq!(
|
||||
first
|
||||
.workspaces
|
||||
.get("workspace-a")
|
||||
.and_then(|entry| entry.backend.as_deref()),
|
||||
Some("init")
|
||||
);
|
||||
assert_eq!(second, first);
|
||||
assert!(!temp.path().join(".yoi/workspace.toml").exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user