From d748274905a73e2e2bd5c5b82274f80ba8c57b1c Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 01:49:25 +0900 Subject: [PATCH] refactor: centralize workspace repository response DTOs --- crates/client/src/backend_workspace.rs | 38 +- crates/workspace-api/Cargo.toml | 4 + .../examples/generate_typescript.rs | 3 + crates/workspace-api/src/lib.rs | 327 ++++++++++++++++++ crates/workspace-server/src/lib.rs | 12 +- crates/workspace-server/src/repositories.rs | 82 +---- crates/workspace-server/src/server.rs | 203 ++++++----- .../workspace-server/src/workspace_catalog.rs | 10 +- 8 files changed, 474 insertions(+), 205 deletions(-) create mode 100644 crates/workspace-api/examples/generate_typescript.rs diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 94ce5afb..a1f093d4 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -2,19 +2,13 @@ use crate::{BackendApiClient, BackendApiClientError}; use reqwest::Method; use serde::{Deserialize, Serialize}; use std::fmt; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; +use workspace_api::{WorkspaceCreateResponse, WorkspaceRepositoryRecord, WorkspaceSummary}; const DEFAULT_WORKSPACE_LIMIT: usize = 200; -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BackendWorkspace { - pub workspace_id: String, - pub owner_account_id: Option, - pub display_name: String, - pub state: String, - pub created_at: String, - pub updated_at: String, -} +pub type BackendWorkspace = WorkspaceSummary; +pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; +pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -32,30 +26,6 @@ pub struct CreateBackendWorkspaceRepository { pub default_ref: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CreateBackendWorkspaceResponse { - pub workspace: BackendWorkspace, - pub repository: CreateBackendWorkspaceRepositoryRecord, - pub config_revision: u64, - pub request_fingerprint: String, - pub replayed: bool, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CreateBackendWorkspaceRepositoryRecord { - pub workspace_id: String, - pub repository_id: String, - pub name: String, - pub kind: String, - pub provider: Option, - pub source: RepositorySource, - pub default_ref: Option, - pub source_revision: u64, - pub source_fingerprint: String, - pub observed_status: RepositoryObservedStatus, - pub observed_at: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendWorkspaceCatalogTarget { pub base_url: String, diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index ab4ef23e..8002805a 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -14,5 +14,9 @@ serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } workdir.workspace = true +[[example]] +name = "generate_typescript" +required-features = ["typescript"] + [dev-dependencies] serde_json.workspace = true diff --git a/crates/workspace-api/examples/generate_typescript.rs b/crates/workspace-api/examples/generate_typescript.rs new file mode 100644 index 00000000..9b4e949d --- /dev/null +++ b/crates/workspace-api/examples/generate_typescript.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::catalog_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 898312e3..fcbb299a 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -12,6 +12,7 @@ use workdir::workspace::WorkingDirectorySummary; /// Local paths remain distinct from network Git transports so callers cannot /// accidentally treat an unmaterialized remote as a server-local filesystem path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositorySourceKind { LocalPath, @@ -55,6 +56,7 @@ impl RepositorySourceKind { /// Stable Repository source identity stored by Workspace authority. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RepositorySource { pub kind: RepositorySourceKind, /// Canonical source representation. This is an absolute local path for @@ -63,6 +65,7 @@ pub struct RepositorySource { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositoryObservedStatus { Unverified, @@ -89,10 +92,216 @@ impl RepositoryObservedStatus { } } +/// Public Workspace catalog item returned by `GET /api/workspaces`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSummary { + pub workspace_id: String, + pub owner_account_id: Option, + pub display_name: String, + pub state: String, + pub created_at: String, + pub updated_at: String, +} + +/// Public Repository record embedded in Workspace creation responses. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRepositoryRecord { + pub workspace_id: String, + pub repository_id: String, + pub name: String, + pub kind: String, + pub provider: Option, + pub source: RepositorySource, + pub default_ref: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + pub observed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Response returned after atomically creating a Workspace and its first Repository. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCreateResponse { + pub workspace: WorkspaceSummary, + pub repository: WorkspaceRepositoryRecord, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub config_revision: u64, + pub request_fingerprint: String, + pub replayed: bool, +} + +/// Browser authentication configuration exposed by the scoped Workspace summary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum WorkspaceAuthConfig { + Passkey { + rp_id: String, + origin: String, + public_base_url: String, + cookie_name: String, + }, +} + +/// Backend-authoritative permissions for the current Workspace actor. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspacePermissionSummary { + pub manage_repositories: bool, + pub manage_secrets: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceExtensionPointState { + pub status: String, + pub note: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceExtensionPoints { + pub store: String, + pub event_stream: WorkspaceExtensionPointState, + pub host_worker_bridge: WorkspaceExtensionPointState, + pub companion_console: WorkspaceExtensionPointState, +} + +/// Scoped Workspace metadata and current-actor permission projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceResponse { + pub workspace_id: String, + pub display_name: String, + pub record_authority: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub schema_version: i64, + pub auth: WorkspaceAuthConfig, + pub permissions: WorkspacePermissionSummary, + pub extension_points: WorkspaceExtensionPoints, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryDiagnostic { + pub severity: String, + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitRemoteSummary { + pub name: String, + pub fetch_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitRepositorySummary { + pub status: String, + pub head: Option, + pub branch: Option, + pub dirty: bool, + pub remotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySummary { + pub id: String, + pub display_name: String, + pub kind: String, + pub provider: String, + pub source: RepositorySource, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub observed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub default_selector: Option, + pub record_authority: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub git: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub diagnostics: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitCommitSummary { + pub hash: String, + pub short_hash: String, + pub summary: String, + pub author_name: String, + pub author_email: String, + pub author_date: String, + pub parents: Vec, + pub refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryDetailResponse { + pub workspace_id: String, + pub item: RepositorySummary, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryLogResponse { + pub workspace_id: String, + pub repository_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub default_selector: Option, + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DiagnosticSeverity { Info, @@ -101,6 +310,7 @@ pub enum DiagnosticSeverity { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct Diagnostic { pub code: String, pub severity: DiagnosticSeverity, @@ -537,10 +747,127 @@ pub struct RepositoryAccessProjection { pub bindings: Vec, } +#[cfg(feature = "typescript")] +pub fn catalog_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + WorkspaceSummary::decl(&config), + WorkspaceRepositoryRecord::decl(&config), + WorkspaceCreateResponse::decl(&config), + WorkspaceAuthConfig::decl(&config), + WorkspacePermissionSummary::decl(&config), + DiagnosticSeverity::decl(&config), + Diagnostic::decl(&config), + WorkspaceExtensionPointState::decl(&config), + WorkspaceExtensionPoints::decl(&config), + WorkspaceResponse::decl(&config), + RepositorySourceKind::decl(&config), + RepositorySource::decl(&config), + RepositoryObservedStatus::decl(&config), + RepositoryDiagnostic::decl(&config), + GitRemoteSummary::decl(&config), + GitRepositorySummary::decl(&config), + RepositorySummary::decl(&config), + GitCommitSummary::decl(&config), + RepositoryListResponse::decl(&config), + RepositoryDetailResponse::decl(&config), + RepositoryLogResponse::decl(&config), + ] + .map(|declaration| format!("export {declaration}")); + + format!( + "// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n", + declarations.join("\n\n") + ) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn workspace_and_repository_response_shapes_round_trip() { + let workspace = serde_json::json!({ + "workspace_id": "workspace-test", + "display_name": "Test", + "record_authority": "workspace-control-plane", + "schema_version": 46, + "auth": {"Passkey": { + "rp_id": "example.test", + "origin": "https://example.test", + "public_base_url": "https://example.test", + "cookie_name": "yoi_session" + }}, + "permissions": { + "manage_repositories": true, + "manage_secrets": true + }, + "extension_points": { + "store": "sqlite", + "event_stream": {"status": "available", "note": "ready", "diagnostics": []}, + "host_worker_bridge": {"status": "available", "note": "ready", "diagnostics": []}, + "companion_console": {"status": "available", "note": "ready", "diagnostics": []} + } + }); + let parsed: WorkspaceResponse = serde_json::from_value(workspace.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), workspace); + + let repositories = serde_json::json!({ + "workspace_id": "workspace-test", + "items": [{ + "id": "main", + "display_name": "main", + "kind": "git", + "provider": "git", + "source": {"kind": "local_path", "uri": "/srv/project"}, + "source_revision": 1, + "source_fingerprint": "sha256:test", + "observed_status": "ready", + "record_authority": "workspace-control-plane" + }], + "source": "workspace-control-plane", + "diagnostics": [] + }); + let parsed: RepositoryListResponse = serde_json::from_value(repositories.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), repositories); + } + + #[test] + fn repository_response_rejects_stale_field_aliases() { + let stale = serde_json::json!({ + "workspace_id": "workspace-test", + "items": [{ + "repository_id": "main", + "display_name": "main", + "kind": "git", + "provider": "git", + "source": {"kind": "local_path", "uri": "/srv/project"}, + "source_revision": 1, + "source_fingerprint": "sha256:test", + "observed_status": "ready", + "record_authority": "workspace-control-plane" + }], + "source": "workspace-control-plane", + "diagnostics": [] + }); + + assert!(serde_json::from_value::(stale).is_err()); + } + + #[cfg(feature = "typescript")] + #[test] + fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { + let output = catalog_typescript(); + assert!(output.contains("export type WorkspaceResponse =")); + assert!(output.contains("permissions: WorkspacePermissionSummary")); + assert!(output.contains("export type RepositoryListResponse =")); + assert!(output.contains("items: Array")); + assert!(output.contains("observed_at?: string | null")); + assert!(!output.contains("repository_id: string, display_name")); + } + #[test] fn worker_resource_key_is_required() { let payload = serde_json::json!({ diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 3c17be14..b6804626 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -42,19 +42,19 @@ pub use authority::{ pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; -pub use repositories::{ - ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, - RepositoryLogRead, RepositoryRegistryReader, RepositorySummary, -}; +pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader}; 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, +pub use workspace_api::{ + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, WorkspaceCreateResponse, }; +pub use workspace_catalog::{ + InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult, +}; use worker_runtime::identity::RuntimeWorkerRef; diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 8e8b5d80..7e7f9160 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -4,8 +4,10 @@ use std::{ process::Command, }; -use serde::{Deserialize, Serialize}; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; +use workspace_api::{ + Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, + RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary, +}; pub type RepositoryId = String; pub type RepositorySelector = String; @@ -24,74 +26,19 @@ pub struct ConfiguredRepository { pub default_selector: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepositorySummary { - pub id: RepositoryId, - pub display_name: String, - pub kind: String, - pub provider: String, - pub source: RepositorySource, - pub source_revision: u64, - pub source_fingerprint: String, - pub observed_status: RepositoryObservedStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub observed_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_selector: Option, - pub record_authority: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRepositorySummary { - pub status: String, - pub head: Option, - pub branch: Option, - pub dirty: bool, - pub remotes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRemoteSummary { - pub name: String, - pub fetch_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepositoryDiagnostic { - pub severity: String, - pub code: String, - pub message: String, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositoryListProjection { pub items: Vec, - pub diagnostics: Vec, + pub diagnostics: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositoryLogRead { pub repository_id: RepositoryId, pub default_selector: Option, pub limit: usize, pub commits: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitCommitSummary { - pub hash: String, - pub short_hash: String, - pub summary: String, - pub author_name: String, - pub author_email: String, - pub author_date: String, - pub parents: Vec, - pub refs: Vec, + pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -131,8 +78,8 @@ impl RepositoryRegistryReader { if self.repositories.is_empty() { return RepositoryListProjection { items: Vec::new(), - diagnostics: vec![RepositoryDiagnostic { - severity: "warning".to_string(), + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Warning, code: "repository_config_empty".to_string(), message: "No repositories are configured for this workspace backend." .to_string(), @@ -177,8 +124,8 @@ impl RepositoryRegistryReader { let commits = match self.git_log(repository, limit) { Ok(commits) => commits, Err(message) => { - diagnostics.push(RepositoryDiagnostic { - severity: "warning".to_string(), + diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Warning, code: "repository_git_log_unavailable".to_string(), message, }); @@ -379,7 +326,7 @@ impl RepositoryRegistryReader { default_selector: repository.default_selector.clone(), record_authority: "workspace-control-plane".to_string(), git, - diagnostics, + diagnostics: (!diagnostics.is_empty()).then_some(diagnostics), } } @@ -675,7 +622,10 @@ mod tests { RepositoryObservedStatus::Unverified ); assert!(summary.git.is_none()); - assert_eq!(summary.diagnostics[0].code, "repository_source_unverified"); + assert_eq!( + summary.diagnostics.as_ref().unwrap()[0].code, + "repository_source_unverified" + ); let repository = reader.merge_repository("remote").unwrap(); let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 615073d0..a2ae339a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -64,9 +64,12 @@ use workspace_api::{ DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, + RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource, WorkspaceWorkerDiscoveryItem, + TICKET_RELATIONS_QUERY_PATH, WorkspaceCreateResponse, WorkspaceExtensionPointState, + WorkspaceExtensionPoints, WorkspacePermissionSummary, WorkspaceRepositoryRecord, + WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; @@ -115,7 +118,7 @@ use crate::records::{ }; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, - RepositoryRegistryReader, RepositorySummary, + RepositoryRegistryReader, }; use crate::repository_access::{ RepositoryAccessConfigSchemaProvider, RepositorySecretService, @@ -150,17 +153,7 @@ use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum AuthConfig { - /// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions; - /// CLI/TUI auth uses API tokens obtained through the device login flow. - Passkey { - rp_id: String, - origin: String, - public_base_url: String, - cookie_name: String, - }, -} +pub use workspace_api::WorkspaceAuthConfig as AuthConfig; #[derive(Clone)] pub struct ServerConfig { @@ -987,7 +980,7 @@ async fn list_server_workspaces( let owner = match resolve_server_actor(&api, &headers).await { Ok(Some(actor)) => Some(actor.account_id), Ok(None) => match api.catalog.is_empty() { - Ok(true) => return Json(Vec::::new()).into_response(), + Ok(true) => return Json(Vec::::new()).into_response(), Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), Err(error) => return server_error_response(error), }, @@ -997,7 +990,13 @@ async fn list_server_workspaces( .catalog .list(owner.as_deref(), query.limit.unwrap_or(100)) { - Ok(workspaces) => Json(workspaces).into_response(), + Ok(workspaces) => Json( + workspaces + .into_iter() + .map(workspace_summary) + .collect::>(), + ) + .into_response(), Err(error) => server_error_response(error), } } @@ -1029,7 +1028,48 @@ async fn create_server_workspace( } else { StatusCode::CREATED }; - (status, Json(created)).into_response() + (status, Json(workspace_create_response(created))).into_response() +} + +fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary { + WorkspaceSummary { + workspace_id: record.workspace_id, + owner_account_id: record.owner_account_id, + display_name: record.display_name, + state: record.state, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryRecord { + WorkspaceRepositoryRecord { + workspace_id: record.workspace_id, + repository_id: record.repository_id, + name: record.name, + kind: record.kind, + provider: record.provider, + source: record.source, + default_ref: record.default_ref, + source_revision: record.source_revision, + source_fingerprint: record.source_fingerprint, + observed_status: record.observed_status, + observed_at: record.observed_at, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn workspace_create_response( + created: crate::workspace_catalog::WorkspaceCreateResult, +) -> WorkspaceCreateResponse { + WorkspaceCreateResponse { + workspace: workspace_summary(created.workspace), + repository: workspace_repository_record(created.repository), + config_revision: created.config_revision, + request_fingerprint: created.request_fingerprint, + replayed: created.replayed, + } } async fn resolve_server_actor( @@ -2734,31 +2774,6 @@ pub async fn serve( Ok(()) } -#[derive(Debug, Serialize, Deserialize)] -pub struct WorkspaceResponse { - pub workspace_id: String, - pub display_name: String, - pub record_authority: String, - pub schema_version: i64, - pub auth: AuthConfig, - pub extension_points: ExtensionPoints, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ExtensionPoints { - pub store: String, - pub event_stream: ExtensionPointState, - pub host_worker_bridge: ExtensionPointState, - pub companion_console: ExtensionPointState, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ExtensionPointState { - pub status: String, - pub note: String, - pub diagnostics: Vec, -} - #[derive(Debug, Serialize, Deserialize)] pub struct ListResponse { pub workspace_id: String, @@ -3062,32 +3077,6 @@ pub struct BrowserCreateWorkerResponse { pub diagnostics: Vec, } -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryListResponse { - pub workspace_id: String, - pub items: Vec, - pub source: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryDetailResponse { - pub workspace_id: String, - pub item: RepositorySummary, - pub source: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryLogResponse { - pub workspace_id: String, - pub repository_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_selector: Option, - pub limit: usize, - pub items: Vec, - pub diagnostics: Vec, -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, @@ -3352,11 +3341,12 @@ async fn scoped_get_flow( } async fn scoped_get_workspace( + headers: HeaderMap, State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - get_workspace(State(api)).await + get_workspace(headers, State(api)).await } async fn scoped_get_workspace_settings( @@ -11051,9 +11041,20 @@ async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult) -> ApiResult> { +async fn get_workspace( + headers: HeaderMap, + State(api): State, +) -> ApiResult> { + let cookie_name = auth_public_config(&api.config).cookie_name; + let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?; let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; + let is_owner = actor.as_ref().is_some_and(|actor| { + stored + .as_ref() + .and_then(|workspace| workspace.owner_account_id.as_ref()) + == Some(&actor.account_id) + }); let display_name = stored .as_ref() .map(|record| record.display_name.clone()) @@ -11066,14 +11067,18 @@ async fn get_workspace(State(api): State) -> ApiResult) -> ApiResult ExtensionPointState { +fn companion_console_extension_point( + status: &CompanionStatusResponse, +) -> WorkspaceExtensionPointState { let completion = status.transport.completion.clone(); let note = match completion.as_str() { "connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(), @@ -11107,10 +11114,10 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens "Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state." ), }; - ExtensionPointState { + WorkspaceExtensionPointState { status: completion, note, - diagnostics: status.diagnostics.clone(), + diagnostics: status.diagnostics.iter().cloned().map(Into::into).collect(), } } @@ -11193,7 +11200,7 @@ async fn list_repositories( workspace_id: api.config.workspace_id, items, source: "workspace-control-plane".to_string(), - diagnostics: repository_diagnostics(diagnostics), + diagnostics, })) } @@ -11230,7 +11237,7 @@ async fn repository_log( default_selector, limit, items: commits, - diagnostics: repository_diagnostics(diagnostics), + diagnostics, })) } @@ -14937,23 +14944,6 @@ fn sanitize_backend_error(message: &str) -> String { message.to_string() } -fn repository_diagnostics( - diagnostics: Vec, -) -> Vec { - diagnostics - .into_iter() - .map(|diagnostic| RuntimeDiagnostic { - code: diagnostic.code, - severity: match diagnostic.severity.as_str() { - "error" => DiagnosticSeverity::Error, - "warning" => DiagnosticSeverity::Warning, - _ => DiagnosticSeverity::Info, - }, - message: diagnostic.message, - }) - .collect() -} - fn repository_lookup(result: std::result::Result) -> ApiResult { result.map_err(|error| match error { RepositoryLookupError::UnknownRepository { id } => { @@ -17761,6 +17751,12 @@ mod tests { .await .unwrap(); assert_eq!(authenticated_legacy.status(), StatusCode::OK); + let workspace_body = to_bytes(authenticated_legacy.into_body(), usize::MAX) + .await + .unwrap(); + let typed_workspace: workspace_api::WorkspaceResponse = + serde_json::from_slice(&workspace_body).unwrap(); + assert!(typed_workspace.permissions.manage_repositories); let anonymous_catalog = app .clone() @@ -17785,6 +17781,15 @@ mod tests { .await .unwrap(); assert_eq!(authenticated_catalog.status(), StatusCode::OK); + let catalog_body = to_bytes(authenticated_catalog.into_body(), usize::MAX) + .await + .unwrap(); + let typed_catalog: Vec = + serde_json::from_slice(&catalog_body).unwrap(); + assert_eq!( + typed_catalog[0].workspace_id, + workspace.workspace.workspace_id + ); for path in ["/api/workspaces", "/api/auth/device-login/approve"] { let cross_site = app @@ -23865,6 +23870,9 @@ mod tests { let workspace = get_json(app.clone(), "/api/workspace").await; assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID); assert_eq!(workspace["display_name"], "Test Workspace"); + let typed_workspace: workspace_api::WorkspaceResponse = + serde_json::from_value(workspace.clone()).unwrap(); + assert!(!typed_workspace.permissions.manage_repositories); assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!( workspace["extension_points"]["host_worker_bridge"]["status"], @@ -24051,6 +24059,9 @@ mod tests { ); let repositories = get_json(app.clone(), "/api/repositories").await; + let typed_repositories: workspace_api::RepositoryListResponse = + serde_json::from_value(repositories.clone()).unwrap(); + assert_eq!(typed_repositories.items[0].id, TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!( @@ -24064,6 +24075,8 @@ mod tests { ); let repository_detail = get_json(app.clone(), "/api/repositories/main").await; + let _: workspace_api::RepositoryDetailResponse = + serde_json::from_value(repository_detail.clone()).unwrap(); assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); let scoped_repository_detail = get_json( app.clone(), @@ -24073,6 +24086,8 @@ mod tests { assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID); let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; + let _: workspace_api::RepositoryLogResponse = + serde_json::from_value(repository_log.clone()).unwrap(); assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index ed70673a..5fb5f313 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -35,8 +35,8 @@ pub struct WorkspaceCreateRequest { pub repository: InitialRepositoryIntent, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkspaceCreateResponse { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceCreateResult { pub workspace: WorkspaceRecord, pub repository: RepositoryRecord, pub config_revision: u64, @@ -81,7 +81,7 @@ impl WorkspaceCatalogService { &self, request: WorkspaceCreateRequest, owner_account_id: String, - ) -> Result { + ) -> Result { self.create_internal(request, owner_account_id, None) } @@ -90,7 +90,7 @@ impl WorkspaceCatalogService { request: WorkspaceCreateRequest, owner_account_id: String, requested_workspace_id: Option, - ) -> Result { + ) -> Result { let operation_key = normalize_required( "operation_key", request.operation_key, @@ -165,7 +165,7 @@ impl WorkspaceCatalogService { updated_at: now, }, })?; - Ok(WorkspaceCreateResponse { + Ok(WorkspaceCreateResult { workspace: result.workspace, repository: result.repository, config_revision: result.config_revision,