refactor: centralize workspace repository response DTOs

This commit is contained in:
2026-09-01 01:49:25 +09:00
parent 9756174676
commit d748274905
8 changed files with 474 additions and 205 deletions
+6 -6
View File
@@ -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;
+16 -66
View File
@@ -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<RepositorySelector>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>,
pub record_authority: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<GitRepositorySummary>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRepositorySummary {
pub status: String,
pub head: Option<String>,
pub branch: Option<String>,
pub dirty: bool,
pub remotes: Vec<GitRemoteSummary>,
}
#[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<RepositorySummary>,
pub diagnostics: Vec<RepositoryDiagnostic>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryLogRead {
pub repository_id: RepositoryId,
pub default_selector: Option<RepositorySelector>,
pub limit: usize,
pub commits: Vec<GitCommitSummary>,
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[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<String>,
pub refs: Vec<String>,
pub diagnostics: Vec<Diagnostic>,
}
#[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();
+109 -94
View File
@@ -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::<WorkspaceRecord>::new()).into_response(),
Ok(true) => return Json(Vec::<WorkspaceSummary>::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::<Vec<_>>(),
)
.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<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListResponse<T> {
pub workspace_id: String,
@@ -3062,32 +3077,6 @@ pub struct BrowserCreateWorkerResponse {
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryListResponse {
pub workspace_id: String,
pub items: Vec<RepositorySummary>,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[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<String>,
pub limit: usize,
pub items: Vec<crate::repositories::GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct LogQuery {
limit: Option<usize>,
@@ -3352,11 +3341,12 @@ async fn scoped_get_flow(
}
async fn scoped_get_workspace(
headers: HeaderMap,
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<WorkspaceResponse>> {
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<Re
})
}
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
async fn get_workspace(
headers: HeaderMap,
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<WorkspaceResponse>> {
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<WorkspaceApi>) -> ApiResult<Json<Worksp
record_authority: "local_yoi_project_records".to_string(),
schema_version,
auth: api.config.auth.clone(),
extension_points: ExtensionPoints {
permissions: WorkspacePermissionSummary {
manage_repositories: is_owner,
manage_secrets: is_owner,
},
extension_points: WorkspaceExtensionPoints {
store: "sqlite".to_string(),
event_stream: ExtensionPointState {
event_stream: WorkspaceExtensionPointState {
status: "backend_proxy".to_string(),
note: "Worker observation streams are exposed only through the Workspace server proxy keyed by runtime_id + worker_id; browser clients never receive raw Runtime endpoints or socket paths.".to_string(),
diagnostics: Vec::new(),
},
host_worker_bridge: ExtensionPointState {
host_worker_bridge: WorkspaceExtensionPointState {
status: "runtime_registry".to_string(),
note: "Hosts and Workers are projected from the Workspace RuntimeRegistry; raw Runtime endpoints, sockets, and local metadata paths are not exposed.".to_string(),
diagnostics: Vec::new(),
@@ -11083,7 +11088,9 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
}))
}
fn companion_console_extension_point(status: &CompanionStatusResponse) -> 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<crate::repositories::RepositoryDiagnostic>,
) -> Vec<RuntimeDiagnostic> {
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<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
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<workspace_api::WorkspaceSummary> =
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);
@@ -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<WorkspaceCreateResponse> {
) -> Result<WorkspaceCreateResult> {
self.create_internal(request, owner_account_id, None)
}
@@ -90,7 +90,7 @@ impl WorkspaceCatalogService {
request: WorkspaceCreateRequest,
owner_account_id: String,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
) -> Result<WorkspaceCreateResult> {
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,