chore: merge develop into work/companion

# Conflicts:
#	crates/tui/src/app.rs
This commit is contained in:
2026-08-21 07:18:07 +09:00
61 changed files with 7110 additions and 523 deletions
+41 -6
View File
@@ -2843,12 +2843,6 @@ mod tests {
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
let db_path = dir.path().join("workspace.db");
SqliteTicketBackend::open(&db_path, "workspace-test")
.unwrap()
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
.unwrap();
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
@@ -2861,6 +2855,27 @@ mod tests {
})
.await
.unwrap();
SqliteTicketBackend::open(&db_path, "workspace-test")
.unwrap()
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute_batch(
r#"
INSERT INTO workspace_resource_human_keys (
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
) VALUES
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'),
('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z');
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
VALUES ('workspace-test', 'ticket', 4);
"#,
)
.unwrap();
store
.upsert_objective(&ObjectiveRecord {
workspace_id: "workspace-test".to_string(),
@@ -3216,6 +3231,26 @@ mod tests {
})
.await
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
INSERT INTO workspace_resource_human_keys (
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
) VALUES
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z');
INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence)
VALUES ('workspace-test', 'ticket', 3);
"#,
)
.unwrap();
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
let created = authority
+8
View File
@@ -2447,6 +2447,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
#[derive(Clone)]
pub struct RemoteRuntimeConfig {
pub runtime_id: String,
/// Explicit Workspace assignment granted by Server authority.
pub workspace_id: Option<String>,
pub display_name: String,
pub base_url: String,
pub bearer_token: Option<String>,
@@ -2489,6 +2491,7 @@ impl RemoteRuntimeConfig {
) -> Self {
Self {
runtime_id: runtime_id.into(),
workspace_id: None,
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
@@ -2501,6 +2504,11 @@ impl RemoteRuntimeConfig {
}
}
pub fn with_workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
self.workspace_id = Some(workspace_id.into());
self
}
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
self.cached_capabilities = capabilities;
self
+9 -1
View File
@@ -27,6 +27,7 @@ pub mod server;
pub mod skills;
pub mod store;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_subscription;
pub use authority::{
@@ -45,8 +46,15 @@ pub use repositories::{
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
};
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
pub use server::{
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
build_workspace_server_router, serve, serve_workspace_catalog,
};
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
pub use workspace_catalog::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest,
WorkspaceCreateResponse,
};
use worker_runtime::identity::RuntimeWorkerRef;
+81 -54
View File
@@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{RepositoryRecord, SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile, WorkspaceIdentity, WorkspaceRecord, serve,
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
#[derive(Debug)]
@@ -152,30 +153,21 @@ async fn run_init_with_database_path(
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: identity.workspace_id.clone(),
owner_account_id: None,
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let service = WorkspaceCatalogService::new(store);
service.create_with_workspace_id(
WorkspaceCreateRequest {
operation_key: format!("cli-init:{}", identity.workspace_id),
display_name: identity.display_name.clone(),
state: "active".to_string(),
created_at: identity.created_at.clone(),
updated_at: identity.created_at.clone(),
})
.await?;
store.upsert_repository(&RepositoryRecord {
workspace_id: identity.workspace_id.clone(),
repository_id: "main".to_string(),
name: "Main repository".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: options.workspace.display().to_string(),
default_ref: Some("HEAD".to_string()),
auth_ref_kind: None,
auth_ref_key: None,
created_at: identity.created_at.clone(),
updated_at: identity.created_at.clone(),
})?;
repository: InitialRepositoryIntent {
uri: options.workspace.display().to_string(),
display_name: Some("Main repository".to_string()),
default_ref: Some("HEAD".to_string()),
},
},
None,
Some(identity.workspace_id.clone()),
)?;
eprintln!(
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
@@ -358,6 +350,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
match subcommand.as_str() {
"add" => {
let mut runtime_id = None;
let mut workspace_id = None;
let mut base_url = None;
let mut public_key = None;
let mut display_name = None;
@@ -368,6 +361,9 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--base-url" | "--endpoint" => {
base_url = Some(take_value(&flag, inline_value, &mut args)?)
}
@@ -390,15 +386,39 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
}
let runtime_id = runtime_id
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
let workspace_id = workspace_id
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
if !store
.list_workspaces()?
.iter()
.any(|workspace| workspace.workspace_id == workspace_id)
{
return Err(Box::new(CliError(format!(
"Workspace `{workspace_id}` is not registered"
))));
}
let base_url = base_url
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
if let Some(existing) = store
.list_trusted_runtimes(true)?
.into_iter()
.find(|runtime| runtime.runtime_id == runtime_id)
{
if existing.workspace_id.as_deref() != Some(workspace_id.as_str()) {
return Err(Box::new(CliError(format!(
"runtime `{runtime_id}` is already assigned to Workspace `{}` and cannot be reparented",
existing.workspace_id.as_deref().unwrap_or("unassigned")
))));
}
}
let now = Utc::now().to_rfc3339();
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
runtime_id: runtime_id.clone(),
workspace_id: Some(workspace_id.clone()),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
@@ -437,8 +457,9 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
} else {
for runtime in records {
println!(
"runtime_id={} base_url={} public_key={} revoked_at={}",
"runtime_id={} workspace_id={} base_url={} public_key={} revoked_at={}",
runtime.runtime_id,
runtime.workspace_id.unwrap_or_default(),
runtime.base_url,
runtime.public_key,
runtime.revoked_at.unwrap_or_default()
@@ -582,12 +603,28 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let workspace = select_serve_workspace(store.as_ref())?;
let workspace_root = infer_workspace_root_from_repositories(store.as_ref(), &workspace)?;
let identity = WorkspaceIdentity {
workspace_id: workspace.workspace_id.clone(),
created_at: workspace.created_at.clone(),
display_name: workspace.display_name.clone(),
let workspaces = store.list_workspaces()?;
let (identity, workspace_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(),
},
infer_workspace_root_from_repositories(store.as_ref(), workspace)?,
)
} else {
(
WorkspaceIdentity {
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
created_at: Utc::now().to_rfc3339(),
display_name: "Server bootstrap".to_string(),
},
database_path
.parent()
.ok_or_else(|| CliError("server database path has no parent".to_string()))?
.to_path_buf(),
)
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
@@ -601,6 +638,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?;
@@ -608,12 +646,12 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
resolved = resolved.with_backend_base_url(format!("http://{local_addr}"));
}
eprintln!(
"yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
workspace.workspace_id,
"yoi-server: serving {} workspace(s) from server DB `{}` on http://{}",
workspaces.len(),
database_path.display(),
local_addr
);
serve(resolved.server, store, listener).await?;
serve_workspace_catalog(resolved.server, store, listener).await?;
Ok(())
}
@@ -630,6 +668,9 @@ fn append_trusted_runtime_sources(
return Ok(());
};
for runtime in store.list_trusted_runtimes(false)? {
let Some(workspace_id) = runtime.workspace_id.clone() else {
continue;
};
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
@@ -640,6 +681,7 @@ fn append_trusted_runtime_sources(
runtime.base_url,
None,
)
.with_workspace_id(workspace_id)
.with_auth(auth);
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
remote_runtime_sources.push(remote);
@@ -647,23 +689,6 @@ fn append_trusted_runtime_sources(
Ok(())
}
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
let workspaces = store
.list_workspaces()
.map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?;
match workspaces.as_slice() {
[] => Err(CliError(
"server DB has no workspace records; run `yoi-server init --workspace <PATH>`"
.to_string(),
)),
[workspace] => Ok(workspace.clone()),
_ => Err(CliError(format!(
"server DB contains {} workspaces; serve workspace selection is not implemented yet",
workspaces.len()
))),
}
}
fn infer_workspace_root_from_repositories(
store: &SqliteWorkspaceStore,
workspace: &WorkspaceRecord,
@@ -914,7 +939,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -1038,6 +1063,7 @@ mod tests {
store
.upsert_trusted_runtime(&TrustedRuntimeRecord {
runtime_id: "runtime-a".to_string(),
workspace_id: None,
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
@@ -1059,6 +1085,7 @@ mod tests {
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
std::fs::create_dir(temp.path().join(".git")).unwrap();
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
+9
View File
@@ -1066,6 +1066,11 @@ mod tests {
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
[worker_id().to_string()],
)?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
[],
)?;
Ok(())
})
.unwrap();
@@ -1249,7 +1254,11 @@ mod tests {
fn purge_tombstone_commit_is_idempotent() {
let s = setup();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?;
Ok(())
}).unwrap();
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
};
use crate::{Error, Result};
const DEFAULT_REPOSITORY_ID: &str = "main";
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 uri: String,
#[serde(default)]
pub display_name: Option<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, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceCreateResponse {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
#[derive(Clone)]
pub struct WorkspaceCatalogService {
store: Arc<dyn ControlPlaneStore>,
}
impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
Self { store }
}
pub fn list(
&self,
owner_account_id: Option<&str>,
limit: usize,
) -> Result<Vec<WorkspaceRecord>> {
let limit = limit.clamp(1, 200);
Ok(self
.store
.list_workspaces()?
.into_iter()
.filter(|workspace| {
workspace.owner_account_id.is_none()
|| owner_account_id
.is_some_and(|owner| workspace.owner_account_id.as_deref() == Some(owner))
})
.take(limit)
.collect())
}
pub fn create(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, None, false)
}
pub fn create_first_ownerless(
&self,
request: WorkspaceCreateRequest,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, None, None, true)
}
pub fn create_with_workspace_id(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, requested_workspace_id, false)
}
fn create_internal(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
requested_workspace_id: Option<String>,
require_empty_catalog: bool,
) -> Result<WorkspaceCreateResponse> {
let operation_key = normalize_required(
"operation_key",
request.operation_key,
MAX_OPERATION_KEY_BYTES,
)?;
let display_name =
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_path = validate_repository_uri(&request.repository.uri)?;
let repository_uri = repository_path.to_string_lossy().into_owned();
let repository_name = request
.repository
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Main repository")
.to_string();
let default_ref = request
.repository
.default_ref
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("HEAD")
.to_string();
let requested_workspace_id = requested_workspace_id
.map(|value| {
Uuid::parse_str(value.trim())
.map(|id| id.to_string())
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
})
.transpose()?;
let workspace_id = requested_workspace_id
.clone()
.unwrap_or_else(|| Uuid::now_v7().to_string());
let fingerprint = workspace_create_fingerprint(
requested_workspace_id.as_deref(),
&display_name,
owner_account_id.as_deref(),
&repository_uri,
&repository_name,
&default_ref,
);
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let result = self
.store
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key,
request_fingerprint: fingerprint.clone(),
require_empty_catalog,
workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(),
owner_account_id,
display_name,
state: "active".to_string(),
created_at: now.clone(),
updated_at: now.clone(),
},
repository: RepositoryRecord {
workspace_id,
repository_id: DEFAULT_REPOSITORY_ID.to_string(),
name: repository_name,
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: repository_uri,
default_ref: Some(default_ref),
auth_ref_kind: None,
auth_ref_key: None,
created_at: now.clone(),
updated_at: now,
},
})?;
Ok(WorkspaceCreateResponse {
workspace: result.workspace,
repository: result.repository,
config_revision: result.config_revision,
request_fingerprint: fingerprint,
replayed: result.replayed,
})
}
}
fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > max_bytes {
return Err(Error::InvalidInput(format!(
"{field} must be between 1 and {max_bytes} bytes"
)));
}
Ok(value.to_string())
}
fn validate_repository_uri(uri: &str) -> Result<PathBuf> {
let uri = uri.trim();
if uri.is_empty() || uri.contains("://") {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = Path::new(uri);
if !path.is_absolute() {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = path.canonicalize().map_err(|error| {
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
})?;
if !path.is_dir() {
return Err(Error::InvalidInput(
"initial repository path must be a directory".to_string(),
));
}
let normal_git = path.join(".git").exists();
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
if !normal_git && !bare_git {
return Err(Error::InvalidInput(
"initial repository path is not a Git repository".to_string(),
));
}
Ok(path)
}
fn workspace_create_fingerprint(
requested_workspace_id: Option<&str>,
display_name: &str,
owner_account_id: Option<&str>,
repository_uri: &str,
repository_name: &str,
default_ref: &str,
) -> String {
let payload = serde_json::json!({
"requested_workspace_id": requested_workspace_id,
"display_name": display_name,
"owner_account_id": owner_account_id,
"repository": {
"repository_id": DEFAULT_REPOSITORY_ID,
"uri": repository_uri,
"display_name": repository_name,
"default_ref": default_ref,
"kind": "git",
}
});
let mut hasher = Sha256::new();
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
let digest = hasher.finalize();
let encoded = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("sha256:{encoded}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SqliteWorkspaceStore;
fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
dir
}
#[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
display_name: None,
default_ref: None,
},
};
let created = service.create(request.clone(), None).unwrap();
let replayed = service.create(request, None).unwrap();
assert!(!created.replayed);
assert!(replayed.replayed);
assert_eq!(
created.workspace.workspace_id,
replayed.workspace.workspace_id
);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
assert_eq!(
store
.list_repositories(&created.workspace.workspace_id)
.unwrap()
.len(),
1
);
assert!(
store
.load_workspace_config(&created.workspace.workspace_id)
.unwrap()
.is_some()
);
}
#[test]
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let repository_a = git_repository();
let repository_b = git_repository();
let requests = [
WorkspaceCreateRequest {
operation_key: "bootstrap-a".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
WorkspaceCreateRequest {
operation_key: "bootstrap-b".to_string(),
display_name: "Workspace B".to_string(),
repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
];
let barrier = Arc::new(std::sync::Barrier::new(2));
let results = std::thread::scope(|scope| {
requests
.into_iter()
.map(|request| {
let service = service.clone();
let barrier = barrier.clone();
scope.spawn(move || {
barrier.wait();
service.create_first_ownerless(request)
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
let error = results
.into_iter()
.find_map(Result::err)
.unwrap()
.to_string();
assert!(error.contains("catalog is empty"), "{error}");
}
#[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store);
let repository = git_repository();
let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
display_name: None,
default_ref: None,
},
};
service.create(request.clone(), None).unwrap();
request.display_name = "Workspace B".to_string();
let error = service.create(request, None).unwrap_err().to_string();
assert!(error.contains("different input"), "{error}");
}
#[test]
fn repository_intent_rejects_remote_and_non_git_paths() {
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err();
assert!(remote.to_string().contains("server-local path"));
let dir = tempfile::tempdir().unwrap();
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err();
assert!(non_git.to_string().contains("not a Git repository"));
}
}