diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 639e7cc75..55f882111 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -434,11 +434,11 @@ mod tests { let request = CreateBackendWorkspaceRequest { operation_key: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), - repository: CreateBackendWorkspaceRepository { + repository: Some(CreateBackendWorkspaceRepository { repository_key: "main".to_string(), uri: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; let retry = request.clone(); diff --git a/crates/client/tests/backend_workspace.rs b/crates/client/tests/backend_workspace.rs index accd61fc2..8920bdcc8 100644 --- a/crates/client/tests/backend_workspace.rs +++ b/crates/client/tests/backend_workspace.rs @@ -8,11 +8,11 @@ fn workspace_creation_request_preserves_operation_key_for_retry() { let request = CreateBackendWorkspaceRequest { operation_key: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), - repository: CreateBackendWorkspaceRepository { + repository: Some(CreateBackendWorkspaceRepository { repository_key: "main".to_string(), uri: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; assert_eq!(request.clone(), request); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 254c61a24..580d26d09 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4239,22 +4239,27 @@ pub struct InitialRepositoryIntent { pub default_ref: Option, } -/// Request for atomically creating a Workspace and its initial Repository. +/// Request for atomically creating a Workspace with an optional initial Repository. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateRequest { pub operation_key: String, pub display_name: String, - pub repository: InitialRepositoryIntent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, } -/// Response returned after atomically creating a Workspace and its first Repository. +/// Response returned after atomically creating a Workspace. +/// +/// On replay, Workspace/config and the Repository at the originally requested key are +/// current authority, not historical snapshots. Repository is null when creation omitted +/// it or that key has since been removed/renamed; replay never recreates an asset. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateResponse { pub workspace: WorkspaceSummary, - pub repository: WorkspaceRepositoryRecord, + pub repository: Option, #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, @@ -12204,12 +12209,27 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: "workspace-create-1".to_string(), display_name: "Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { repository_key: "main".to_string(), uri: "/srv/repositories/main".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; + for repository in [None, Some(serde_json::Value::Null)] { + let mut wire = + serde_json::json!({"operation_key": "empty", "display_name": "Workspace"}); + if let Some(repository) = repository { + wire["repository"] = repository; + } + let decoded: WorkspaceCreateRequest = serde_json::from_value(wire).unwrap(); + assert!(decoded.repository.is_none()); + assert!( + serde_json::to_value(decoded) + .unwrap() + .get("repository") + .is_none() + ); + } let json = serde_json::to_value(&request).unwrap(); assert_eq!(json["operation_key"], "workspace-create-1"); assert_eq!(json["repository"]["uri"], "/srv/repositories/main"); diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index b4b95755a..0096714ef 100644 --- a/crates/tui/src/backend_workspace_picker.rs +++ b/crates/tui/src/backend_workspace_picker.rs @@ -199,30 +199,40 @@ fn prompt_create_request_with_io( if display_name.is_empty() { return Ok(None); } - let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else { - return Ok(None); - }; - let uri = uri.trim().to_string(); - if uri.is_empty() { - writeln!(output, "Repository path/URI is required.")?; - return Ok(None); - } - let repository_key = loop { - let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")? - else { - return Ok(None); - }; - if repository_key.trim().is_empty() { - writeln!(output, "Repository key is required.")?; - continue; - } - break repository_key; - }; - let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")? + let Some(uri) = prompt_line( + input, + output, + "Initial repository absolute path/URI (optional, Enter to skip): ", + )? else { return Ok(None); }; - let default_ref = default_ref.trim().to_string(); + let uri = uri.trim().to_string(); + let repository = if uri.is_empty() { + None + } else { + let repository_key = loop { + let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")? + else { + return Ok(None); + }; + if repository_key.trim().is_empty() { + writeln!(output, "Repository key is required.")?; + continue; + } + break repository_key; + }; + let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")? + else { + return Ok(None); + }; + let default_ref = default_ref.trim().to_string(); + Some(CreateBackendWorkspaceRepository { + uri, + repository_key, + default_ref: (!default_ref.is_empty()).then_some(default_ref), + }) + }; let operation_key = format!( "tui-workspace-create-{}-{}", std::process::id(), @@ -234,11 +244,7 @@ fn prompt_create_request_with_io( Ok(Some(CreateBackendWorkspaceRequest { operation_key, display_name, - repository: CreateBackendWorkspaceRepository { - uri, - repository_key, - default_ref: (!default_ref.is_empty()).then_some(default_ref), - }, + repository, })) } @@ -286,15 +292,29 @@ mod tests { ); } + #[test] + fn workspace_creation_without_initial_repository_skips_repository_prompts() { + let (request, output) = prompt_create("Workspace\n\n"); + assert!(request.unwrap().repository.is_none()); + assert!(!output.contains("Repository key (required)")); + assert!(!output.contains("Default ref")); + } + #[test] fn workspace_creation_preserves_an_explicit_non_main_repository_key() { let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n"); let request = request.expect("complete input should create a request"); assert_eq!(request.display_name, "Platform"); - assert_eq!(request.repository.uri, "/srv/platform"); - assert_eq!(request.repository.repository_key, "platform"); - assert_eq!(request.repository.default_ref.as_deref(), Some("develop")); + assert_eq!(request.repository.as_ref().unwrap().uri, "/srv/platform"); + assert_eq!( + request.repository.as_ref().unwrap().repository_key, + "platform" + ); + assert_eq!( + request.repository.as_ref().unwrap().default_ref.as_deref(), + Some("develop") + ); assert!(output.contains("Repository key (required): ")); assert!(!output.contains("Repository key [main]")); } @@ -304,7 +324,10 @@ mod tests { let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n"); let request = request.expect("a later explicit key should create a request"); - assert_eq!(request.repository.repository_key, "platform"); + assert_eq!( + request.repository.as_ref().unwrap().repository_key, + "platform" + ); assert_eq!(output.matches("Repository key is required.").count(), 2); assert_eq!(output.matches("Repository key (required): ").count(), 3); } @@ -314,7 +337,7 @@ mod tests { let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n"); assert_eq!( - request.unwrap().repository.repository_key, + request.unwrap().repository.unwrap().repository_key, " platform ", "the Backend must validate the creator's exact Repository key" ); @@ -325,7 +348,7 @@ mod tests { let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n"); assert_eq!( - request.unwrap().repository.repository_key, + request.unwrap().repository.unwrap().repository_key, "main", "main remains valid when the creator enters it explicitly" ); diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 69840f65e..9da499791 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -75,18 +75,6 @@ impl RepositoryRegistryReader { } pub fn list(&self) -> RepositoryListProjection { - if self.repositories.is_empty() { - return RepositoryListProjection { - items: Vec::new(), - diagnostics: vec![Diagnostic { - severity: DiagnosticSeverity::Warning, - code: "repository_config_empty".to_string(), - message: "No repositories are configured for this workspace backend." - .to_string(), - }], - }; - } - RepositoryListProjection { items: self .repositories @@ -598,12 +586,11 @@ mod tests { } #[test] - fn empty_registry_reports_diagnostic_without_implicit_repository() { + fn empty_registry_is_valid_without_warning_or_implicit_repository() { let projection = RepositoryRegistryReader::new(Vec::new()).list(); assert!(projection.items.is_empty()); - assert_eq!(projection.diagnostics.len(), 1); - assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); + assert!(projection.diagnostics.is_empty()); } #[test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 63f1b7934..848f0cfad 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -355,14 +355,16 @@ impl ServerConfig { workspace: &WorkspaceRecord, repositories: Vec, ) -> Result { - if repositories.is_empty() { - return Err(Error::Config(format!( - "Workspace {} has no registered repository", - workspace.workspace_id - ))); - } - let workspace_execution_root = - Self::default_workspace_backend_data_root(&workspace.workspace_id); + // Derive scoped storage from the configured authority database, not process-global + // defaults. This also keeps custom server roots and temporary API fixtures isolated. + let workspace_execution_root = self + .database_path + .parent() + .ok_or_else(|| { + Error::Config("Server database path must have a parent directory".to_string()) + })? + .join("workspaces") + .join(&workspace.workspace_id); let repositories = repositories .into_iter() .map(|repository| ConfiguredRepository { @@ -386,9 +388,8 @@ impl ServerConfig { scoped .workspace_created_at .clone_from(&workspace.created_at); + scoped.embedded_runtime_store_root = workspace_execution_root.join("embedded-runtime"); scoped.workspace_execution_root = workspace_execution_root; - scoped.embedded_runtime_store_root = - Self::default_embedded_runtime_store_root(&workspace.workspace_id); scoped.repositories = repositories; // Runtime trust is server-global. Only explicitly assigned sources enter // this Workspace's registry and receive Workspace-scoped capabilities. @@ -2549,7 +2550,7 @@ fn workspace_create_response( ) -> WorkspaceCreateResponse { WorkspaceCreateResponse { workspace: workspace_summary(created.workspace), - repository: workspace_repository_record(created.repository), + repository: created.repository.map(workspace_repository_record), config_revision: created.config_revision, request_fingerprint: created.request_fingerprint, replayed: created.replayed, @@ -42748,7 +42749,10 @@ mod tests { ); assert_eq!( scoped.workspace_execution_root, - ServerConfig::default_workspace_backend_data_root("remote-workspace") + base.database_path + .parent() + .unwrap() + .join("workspaces/remote-workspace") ); } @@ -45045,6 +45049,235 @@ mod tests { assert!(auth["cookie_name"].is_string()); } + #[tokio::test] + async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() { + let temp = tempfile::tempdir().unwrap(); + let config = test_server_config(temp.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let owner = seed_test_api_token(store.as_ref(), "empty-owner"); + let foreign = seed_test_api_token(store.as_ref(), "empty-foreign"); + let app = build_workspace_server_router(config.clone(), store.clone()) + .await + .unwrap(); + let wire = + json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"}); + request_json( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + StatusCode::FORBIDDEN, + ) + .await; + let created = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::CREATED, + ) + .await; + assert!(created["repository"].is_null()); + assert!(!created["replayed"].as_bool().unwrap()); + let id = created["workspace"]["workspace_id"].as_str().unwrap(); + assert!(store.list_repositories(id).unwrap().is_empty()); + let scoped = format!("/api/w/{id}"); + + let replay = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(replay["workspace"]["workspace_id"], id); + assert!(replay["replayed"].as_bool().unwrap()); + let repositories = request_json_authenticated( + app.clone(), + "GET", + &format!("{scoped}/repositories"), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(repositories["items"], json!([])); + assert_eq!(repositories["diagnostics"], json!([])); + request_json_authenticated( + app.clone(), + "GET", + &format!("{scoped}/workspace"), + None, + &owner, + StatusCode::OK, + ) + .await; + request_json( + app.clone(), + "GET", + &format!("{scoped}/workspace"), + None, + StatusCode::UNAUTHORIZED, + ) + .await; + let foreign_catalog = request_json_authenticated( + app.clone(), + "GET", + "/api/workspaces", + None, + &foreign, + StatusCode::OK, + ) + .await; + assert_eq!(foreign_catalog, json!([])); + let deletion = request_json_authenticated( + app.clone(), + "GET", + &format!("/api/workspaces/{id}/deletion"), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(deletion["resources"]["repositories"], 0); + assert_eq!( + deletion["blockers"][0]["kind"], "last_accessible_workspace", + "{deletion}" + ); + let worker = request_json_authenticated(app.clone(), "POST", &format!("{scoped}/workers"), Some(json!({"runtime_id": "embedded-worker-runtime", "display_name": "Worker", "initial_submit": [], "feature_connections": {}})), &owner, StatusCode::OK).await; + assert!(worker["worker_id"].is_string(), "{worker}"); + let detail = request_json_authenticated( + app.clone(), + "GET", + &format!( + "{scoped}/runtimes/embedded-worker-runtime/workers/{}", + worker["worker_id"].as_str().unwrap() + ), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(detail["profile"], "builtin:companion"); + let worker_ref = RuntimeWorkerRef::new( + "embedded-worker-runtime", + worker["worker_id"].as_str().unwrap(), + ); + assert!( + store + .list_worker_workdir_links(id, &worker_ref) + .unwrap() + .is_empty() + ); + assert_eq!(detail["resource_key"], "W-1"); + let repository = json!({"repository_key": "later", "source": "https://example.test/later.git", "default_ref": "main"}); + request_json_authenticated( + app.clone(), + "POST", + &format!("{scoped}/repositories"), + Some(repository.clone()), + &foreign, + StatusCode::FORBIDDEN, + ) + .await; + request_json_authenticated( + app.clone(), + "POST", + &format!("{scoped}/repositories"), + Some(repository), + &owner, + StatusCode::CREATED, + ) + .await; + assert_eq!(store.list_repositories(id).unwrap().len(), 1); + let replay = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::OK, + ) + .await; + assert!( + replay["repository"].is_null(), + "omitted initial repository never means a later asset" + ); + let mut conflict = wire.clone(); + conflict["repository"] = + json!({"repository_key": "later", "uri": "https://example.test/later.git"}); + request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(conflict), + &owner, + StatusCode::CONFLICT, + ) + .await; + for repository in [ + json!({"repository_key": "main", "uri": "relative/repo"}), + json!({"repository_key": " main ", "uri": "/runtime/repo"}), + ] { + let invalid = json!({"operation_key": "invalid-empty-repo", "display_name": "Workspace", "repository": repository}); + request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(invalid), + &owner, + StatusCode::BAD_REQUEST, + ) + .await; + } + assert_eq!(store.list_workspaces().unwrap().len(), 1); + // With another accessible Workspace, zero repositories are not a deletion blocker. + let second = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(json!({"operation_key": "second-empty", "display_name": "Second Empty"})), + &owner, + StatusCode::CREATED, + ) + .await; + let second_id = second["workspace"]["workspace_id"].as_str().unwrap(); + let deletion_uri = format!("/api/workspaces/{second_id}/deletion"); + let preflight = request_json_authenticated( + app.clone(), + "GET", + &deletion_uri, + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(preflight["can_delete"], true, "{preflight}"); + assert_eq!(preflight["resources"]["repositories"], 0); + let deletion_request = json!({"operation_id": "delete-second-empty", "expected_revision": preflight["expected_revision"], "confirmation": "Second Empty"}); + request_json_authenticated( + app.clone(), + "POST", + &deletion_uri, + Some(deletion_request.clone()), + &foreign, + StatusCode::FORBIDDEN, + ) + .await; + request_json_authenticated( + app, + "POST", + &deletion_uri, + Some(deletion_request), + &owner, + StatusCode::ACCEPTED, + ) + .await; + } + #[tokio::test] async fn workspace_server_router_requires_identity_for_scoped_rest() { let temp = tempfile::tempdir().unwrap(); @@ -45087,11 +45320,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-auth".to_owned(), display_name: "Auth Workspace".to_owned(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository.display().to_string(), default_ref: None, - }, + }), }, "account-auth".to_owned(), ) @@ -45217,11 +45450,11 @@ mod tests { let create_request = WorkspaceCreateRequest { operation_key: "create-authenticated-workspace".to_owned(), display_name: "Authenticated Workspace".to_owned(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: created_repository.display().to_string(), default_ref: Some("develop".to_owned()), - }, + }), }; let created_response = app .clone() @@ -46345,11 +46578,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-a".to_string(), display_name: "Workspace A".to_string(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository_a.display().to_string(), default_ref: None, - }, + }), }, "account-two-workspaces".to_owned(), ) @@ -46359,11 +46592,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-b".to_string(), display_name: "Workspace B".to_string(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository_b.display().to_string(), default_ref: None, - }, + }), }, "account-two-workspaces".to_owned(), ) @@ -57520,7 +57753,7 @@ mod tests { } #[tokio::test] - async fn empty_repository_config_returns_empty_list_with_warning() { + async fn empty_repository_config_returns_valid_empty_list_without_warning() { let root = tempfile::tempdir().unwrap(); let mut config = test_server_config(root.path()); config.repositories.clear(); @@ -57537,10 +57770,7 @@ mod tests { let repositories = get_json(app, "/api/repositories").await; assert!(repositories["items"].as_array().unwrap().is_empty()); - assert_eq!( - repositories["diagnostics"][0]["code"], - "repository_config_empty" - ); + assert_eq!(repositories["diagnostics"], json!([])); } #[tokio::test] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f9bd8e96c..c3e242f29 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -384,13 +384,13 @@ pub struct WorkspaceBootstrapRecord { pub operation_key: String, pub request_fingerprint: String, pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceBootstrapResult { pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, pub config_revision: u64, pub replayed: bool, } @@ -4431,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { signing_identity: &WorkspaceSigningIdentityActivation, identity_provisioning_operation_key: &str, ) -> Result { - validate_repository_record_identity(&record.repository)?; + if let Some(repository) = &record.repository { + validate_repository_record_identity(repository)?; + if repository.workspace_id != record.workspace.workspace_id { + return Err(Error::Store( + "Initial Repository does not belong to the Workspace bootstrap".to_string(), + )); + } + } if signing_identity.workspace_id != record.workspace.workspace_id { return Err(Error::Store( "Workspace signing identity does not belong to the Workspace bootstrap".to_string(), @@ -4472,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace_id], read_workspace_record, )?; - let repository = tx.query_row( + let repository = if let Some(initial) = &record.repository { tx.query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, source_kind, source_uri, default_ref, source_revision, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, - params![workspace.workspace_id, record.repository.repository_key], + params![workspace.workspace_id, initial.repository_key], read_repository_record, - )?; + ).optional()? } else { None }; let persisted_identity = tx.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, public_key_fingerprint, private_material_ref, revision, state, @@ -4566,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.workspace.updated_at, ], )?; + if let Some(repository) = &record.repository { tx.execute( r#"INSERT INTO repositories ( workspace_id, repository_id, repository_key, kind, provider, uri, @@ -4573,23 +4581,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { source_fingerprint, observed_status, observed_at, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, params![ - record.repository.workspace_id, - record.repository.repository_id, - record.repository.repository_key, - record.repository.kind, - record.repository.provider, - record.repository.source.uri, - record.repository.source.kind.as_str(), - record.repository.source.uri, - record.repository.default_ref, - record.repository.source_revision, - record.repository.source_fingerprint, - record.repository.observed_status.as_str(), - record.repository.observed_at, - record.repository.created_at, - record.repository.updated_at, + repository.workspace_id, + repository.repository_id, + repository.repository_key, + repository.kind, + repository.provider, + repository.source.uri, + repository.source.kind.as_str(), + repository.source.uri, + repository.default_ref, + repository.source_revision, + repository.source_fingerprint, + repository.observed_status.as_str(), + repository.observed_at, + repository.created_at, + repository.updated_at, ], )?; + } } if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() { let state = crate::config_source::initial_state()?; @@ -21732,7 +21741,7 @@ INSERT INTO worker_registry ( operation_key: "invalid-key".to_string(), request_fingerprint: "sha256:invalid-key".to_string(), workspace: workspace.clone(), - repository, + repository: Some(repository), }, &signing_identity, "identity-store-test", @@ -21771,7 +21780,7 @@ INSERT INTO worker_registry ( operation_key: "create-workspace".to_string(), request_fingerprint: "sha256:create-workspace".to_string(), workspace, - repository: valid_repository, + repository: Some(valid_repository), }; store .reserve_workspace_signing_identity_provisioning( @@ -21798,7 +21807,7 @@ INSERT INTO worker_registry ( ); let mut duplicate = first; duplicate.operation_key = "duplicate-workspace".to_string(); - duplicate.repository.repository_id = Uuid::now_v7().to_string(); + duplicate.repository.as_mut().unwrap().repository_id = Uuid::now_v7().to_string(); let error = store .create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test") .unwrap_err() diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index d9fce280e..199387849 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceCreateResult { pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, @@ -94,19 +94,23 @@ impl WorkspaceCatalogService { )?; let display_name = normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; - let repository_source = validate_repository_source(&request.repository.uri)?; - let repository_uri = repository_source.uri.clone(); - server_api::validate_repository_key(&request.repository.repository_key) - .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; - let repository_key = request.repository.repository_key.clone(); - let default_ref = request + let repository_intent = request .repository - .default_ref - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("HEAD") - .to_string(); + .map(|repository| { + let source = validate_repository_source(&repository.uri)?; + server_api::validate_repository_key(&repository.repository_key).map_err( + |error| Error::InvalidInput(format!("invalid Repository key: {error}")), + )?; + let default_ref = repository + .default_ref + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("HEAD") + .to_string(); + Ok::<_, Error>((repository.repository_key, source, default_ref)) + }) + .transpose()?; let requested_workspace_id = requested_workspace_id .map(|value| { Uuid::parse_str(value.trim()) @@ -121,9 +125,9 @@ impl WorkspaceCatalogService { requested_workspace_id.as_deref(), &display_name, Some(&owner_account_id), - &repository_key, - &repository_uri, - &default_ref, + repository_intent.as_ref().map(|(key, source, selector)| { + (key.as_str(), source.uri.as_str(), selector.as_str()) + }), ); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let (signing_identity, identity_provisioning_operation_key) = @@ -146,21 +150,23 @@ impl WorkspaceCatalogService { created_at: now.clone(), updated_at: now.clone(), }, - repository: RepositoryRecord { - workspace_id, - repository_id: Uuid::now_v7().to_string(), - repository_key: repository_key.clone(), - kind: "git".to_string(), - provider: Some("git".to_string()), - source: repository_source.clone(), - default_ref: Some(default_ref), - source_revision: 1, - source_fingerprint: repository_source_fingerprint(&repository_source), - observed_status: RepositoryObservedStatus::Unverified, - observed_at: None, - created_at: now.clone(), - updated_at: now, - }, + repository: repository_intent.map( + |(repository_key, repository_source, default_ref)| RepositoryRecord { + workspace_id, + repository_id: Uuid::now_v7().to_string(), + repository_key: repository_key.clone(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source: repository_source.clone(), + default_ref: Some(default_ref), + source_revision: 1, + source_fingerprint: repository_source_fingerprint(&repository_source), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: now.clone(), + updated_at: now, + }, + ), }, &signing_identity, &identity_provisioning_operation_key, @@ -193,20 +199,19 @@ fn workspace_create_fingerprint( requested_workspace_id: Option<&str>, display_name: &str, owner_account_id: Option<&str>, - repository_key: &str, - repository_uri: &str, - default_ref: &str, + repository: Option<(&str, &str, &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_key": repository_key, - "uri": repository_uri, - "default_ref": default_ref, + // Preserve repository-present fingerprint bytes for persisted create receipts. + "repository": repository.map(|(key, uri, selector)| serde_json::json!({ + "repository_key": key, + "uri": uri, + "default_ref": selector, "kind": "git", - } + })) }); let mut hasher = Sha256::new(); hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes")); @@ -294,11 +299,11 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "platform".to_string(), default_ref: None, - }, + }), }; let owner_account_id = owner_account(store.as_ref()); @@ -354,11 +359,11 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: operation_key.to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: repository_key.to_string(), default_ref: None, - }, + }), }; let error = service @@ -373,36 +378,37 @@ mod tests { #[tokio::test] async fn create_recovers_same_reserved_identity_after_material_write_failure() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let owner_account_id = owner_account(store.as_ref()); - let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); - let service = WorkspaceCatalogService::new( - store.clone(), - Arc::new(FailFirstMaterialWrite { - inner: materials.clone(), - fail: AtomicBool::new(true), - }), - ); - let repository = git_repository(); - let request = WorkspaceCreateRequest { - operation_key: "material-failure".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository.path().display().to_string(), - repository_key: "main".to_string(), - default_ref: None, - }, - }; + for with_repository in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FailFirstMaterialWrite { + inner: materials.clone(), + fail: AtomicBool::new(true), + }), + ); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "material-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: with_repository.then(|| InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }), + }; - assert!( - service - .create(request.clone(), owner_account_id.clone()) - .is_err() - ); - assert!(store.list_workspaces().unwrap().is_empty()); - let reserved_key = store + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let reserved_key = store .with_conn(|conn| { conn.query_row( "SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'", @@ -413,55 +419,57 @@ mod tests { }) .unwrap(); - drop(service); - drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let restarted = WorkspaceCatalogService::new(store.clone(), materials); - let created = restarted.create(request, owner_account_id).unwrap(); - let identity = store - .get_workspace_signing_identity(&created.workspace.workspace_id) - .unwrap() - .unwrap(); - assert_eq!(identity.key_id, reserved_key); - assert_eq!(store.list_workspaces().unwrap().len(), 1); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + } } #[tokio::test] async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let owner_account_id = owner_account(store.as_ref()); - let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); - let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); - let repository = git_repository(); - let request = WorkspaceCreateRequest { - operation_key: "db-failure".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository.path().display().to_string(), - repository_key: "main".to_string(), - default_ref: None, - }, - }; - store - .with_conn(|conn| { - conn.execute_batch( - r#"CREATE TRIGGER fail_workspace_create_identity_audit + for with_repository in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "db-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: with_repository.then(|| InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }), + }; + store + .with_conn(|conn| { + conn.execute_batch( + r#"CREATE TRIGGER fail_workspace_create_identity_audit BEFORE INSERT ON workspace_signing_identity_audit BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#, - )?; - Ok(()) - }) - .unwrap(); + )?; + Ok(()) + }) + .unwrap(); - assert!( - service - .create(request.clone(), owner_account_id.clone()) - .is_err() - ); - assert!(store.list_workspaces().unwrap().is_empty()); - let (reserved_key, material_ref) = store + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let (reserved_key, material_ref) = store .with_conn(|conn| { conn.query_row( "SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'", @@ -471,25 +479,26 @@ mod tests { .map_err(Error::from) }) .unwrap(); - assert!(materials.load(&material_ref).unwrap().is_some()); - store - .with_conn(|conn| { - conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?; - Ok(()) - }) - .unwrap(); + assert!(materials.load(&material_ref).unwrap().is_some()); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?; + Ok(()) + }) + .unwrap(); - drop(service); - drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let restarted = WorkspaceCatalogService::new(store.clone(), materials); - let created = restarted.create(request, owner_account_id).unwrap(); - let identity = store - .get_workspace_signing_identity(&created.workspace.workspace_id) - .unwrap() - .unwrap(); - assert_eq!(identity.key_id, reserved_key); - assert_eq!(identity.state, "active"); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(identity.state, "active"); + } } #[tokio::test] @@ -506,11 +515,11 @@ mod tests { let mut request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }; service .create(request.clone(), owner_account_id.clone()) @@ -524,6 +533,199 @@ mod tests { assert!(error.contains("different input"), "{error}"); } + #[test] + fn repository_free_create_initializes_authority_and_concurrent_retries_converge() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let request = WorkspaceCreateRequest { + operation_key: "empty-concurrent".to_string(), + display_name: "No repositories".to_string(), + repository: None, + }; + let barrier = Arc::new(std::sync::Barrier::new(4)); + let handles = (0..4) + .map(|_| { + let (service, request, owner, barrier) = ( + service.clone(), + request.clone(), + owner.clone(), + barrier.clone(), + ); + std::thread::spawn(move || { + barrier.wait(); + service.create(request, owner).unwrap() + }) + }) + .collect::>(); + let results = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + let id = &results[0].workspace.workspace_id; + assert_eq!(results.iter().filter(|result| !result.replayed).count(), 1); + assert!( + results + .iter() + .all(|result| &result.workspace.workspace_id == id && result.repository.is_none()) + ); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + assert!(store.list_repositories(id).unwrap().is_empty()); + assert!(store.load_workspace_config(id).unwrap().is_some()); + assert_eq!( + store + .get_workspace_signing_identity(id) + .unwrap() + .unwrap() + .state, + "active" + ); + let counters = store.with_conn(|conn| { + let mut query = conn.prepare("SELECT resource_kind, next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 ORDER BY resource_kind")?; + Ok(query.query_map([id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?.collect::, _>>()?) + }).unwrap(); + assert_eq!( + counters, + vec![ + ("objective".into(), 1), + ("ticket".into(), 1), + ("worker".into(), 1) + ] + ); + let mut different = request.clone(); + different.repository = Some(InitialRepositoryIntent { + repository_key: "main".into(), + uri: "/runtime/repo".into(), + default_ref: None, + }); + assert!( + service + .create(different, owner.clone()) + .unwrap_err() + .to_string() + .contains("different input") + ); + assert!(service.create(request, "missing-owner".into()).is_err()); + assert!(store.list_repositories(id).unwrap().is_empty()); + } + + #[test] + fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let request = WorkspaceCreateRequest { + operation_key: "current-repository".into(), + display_name: "Workspace".into(), + repository: Some(InitialRepositoryIntent { + repository_key: "main".into(), + uri: "/runtime/original".into(), + default_ref: None, + }), + }; + let created = service.create(request.clone(), owner.clone()).unwrap(); + let mut repository = created.repository.unwrap(); + repository.source = validate_repository_source("https://example.test/changed.git").unwrap(); + repository.source_revision += 1; + repository.source_fingerprint = repository_source_fingerprint(&repository.source); + store.with_conn(|conn| { + conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'", + rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?; + Ok(()) + }).unwrap(); + assert_eq!( + service + .create(request.clone(), owner.clone()) + .unwrap() + .repository, + Some(repository) + ); + store + .with_conn(|conn| { + conn.execute( + "DELETE FROM repositories WHERE workspace_id = ?1", + [&created.workspace.workspace_id], + )?; + Ok(()) + }) + .unwrap(); + let replay = service.create(request, owner).unwrap(); + assert!(replay.replayed); + assert!(replay.repository.is_none()); + assert!( + store + .list_repositories(&replay.workspace.workspace_id) + .unwrap() + .is_empty() + ); + } + + #[test] + fn persisted_repository_free_receipt_replays_after_catalog_reopen() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("server.db"); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let request = WorkspaceCreateRequest { + operation_key: "persist-empty".into(), + display_name: "Workspace".into(), + repository: None, + }; + let (owner, created) = { + let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new(store, materials.clone()); + let created = service.create(request.clone(), owner.clone()).unwrap(); + (owner, created) + }; + let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let reopened = WorkspaceCatalogService::new(store.clone(), materials); + let replay = reopened.create(request, owner).unwrap(); + assert_eq!(replay.workspace, created.workspace); + assert!(replay.replayed); + assert!(replay.repository.is_none()); + assert!( + store + .list_repositories(&replay.workspace.workspace_id) + .unwrap() + .is_empty() + ); + } + + #[test] + fn repository_present_fingerprint_preserves_saved_receipt_contract() { + let legacy_payload = serde_json::json!({ + "requested_workspace_id": null, "display_name": "Workspace", "owner_account_id": "owner", + "repository": {"repository_key": "main", "uri": "/runtime/repo", "default_ref": "HEAD", "kind": "git"} + }); + let legacy_digest = Sha256::digest(serde_json::to_vec(&legacy_payload).unwrap()); + let legacy = format!( + "sha256:{}", + legacy_digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + assert_eq!( + workspace_create_fingerprint( + None, + "Workspace", + Some("owner"), + Some(("main", "/runtime/repo", "HEAD")) + ), + legacy + ); + assert_ne!( + workspace_create_fingerprint(None, "Workspace", Some("owner"), None), + legacy + ); + } + #[test] fn repository_intent_accepts_unavailable_local_sources_without_server_io() { let remote = validate_repository_source("https://example.test/repo.git").unwrap(); @@ -564,11 +766,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "organization-owner-create".to_string(), display_name: "Organization Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, "organization-owner".to_string(), ) @@ -605,11 +807,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "owner-a-create".to_string(), display_name: "Owner A Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository_a.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, owner_a.clone(), ) @@ -619,11 +821,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "owner-b-create".to_string(), display_name: "Owner B Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository_b.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, owner_b.clone(), ) @@ -660,11 +862,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "remote-create".to_string(), display_name: "Remote Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: "ssh://git@example.test/org/repository.git".to_string(), repository_key: "remote".to_string(), default_ref: Some("main".to_string()), - }, + }), }, owner_account_id, ) @@ -673,7 +875,7 @@ mod tests { let persisted = store .get_repository( &result.workspace.workspace_id, - &result.repository.repository_id, + &result.repository.as_ref().unwrap().repository_id, ) .unwrap() .unwrap(); diff --git a/crates/yoi/README.md b/crates/yoi/README.md index 397c6ee6e..3bb4b210b 100644 --- a/crates/yoi/README.md +++ b/crates/yoi/README.md @@ -24,6 +24,39 @@ Does not own: Keeping product CLI ownership here prevents lower crates from depending on the binary name or parsing top-level arguments. Runtime launch should use typed command boundaries, not shell-command strings. +## Workspace creation and selection + +`yoi init` remains Git-oriented and requires an explicit initial repository key: + +```sh +yoi --backend https://backend.example init --display-name Development --repository-key platform +``` + +For a Workspace with no initial repository, use the same Backend creation API explicitly: + +```sh +yoi --backend https://backend.example workspace create --display-name Research +``` + +Both commands record Workspace-to-Backend routing in the global client configuration, +not repository-local identity files. Repository-free creation prints the Workspace id; +select it explicitly from any directory, without Git discovery: + +```sh +yoi --workspace-id +yoi --workspace-id workers +yoi --workspace-id resume +yoi --workspace-id panel +``` + +`--workspace-id` selects Backend even when `default_connection` is `local`, and conflicts +with `--local`. An explicit `--backend ` overrides configured routing. Without an +explicit Workspace id, Backend commands retain Git-based Workspace discovery. + +Normal Backend startup launches the existing workdir-free Worker form. Repository and +workdir attachment selection are not prerequisites imposed by this CLI; Runtime +availability and workdir requirements remain Backend/TUI responsibilities. + ## See also - [`../../docs/design/overview.md`](../../docs/design/overview.md) diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 618b8cf39..488969646 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -250,10 +250,15 @@ pub(crate) fn resolve_connection_aware_cli_connection), @@ -123,10 +126,7 @@ async fn run(mode: Mode) -> ExitCode { }, Mode::Init(options) => match run_init(options).await { Ok(workspace) => { - println!( - "Initialized Workspace '{}' for repository '{}'", - workspace.workspace.display_name, workspace.repository.repository_key - ); + println!("{}", workspace_creation_message(&workspace)); ExitCode::SUCCESS } Err(error) => { @@ -134,6 +134,20 @@ async fn run(mode: Mode) -> ExitCode { ExitCode::FAILURE } }, + Mode::WorkspaceHelp => { + print!("{WORKSPACE_HELP}"); + ExitCode::SUCCESS + } + Mode::WorkspaceCreate(options) => match run_workspace_create(options).await { + Ok(workspace) => { + println!("{}", workspace_creation_message(&workspace)); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("yoi workspace create: {error}"); + ExitCode::FAILURE + } + }, Mode::WorkdirHelp => { print_workdir_help(); ExitCode::SUCCESS @@ -278,7 +292,7 @@ fn resolve_tui_target( command, true, None, - None, + selection.workspace_id.as_deref(), ); } @@ -521,6 +535,9 @@ fn parse_args_slice_with_connection_resolver( .map_err(|error| ParseError(error.to_string()))?; return Ok(Mode::Ticket { cli, target }); } + "workspace" => { + return parse_workspace_args(&args[1..], &target_selection); + } "init" => { if target_selection.explicit_local { return Err(ParseError( @@ -807,6 +824,68 @@ fn parse_workdir_args( })) } +const WORKSPACE_HELP: &str = "yoi workspace\n\nUsage:\n yoi [--backend ] workspace create --display-name \n\nCreate a Workspace without an initial repository; no Git checkout is needed.\nUse `yoi init` to create a Workspace with the current Git repository.\nSelect the created Workspace with `yoi --workspace-id `; this does not require Git.\n\nOptions:\n --display-name Workspace display name (required)\n -h, --help Print help\n"; + +fn parse_workspace_args(args: &[String], selection: &TargetSelection) -> Result { + let Some((subcommand, args)) = args.split_first() else { + return Ok(Mode::WorkspaceHelp); + }; + if matches!(subcommand.as_str(), "--help" | "-h") { + return Ok(Mode::WorkspaceHelp); + } + if subcommand != "create" { + return Err(ParseError(format!( + "unknown yoi workspace subcommand `{subcommand}`" + ))); + } + if args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + return Ok(Mode::WorkspaceHelp); + } + if selection.explicit_local { + return Err(ParseError( + "yoi workspace create requires a Backend target and cannot use --local".to_string(), + )); + } + if selection.workspace_id.is_some() { + return Err(ParseError( + "yoi workspace create creates a Workspace and does not accept --workspace-id" + .to_string(), + )); + } + let mut display_name = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--display-name" => { + if display_name.is_some() { + return Err(ParseError( + "--display-name may only be provided once".to_string(), + )); + } + display_name = + Some(required_option_value(args, index, "--display-name")?.to_string()); + index += 2; + } + unknown => { + return Err(ParseError(format!( + "unknown yoi workspace create option `{unknown}`" + ))); + } + } + } + let display_name = display_name.ok_or_else(|| { + ParseError("yoi workspace create requires --display-name NAME".to_string()) + })?; + Ok(Mode::WorkspaceCreate(WorkspaceCreateOptions { + backend_url: resolve_backend_url(selection.backend_url.clone(), None)?, + display_name, + repository: None, + })) +} + fn parse_init_args( args: &[String], explicit_backend_url: Option, @@ -1989,6 +2068,7 @@ Usage: yoi [TARGET] workers [-r|--stopped] [--runtime-id ] yoi [TARGET] resume [--all] [--runtime-id ] yoi --backend [--workspace-id ] panel + yoi [--backend ] workspace create --display-name yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] yoi [--backend ] workdir share [--workspace-id ] [--permission ] [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] @@ -1997,9 +2077,11 @@ Usage: Target selection: --local Use the client-owned one-process Standalone host --backend Use a Workspace Backend explicitly - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed If no target is explicit, connection-aware commands use the merged client config. + Explicit --workspace-id selects Backend even when the default connection is local. + Without --workspace-id, Backend selection matches the current Git repository. `default_connection = "local"` selects Standalone; it does not enable a filesystem Ticket, Objective, Worker catalog, PID, socket, or subprocess authority. @@ -2017,6 +2099,7 @@ Console options: --worker-id Backend Worker id; requires --runtime-id Host commands: + yoi workspace create Create a Backend Workspace without an initial repository. yoi init Register the current Git repository as a new Backend Workspace. yoi workdir share Interactively share a client-hosted External Workdir. keys Manage local model/API keys @@ -2058,7 +2141,7 @@ Authority: Options: --backend Use this Workspace Backend - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed --workspace Match this Git repository against Server DB Repository records -r, --stopped List stopped Backend Workers --runtime-id Restrict the Backend Worker picker to a Runtime id @@ -2077,7 +2160,7 @@ Usage: Target options: --local Restore from the client-owned Standalone Worker store --backend Restore a stopped Backend Workspace Worker - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed Options: --workspace Scope Standalone Workers to this cwd identity (defaults to cwd) @@ -2461,7 +2544,8 @@ backend = "shared" ) ); assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins")); - assert!(!TOP_LEVEL_HELP.contains("yoi workspace")); + assert!(TOP_LEVEL_HELP.contains("yoi workspace create")); + assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve")); assert!(!TOP_LEVEL_HELP.contains("yoi server")); assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS")); } @@ -3174,6 +3258,232 @@ backend = "shared" assert!(!error.contains("stale-local-workspace")); } + #[test] + fn workspace_create_parsing_requires_no_repository_inputs() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + let args = [ + "--backend", + "http://backend.example", + "workspace", + "create", + "--display-name", + "Research", + ] + .map(String::from); + let Mode::WorkspaceCreate(options) = + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected workspace creation mode"); + }; + assert_eq!(options.backend_url, "http://backend.example"); + assert_eq!(options.display_name, "Research"); + assert!(options.repository.is_none()); + } + + #[test] + fn workspace_create_rejects_repository_options_and_invalid_creation_selectors() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + for (prefix, suffix, expected) in [ + ( + vec!["--local"], + vec!["--display-name", "Research"], + "cannot use --local", + ), + ( + vec!["--workspace-id", "existing"], + vec!["--display-name", "Research"], + "does not accept --workspace-id", + ), + (vec![], vec![], "requires --display-name NAME"), + ( + vec![], + vec!["--display-name"], + "--display-name requires a value", + ), + ( + vec![], + vec!["--display-name", "A", "--display-name", "B"], + "may only be provided once", + ), + ( + vec![], + vec!["--display-name", "A", "--repository", "/tmp"], + "unknown yoi workspace create option `--repository`", + ), + ( + vec![], + vec!["--display-name", "A", "--repository-key", "main"], + "unknown yoi workspace create option `--repository-key`", + ), + ( + vec![], + vec!["--display-name", "A", "--default-ref", "main"], + "unknown yoi workspace create option `--default-ref`", + ), + ] { + let args = [prefix, vec!["workspace", "create"], suffix] + .concat() + .into_iter() + .map(String::from) + .collect::>(); + let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err(); + assert!(error.to_string().contains(expected), "{args:?}: {error}"); + } + } + + #[test] + fn workspace_help_documents_repo_free_creation_without_resolving_backend() { + for args in [ + vec!["workspace"], + vec!["workspace", "--help"], + vec!["workspace", "create", "--help"], + ] { + assert!(matches!( + parse_args_from(args).unwrap(), + Mode::WorkspaceHelp + )); + } + assert!(TOP_LEVEL_HELP.contains("workspace create --display-name ")); + assert!(WORKSPACE_HELP.contains("without an initial repository")); + assert!(WORKSPACE_HELP.contains("--workspace-id ")); + } + + #[test] + fn explicit_workspace_selects_backend_from_non_git_directory_even_with_local_default() { + let directory = tempfile::tempdir().unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://configured-backend.example", + }; + // This resolver defaults to Standalone and rejects repository-based selection. + for command in [None, Some("workers"), Some("resume"), Some("panel")] { + let mut args = vec!["--workspace-id".to_string(), "repo-free".to_string()]; + if let Some(command) = command { + args.push(command.to_string()); + } + args.extend([ + "--workspace".to_string(), + directory.path().display().to_string(), + ]); + let Mode::Tui { + target, + mode, + workspace_root, + } = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected TUI mode for {args:?}"); + }; + assert_eq!(workspace_root, directory.path()); + assert_eq!( + target.resolve().unwrap(), + client::ResolvedTarget::Backend { + base_url: "http://configured-backend.example".to_string(), + workspace_id: "repo-free".to_string(), + } + ); + match command { + None => assert!(matches!(mode, LaunchMode::BackendSpawn)), + Some("workers") => assert!(matches!( + mode, + LaunchMode::Workers { + intent: BackendWorkerPickerIntent::Attach { .. }, + .. + } + )), + Some("resume") => assert!(matches!( + mode, + LaunchMode::Workers { + intent: BackendWorkerPickerIntent::Resume, + .. + } + )), + Some("panel") => assert!(matches!(mode, LaunchMode::Panel)), + _ => unreachable!(), + } + } + assert!(!directory.path().join(".git").exists()); + assert!(!directory.path().join(".yoi").exists()); + } + + #[test] + fn explicit_workspace_attaches_to_backend_worker_without_git_discovery() { + let directory = tempfile::tempdir().unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://configured-backend.example", + }; + let args = [ + vec![ + "--workspace-id=repo-free", + "--runtime-id", + "runtime-a", + "--worker-id", + "worker-a", + "--workspace", + ] + .into_iter() + .map(String::from) + .collect::>(), + vec![directory.path().display().to_string()], + ] + .concat(); + let Mode::Tui { + target, + mode: + LaunchMode::OpenWorker { + runtime_id, + worker_id, + }, + .. + } = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected Backend Worker attach mode"); + }; + assert_eq!( + target.resolve().unwrap(), + client::ResolvedTarget::Backend { + base_url: "http://configured-backend.example".to_string(), + workspace_id: "repo-free".to_string(), + } + ); + assert_eq!(runtime_id, "runtime-a"); + assert_eq!(worker_id, "worker-a"); + } + + #[test] + fn explicit_workspace_conflicts_with_local_selection() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + let args = ["--local", "--workspace-id", "repo-free"].map(String::from); + let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err(); + assert_eq!( + error.to_string(), + "--local and --workspace-id are mutually exclusive" + ); + } + + #[test] + fn init_rejects_non_git_repository_instead_of_creating_repo_free_workspace() { + let directory = tempfile::tempdir().unwrap(); + let args = vec![ + "--display-name".to_string(), + "Research".to_string(), + "--repository-key".to_string(), + "main".to_string(), + "--repository".to_string(), + directory.path().display().to_string(), + ]; + let error = parse_init_args(&args, Some("http://backend.example".to_string())).unwrap_err(); + assert!( + error + .to_string() + .contains("not inside a readable Git repository") + ); + } + #[test] fn init_parsing_uses_git_root_without_writing_repository_local_identity() { let repository = tempfile::tempdir().unwrap(); @@ -3398,9 +3708,9 @@ backend = "shared" } #[test] - fn parse_workspace_command_is_removed_from_yoi_surface() { + fn workspace_creation_does_not_restore_removed_workspace_server_command() { let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err(); - assert_eq!(err.to_string(), "unknown command `workspace`"); + assert_eq!(err.to_string(), "unknown yoi workspace subcommand `serve`"); } #[test] diff --git a/crates/yoi/src/workspace_bootstrap.rs b/crates/yoi/src/workspace_bootstrap.rs index 6daf87c85..f141e8f00 100644 --- a/crates/yoi/src/workspace_bootstrap.rs +++ b/crates/yoi/src/workspace_bootstrap.rs @@ -19,6 +19,13 @@ pub(crate) struct InitOptions { pub(crate) default_ref: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspaceCreateOptions { + pub(crate) backend_url: String, + pub(crate) display_name: String, + pub(crate) repository: Option, +} + pub(crate) async fn run_init( options: InitOptions, ) -> Result { @@ -26,27 +33,56 @@ pub(crate) async fn run_init( .repository_root .to_str() .ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?; + run_workspace_create(WorkspaceCreateOptions { + backend_url: options.backend_url, + display_name: options.display_name, + repository: Some(CreateBackendWorkspaceRepository { + repository_key: options.repository_key, + uri: repository_uri.to_string(), + default_ref: options.default_ref, + }), + }) + .await +} + +pub(crate) async fn run_workspace_create( + options: WorkspaceCreateOptions, +) -> Result { 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}")))?; + let request = workspace_create_request(options.display_name, options.repository); + let response = create_backend_workspace(&target, &request) + .await + .map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?; record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?; Ok(response) } +fn workspace_create_request( + display_name: String, + repository: Option, +) -> CreateBackendWorkspaceRequest { + CreateBackendWorkspaceRequest { + operation_key: uuid::Uuid::now_v7().to_string(), + display_name, + repository, + } +} + +pub(crate) fn workspace_creation_message(response: &server_api::WorkspaceCreateResponse) -> String { + match &response.repository { + Some(repository) => format!( + "Initialized Workspace '{}' for repository '{}'", + response.workspace.display_name, repository.repository_key + ), + None => format!( + "Created Workspace '{}' (workspace-id: {}) without an initial repository", + response.workspace.display_name, response.workspace.workspace_id + ), + } +} + pub(crate) fn select_backend_workspace_for_repository( base_url: &str, repository_path: &Path, @@ -333,6 +369,85 @@ mod tests { } } + #[test] + fn workspace_creation_wire_request_omits_repository_for_repo_free_creation() { + let request = workspace_create_request("Research".to_string(), None); + let body = serde_json::to_value(request).unwrap(); + assert_eq!(body["display_name"], "Research"); + assert!( + body.get("repository") + .is_none_or(serde_json::Value::is_null) + ); + } + + #[test] + fn git_workspace_creation_wire_request_preserves_initial_repository() { + let request = workspace_create_request( + "Development".to_string(), + Some(CreateBackendWorkspaceRepository { + repository_key: "platform".to_string(), + uri: "/checkout/platform".to_string(), + default_ref: Some("develop".to_string()), + }), + ); + let body = serde_json::to_value(request).unwrap(); + assert_eq!( + body["repository"], + serde_json::json!({ + "repository_key": "platform", + "uri": "/checkout/platform", + "default_ref": "develop", + }) + ); + } + + #[test] + fn creation_response_with_null_repository_reports_workspace_including_on_replay() { + for replayed in [false, true] { + let body = serde_json::json!({ + "workspace": workspace_summary("workspace-a", "Research"), + "repository": null, + "config_revision": 0, + "request_fingerprint": "fingerprint", + "replayed": replayed, + }); + let response = serde_json::from_value(body).unwrap(); + assert_eq!( + workspace_creation_message(&response), + "Created Workspace 'Research' (workspace-id: workspace-a) without an initial repository" + ); + } + } + + #[test] + fn creation_response_with_repository_preserves_git_init_message() { + let body = serde_json::json!({ + "workspace": workspace_summary("workspace-a", "Development"), + "repository": { + "workspace_id": "workspace-a", + "repository_key": "platform", + "kind": "git", + "provider": "builtin:git", + "source": {"kind": "local_path", "uri": "/checkout/platform"}, + "default_ref": "develop", + "source_revision": 1, + "source_fingerprint": "fingerprint", + "observed_status": "unverified", + "observed_at": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }, + "config_revision": 1, + "request_fingerprint": "fingerprint", + "replayed": false, + }); + let response = serde_json::from_value(body).unwrap(); + assert_eq!( + workspace_creation_message(&response), + "Initialized Workspace 'Development' for repository 'platform'" + ); + } + #[test] fn repository_catalog_selection_handles_single_zero_and_multiple_matches() { let repository = tempfile::tempdir().unwrap(); diff --git a/openapi/server-api.json b/openapi/server-api.json index 2634f31f4..c81421bc5 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -13963,7 +13963,7 @@ }, "WorkspaceCreateRequest": { "additionalProperties": false, - "description": "Request for atomically creating a Workspace and its initial Repository.", + "description": "Request for atomically creating a Workspace with an optional initial Repository.", "properties": { "display_name": { "type": "string" @@ -13972,19 +13972,25 @@ "type": "string" }, "repository": { - "$ref": "#/components/schemas/InitialRepositoryIntent" + "anyOf": [ + { + "$ref": "#/components/schemas/InitialRepositoryIntent" + }, + { + "type": "null" + } + ] } }, "required": [ "operation_key", - "display_name", - "repository" + "display_name" ], "type": "object" }, "WorkspaceCreateResponse": { "additionalProperties": false, - "description": "Response returned after atomically creating a Workspace and its first Repository.", + "description": "Response returned after atomically creating a Workspace.\n\nOn replay, Workspace/config and the Repository at the originally requested key are\ncurrent authority, not historical snapshots. Repository is null when creation omitted\nit or that key has since been removed/renamed; replay never recreates an asset.", "properties": { "config_revision": { "format": "uint64", @@ -13996,7 +14002,14 @@ "type": "boolean" }, "repository": { - "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + }, + { + "type": "null" + } + ] }, "request_fingerprint": { "type": "string" @@ -14007,7 +14020,6 @@ }, "required": [ "workspace", - "repository", "config_revision", "request_fingerprint", "replayed" @@ -15538,7 +15550,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8" + "x-yoi-source-digest": "sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index e17a7fb8b..733276e24 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -8,6 +8,7 @@ "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", + "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "build": "deno run -A npm:vite@8.3.2 build", "preview": "deno run -A npm:vite@8.3.2 preview", "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 185528645..92fb5b882 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index aeb99cdaf..955ae4b2d 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -34,7 +34,7 @@ export type WorkspaceRepositoryRecord = { export type WorkspaceCreateResponse = { workspace: WorkspaceSummary; - repository: WorkspaceRepositoryRecord; + repository: WorkspaceRepositoryRecord | null; config_revision: number; request_fingerprint: string; replayed: boolean; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 7f0bbc332..9343d7c60 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index f0474897d..31e0d9ebb 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index c44888c50..0d1461966 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index d545d712c..5190b3dec 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 86e6acc57..9aa45b67d 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8) +// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts new file mode 100644 index 000000000..d2707c2d4 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts @@ -0,0 +1,33 @@ +export const createdWorkspace = { + workspace_id: "home-empty", + owner_account_id: "fixture-owner", + display_name: "New Workspace", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +export const currentInitialRepository = { + workspace_id: createdWorkspace.workspace_id, + repository_key: "platform", + kind: "git", + provider: "git", + source: { kind: "https", uri: "https://example.test/current.git" }, + default_ref: "main", + source_revision: 2, + source_fingerprint: "sha256:current", + observed_status: "unverified", + observed_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +export function creationResponse(repository: unknown = null, replayed = false) { + return { + workspace: createdWorkspace, + repository, + config_revision: 1, + request_fingerprint: "sha256:creation", + replayed, + }; +} diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 587b54839..56afce918 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -17,11 +17,11 @@ export type CreateWorkspaceResponse = WorkspaceCreateResponse; export type CreateWorkspaceRequest = { operation_key: string; display_name: string; - repository: { + repository?: { repository_key: string; uri: string; default_ref: string | null; - }; + } | null; }; export class WorkspaceCatalogError extends Error { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index d16e9bacc..566a7b41d 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -569,7 +569,7 @@ export function parseWorkspaceCreateResponse( response.workspace, "workspace create response.workspace", ), - repository: workspaceRepositoryRecord( + repository: response.repository === null ? null : workspaceRepositoryRecord( response.repository, "workspace create response.repository", ), diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts new file mode 100644 index 000000000..d2e8fa0f6 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts @@ -0,0 +1,39 @@ +import type { WorkerLaunchOptionsResponse } from "./types.ts"; + +export function emptyLaunchOptions( + workspaceId: string, +): WorkerLaunchOptionsResponse { + return { + workspace_id: workspaceId, + runtimes: [ + { + runtime_id: "embedded", + display_name: "Embedded", + status: "active", + worker_creation_available: true, + built_in: true, + working_directory_required: false, + diagnostics: [], + }, + { + runtime_id: "remote", + display_name: "Remote", + status: "active", + worker_creation_available: true, + built_in: false, + working_directory_required: true, + diagnostics: [], + }, + ], + default_profile: "builtin:companion", + profiles: [{ + id: "builtin:companion", + label: "Companion", + description: "Conversation", + feature_connections: { subjektiv: false }, + }], + repositories: [], + working_directories: [], + diagnostics: [], + }; +} diff --git a/web/workspace/src/lib/workspace/styles/workspace-catalog.css b/web/workspace/src/lib/workspace/styles/workspace-catalog.css index 487eb10c5..7fc9c6467 100644 --- a/web/workspace/src/lib/workspace/styles/workspace-catalog.css +++ b/web/workspace/src/lib/workspace/styles/workspace-catalog.css @@ -41,7 +41,7 @@ .workspace-catalog-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 1rem; margin-top: 1rem; } @@ -109,7 +109,7 @@ .workspace-create-panel { padding: 1.5rem; display: grid; - grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr); + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 2rem; } @@ -136,6 +136,32 @@ font: inherit; } + .workspace-create-panel .workspace-repository-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text); + } + + .workspace-repository-fields { + display: grid; + gap: var(--space-4); + min-width: 0; + } + + .workspace-create-panel input:not([type="checkbox"]) { + width: 100%; + } + + .workspace-create-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + } + + .workspace-create-panel label { + min-width: 0; + } + .workspace-create-row > label { flex: 1; } diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 11ddae502..5eb48a545 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -17,6 +17,7 @@ let creating = $state(false); let creationError = $state(null); let displayName = $state(""); + let includeRepository = $state(false); let repositoryKey = $state(""); let repositoryUri = $state(""); let defaultRef = $state(""); @@ -46,23 +47,17 @@ event.preventDefault(); if (creating) return; const normalized = { - displayName: displayName.trim(), - repositoryUri: repositoryUri.trim(), - repositoryKey: repositoryKey.trim(), - defaultRef: defaultRef.trim(), + display_name: displayName.trim(), + repository: includeRepository ? { + repository_key: repositoryKey.trim(), + uri: repositoryUri.trim(), + default_ref: defaultRef.trim() || null, + } : null, }; const signature = JSON.stringify(normalized); const request = lastSubmission?.signature === signature ? lastSubmission.request - : { - operation_key: createOperationKey(), - display_name: normalized.displayName, - repository: { - repository_key: normalized.repositoryKey, - uri: normalized.repositoryUri, - default_ref: normalized.defaultRef || null, - }, - }; + : { operation_key: createOperationKey(), ...normalized }; lastSubmission = { signature, request }; creating = true; creationError = null; @@ -154,29 +149,38 @@

New team space

Create Workspace

-

- Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH. -

+

You can add repositories later in Workspace Settings.

-