Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1082,6 +1082,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();
|
||||
@@ -1267,7 +1272,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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user