From 996b7f246824e2922acf04f1e6b7875e2beabfa8 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 01:42:05 +0900 Subject: [PATCH] refactor: centralize workdir REST DTOs --- Cargo.lock | 2 - crates/client/Cargo.toml | 1 - crates/client/src/backend_runtime.rs | 10 +- crates/workdir/src/workspace.rs | 118 --------- .../src/feature/builtin/manage_workdir.rs | 11 +- crates/workspace-api/Cargo.toml | 1 - .../examples/generate_workdir_api_types.rs | 3 + crates/workspace-api/src/lib.rs | 233 +++++++++++++++++- crates/workspace-server/src/hosts.rs | 2 +- crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/server.rs | 144 ++++++++--- crates/workspace-server/src/workdir_api.rs | 91 +++++++ .../src/lib/generated/workdir-api.ts | 24 ++ 13 files changed, 473 insertions(+), 168 deletions(-) create mode 100644 crates/workspace-api/examples/generate_workdir_api_types.rs create mode 100644 crates/workspace-server/src/workdir_api.rs create mode 100644 web/workspace/src/lib/generated/workdir-api.ts diff --git a/Cargo.lock b/Cargo.lock index 28870bb0..8b0007c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,7 +649,6 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "uuid", - "workdir", "workspace-api", ] @@ -6684,7 +6683,6 @@ dependencies = [ "serde", "serde_json", "ts-rs", - "workdir", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index a17997e7..11b020c3 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -17,7 +17,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync" tokio-tungstenite = { workspace = true } uuid = { workspace = true } workspace-api.workspace = true -workdir = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 8cb67625..064211d6 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -11,7 +11,6 @@ use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary; pub use workspace_api::{ Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, @@ -20,6 +19,11 @@ pub use workspace_api::{ WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, + WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse, + WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse, + WorkingDirectorySummary as BackendWorkingDirectorySummary, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -675,8 +679,8 @@ mod tests { .unwrap() .occupied_by .expect("occupied Workdir"); - assert_eq!(occupied_by.worker.runtime_id, "arcadia"); - assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); + assert_eq!(occupied_by.runtime_id, "arcadia"); + assert_eq!(occupied_by.worker_id, "worker-opaque-64"); let mut stale = payload; stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index e76fdf76..616eb0a9 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -200,39 +200,6 @@ impl WorkingDirectorySummary { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkingDirectoryDiagnosticSeverity { - Info, - Warning, - Error, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkingDirectoryDiagnostic { - pub code: String, - pub severity: WorkingDirectoryDiagnosticSeverity, - pub message: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkingDirectoryListResponse { - pub workspace_id: String, - pub items: Vec, - pub diagnostics: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkingDirectoryDetailResponse { - pub workspace_id: String, - pub runtime_id: String, - pub item: WorkingDirectorySummary, - pub diagnostics: Vec, -} - #[cfg(test)] mod tests { use super::*; @@ -253,91 +220,6 @@ mod tests { assert_eq!(serde_json::to_value(status).unwrap(), expected); } } - - #[test] - fn occupied_and_free_list_response_round_trips() { - let response = WorkingDirectoryListResponse { - workspace_id: "workspace".to_string(), - items: vec![ - WorkingDirectorySummary { - working_directory_id: "occupied".to_string(), - repository_id: "repo".to_string(), - creation_selector: Some("develop".to_string()), - creation_ref: Some("abc123".to_string()), - creation_tree: Some("tree123".to_string()), - current_selector: Some("work/ticket".to_string()), - current_ref: Some("def456".to_string()), - current_tree: Some("tree456".to_string()), - observed_at_epoch_seconds: Some(1_777_777_777), - materializer_kind: MaterializerKind::LocalGitWorktree, - cleanup_target: Some(WorkingDirectoryCleanupTarget { - kind: "git_worktree".to_string(), - working_directory_id: "occupied".to_string(), - repository_id: "repo".to_string(), - }), - status: WorkingDirectoryStatusKind::Active, - cleanliness: Some("clean".to_string()), - primary_worker_id: None, - occupied_by: Some(WorkingDirectoryOccupancy { - worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"), - display_name: "Coder".to_string(), - linked_at: "2026-08-12T00:00:00Z".to_string(), - }), - }, - WorkingDirectorySummary { - working_directory_id: "free".to_string(), - repository_id: "repo".to_string(), - creation_selector: None, - creation_ref: None, - creation_tree: None, - current_selector: None, - current_ref: Some("987fed".to_string()), - current_tree: None, - observed_at_epoch_seconds: None, - materializer_kind: MaterializerKind::LocalGitWorktree, - cleanup_target: None, - status: WorkingDirectoryStatusKind::Active, - cleanliness: Some("unknown".to_string()), - primary_worker_id: None, - occupied_by: None, - }, - ], - diagnostics: vec![WorkingDirectoryDiagnostic { - code: "observed".to_string(), - severity: WorkingDirectoryDiagnosticSeverity::Info, - message: "inventory observed".to_string(), - }], - }; - - let encoded = serde_json::to_value(&response).unwrap(); - assert_eq!( - encoded["items"][0]["occupied_by"]["worker_id"], - "worker-opaque-64" - ); - assert!( - encoded["items"][0]["occupied_by"] - .get("runtime_worker_id") - .is_none() - ); - assert!(encoded["items"][1].get("occupied_by").is_none()); - - let mut stale = encoded.clone(); - stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); - assert!(serde_json::from_value::(stale).is_err()); - - let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, response); - - let detail = WorkingDirectoryDetailResponse { - workspace_id: decoded.workspace_id.clone(), - runtime_id: "arcadia".to_string(), - item: decoded.items[0].clone(), - diagnostics: decoded.diagnostics.clone(), - }; - let encoded = serde_json::to_value(&detail).unwrap(); - let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, detail); - } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 46397ea0..69f22d9b 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -12,11 +12,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::json; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; -use workdir::workspace::{ - WorkingDirectoryDetailResponse as WorkdirDetailResponse, - WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence, - WorkspaceWorkdirSessionOperationRequest, -}; +use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest}; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, @@ -24,6 +20,11 @@ use workdir::{ WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, }; +use workspace_api::{ + WorkingDirectoryDetailResponse as WorkdirDetailResponse, + WorkingDirectoryListResponse as WorkdirListResponse, +}; + use crate::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, ToolDeclaration, diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index ab4ef23e..8af554f8 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -12,7 +12,6 @@ typescript = ["dep:ts-rs"] [dependencies] serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } -workdir.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/crates/workspace-api/examples/generate_workdir_api_types.rs b/crates/workspace-api/examples/generate_workdir_api_types.rs new file mode 100644 index 00000000..53371add --- /dev/null +++ b/crates/workspace-api/examples/generate_workdir_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::workdir_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 898312e3..669bd5a5 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -5,7 +5,6 @@ //! callers must explicitly construct these Workspace-authoritative resources. use serde::{Deserialize, Serialize}; -use workdir::workspace::WorkingDirectorySummary; /// Provider-neutral classification of an authoritative Repository source. /// @@ -93,6 +92,7 @@ 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,12 +101,140 @@ pub enum DiagnosticSeverity { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct Diagnostic { pub code: String, pub severity: DiagnosticSeverity, pub message: String, } +/// Public Workdir materializer classification. +/// +/// The value identifies stable materialization provenance without exposing a +/// provider path, Runtime handle, or session identity. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryMaterializerKind { + RuntimeGitCache, + LocalGitWorktree, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryStatusKind { + Active, + CleanupPending, + Corrupted, + NotFound, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCleanupTarget { + pub kind: String, + pub working_directory_id: String, + pub repository_id: String, +} + +/// Durable Workspace occupancy projection for one Workdir. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryOccupancy { + pub runtime_id: String, + pub worker_id: String, + pub display_name: String, + pub linked_at: String, +} + +/// Public, provider-neutral Workdir inventory projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectorySummary { + pub working_directory_id: String, + pub repository_id: String, + #[serde(default)] + pub creation_selector: Option, + #[serde(default)] + pub creation_ref: Option, + #[serde(default)] + pub creation_tree: Option, + #[serde(default)] + pub current_selector: Option, + #[serde(default)] + pub current_ref: Option, + #[serde(default)] + pub current_tree: Option, + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + pub observed_at_epoch_seconds: Option, + pub materializer_kind: WorkingDirectoryMaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default)] + pub cleanliness: Option, + #[serde(default)] + pub primary_worker_id: Option, + #[serde(default)] + pub occupied_by: Option, +} + +/// Browser/Rust-client Workdir materialization request. +/// +/// `runtime_id = None` requests Workspace default Runtime resolution and +/// `operation_id = Some(_)` fences exact replay. All four fields deliberately +/// preserve the Server's existing optionality. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCreateRequest { + #[serde(default)] + pub runtime_id: Option, + pub repository_id: String, + #[serde(default)] + pub selector: Option, + #[serde(default)] + pub operation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryDetailResponse { + pub workspace_id: String, + pub runtime_id: String, + pub item: WorkingDirectorySummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCreateResponse { + pub workspace_id: String, + pub runtime_id: String, + pub item: WorkingDirectorySummary, + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ListResponse { pub workspace_id: String, @@ -537,6 +665,61 @@ pub struct RepositoryAccessProjection { pub bindings: Vec, } +#[cfg(feature = "typescript")] +pub fn workdir_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + DiagnosticSeverity::decl(&config), + Diagnostic::decl(&config), + WorkingDirectoryMaterializerKind::decl(&config), + WorkingDirectoryStatusKind::decl(&config), + WorkingDirectoryCleanupTarget::decl(&config), + WorkingDirectoryOccupancy::decl(&config), + WorkingDirectorySummary::decl(&config), + WorkingDirectoryCreateRequest::decl(&config), + WorkingDirectoryListResponse::decl(&config), + WorkingDirectoryDetailResponse::decl(&config), + WorkingDirectoryCreateResponse::decl(&config), + ]; + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_workdir_api_contract_is_current() { + let expected = super::workdir_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/workdir-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Workdir API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file", + ); + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + '\r' | '\n' | ' ' | '\t' => None, + _ => Some(character), + }) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; @@ -560,4 +743,52 @@ mod tests { assert!(serde_json::from_value::(payload).is_err()); } + + #[test] + fn workdir_create_request_preserves_optional_operation_fields() { + let payload = serde_json::json!({"repository_id": "main"}); + let request = serde_json::from_value::(payload) + .expect("optional create fields may be absent"); + + assert_eq!(request.runtime_id, None); + assert_eq!(request.selector, None); + assert_eq!(request.operation_id, None); + } + + #[test] + fn workdir_create_request_rejects_stale_or_incomplete_json() { + let stale = serde_json::json!({ + "repository_id": "main", + "selector": "develop", + "path": "/tmp/workdir" + }); + assert!(serde_json::from_value::(stale).is_err()); + + let incomplete = serde_json::json!({ + "runtime_id": "arcadia", + "operation_id": "operation-1" + }); + assert!(serde_json::from_value::(incomplete).is_err()); + } + + #[test] + fn workdir_response_rejects_stale_occupancy_shape() { + let stale = serde_json::json!({ + "workspace_id": "workspace-test", + "items": [{ + "working_directory_id": "workdir-1", + "repository_id": "main", + "materializer_kind": "runtime_git_cache", + "status": "active", + "occupied_by": { + "runtime_worker_id": "worker-1", + "display_name": "Coder", + "linked_at": "2026-01-01T00:00:00Z" + } + }], + "diagnostics": [] + }); + + assert!(serde_json::from_value::(stale).is_err()); + } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 3e06d842..70ea6e7d 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -341,7 +341,7 @@ pub(crate) fn workspace_worker_summary( can_stop: summary.capabilities.can_stop, can_spawn_followup: summary.capabilities.can_spawn_followup, }, - working_directory: summary.working_directory, + working_directory: summary.working_directory.map(crate::workdir_api::summary), diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(), } } diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 3c17be14..f6e1a4a7 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -29,6 +29,7 @@ pub mod runtime_subscription; pub mod server; pub mod skills; pub mod store; +mod workdir_api; pub mod workdir_create_operations; pub mod worker_source; pub mod workspace_catalog; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 615073d0..8adb5765 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -48,10 +48,7 @@ use workdir::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, }; use workdir::workspace::{ - MaterializerKind, WorkingDirectoryCleanupTarget, - WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, - WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity, - WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy, + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest, }; @@ -66,8 +63,12 @@ use workspace_api::{ ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource, WorkspaceWorkerDiscoveryItem, - WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, + TICKET_RELATIONS_QUERY_PATH, + WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, + WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkspaceRuntimeResource, + WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; use crate::auth::{ @@ -2994,18 +2995,6 @@ pub struct WorkingDirectoryRepositoryOption { pub default_selector: Option, } -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BrowserWorkingDirectoryCreateRequest { - #[serde(default)] - pub runtime_id: Option, - pub repository_id: String, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub operation_id: Option, -} - #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserWorkerWorkingDirectorySelection { @@ -8827,15 +8816,15 @@ async fn scoped_get_worker_launch_options( fn working_directory_diagnostics( diagnostics: Vec, -) -> Vec { +) -> Vec { diagnostics .into_iter() - .map(|diagnostic| WorkingDirectoryDiagnostic { + .map(|diagnostic| workspace_api::Diagnostic { code: diagnostic.code, severity: match diagnostic.severity { - DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info, - DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning, - DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error, + DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info, + DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning, + DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error, }, message: diagnostic.message, }) @@ -8850,7 +8839,7 @@ async fn scoped_list_runtime_working_directories( let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?; Ok(Json(BrowserWorkingDirectoryListResponse { workspace_id: api.config.workspace_id.clone(), - items, + items: items.into_iter().map(crate::workdir_api::summary).collect(), diagnostics: working_directory_diagnostics(diagnostics), })) } @@ -8859,7 +8848,7 @@ async fn scoped_create_runtime_working_directory( State(api): State, AxumPath(path): AxumPath, Json(request): Json, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { create_workspace_working_directory( &api, &path.workspace_id, @@ -8893,7 +8882,7 @@ async fn scoped_list_working_directories( let items = working_directory_summaries(&api)?; Ok(Json(BrowserWorkingDirectoryListResponse { workspace_id: api.config.workspace_id.clone(), - items, + items: items.into_iter().map(crate::workdir_api::summary).collect(), diagnostics: Vec::new(), })) } @@ -8902,7 +8891,7 @@ async fn scoped_create_working_directory( State(api): State, AxumPath(path): AxumPath, Json(request): Json, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { create_workspace_working_directory(&api, &path.workspace_id, None, request).await } @@ -8995,7 +8984,7 @@ async fn create_workspace_working_directory( workspace_id: &str, route_runtime_id: Option<&str>, request: BrowserWorkingDirectoryCreateRequest, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { validate_workspace_scope(api, workspace_id)?; if let (Some(route_runtime_id), Some(request_runtime_id)) = (route_runtime_id, request.runtime_id.as_deref()) @@ -9158,7 +9147,17 @@ async fn create_workspace_working_directory( &reserved.resolved_runtime_id, &reserved.working_directory_id, ) - .map(|response| (StatusCode::OK, response)); + .map(|Json(response)| { + ( + StatusCode::OK, + Json(BrowserWorkingDirectoryCreateResponse { + workspace_id: response.workspace_id, + runtime_id: response.runtime_id, + item: response.item, + diagnostics: response.diagnostics, + }), + ) + }); } let runtime = match api @@ -9337,10 +9336,10 @@ async fn create_workspace_working_directory( apply_workdir_occupancy_projection(api, &mut summary)?; Ok(( StatusCode::CREATED, - Json(BrowserWorkingDirectoryDetailResponse { + Json(BrowserWorkingDirectoryCreateResponse { workspace_id: workspace_id.to_string(), runtime_id: reserved.resolved_runtime_id, - item: summary, + item: crate::workdir_api::summary(summary), diagnostics: working_directory_diagnostics(result.diagnostics), }), )) @@ -9363,7 +9362,7 @@ fn working_directory_detail_for_runtime( return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), - item: summary, + item: crate::workdir_api::summary(summary), diagnostics: working_directory_diagnostics(result.diagnostics), })); } @@ -9374,7 +9373,9 @@ fn working_directory_detail_for_runtime( return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), - item: projected_workdir_summary_from_record(&api, &record)?, + item: crate::workdir_api::summary(projected_workdir_summary_from_record( + &api, &record, + )?), diagnostics: working_directory_diagnostics(result.diagnostics), })); } @@ -9433,7 +9434,7 @@ fn cleanup_working_directory_for_runtime( Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), - item: summary, + item: crate::workdir_api::summary(summary), diagnostics: working_directory_diagnostics(result.diagnostics), })) } @@ -22987,6 +22988,77 @@ mod tests { ); } + #[tokio::test] + async fn browser_workspace_workdir_create_rejects_stale_json_before_side_effects() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "stale-workdir-create-json"); + + let response = request_json_authenticated( + build_router(api.clone()), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), + Some(serde_json::json!({ + "runtime_id": "missing-runtime", + "repository_id": TEST_REPOSITORY_ID, + "selector": "HEAD", + "operation_id": "stale-workdir-create", + "path": "/tmp/legacy-workdir", + })), + &token, + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("unknown field") + ); + assert!( + api.config_store + .load_workdir_create_operation(TEST_WORKSPACE_ID, "stale-workdir-create") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn browser_workspace_workdir_create_rejects_unconfigured_repository() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "missing-workdir-repository"); + + let response = request_json_authenticated( + build_router(api.clone()), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), + Some(serde_json::json!({ + "runtime_id": EMBEDDED_WORKER_RUNTIME_ID, + "repository_id": "foreign-or-missing-repository", + "selector": "HEAD", + "operation_id": "missing-workdir-repository", + })), + &token, + StatusCode::NOT_FOUND, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("unknown local repository") + ); + assert!( + api.config_store + .load_workdir_create_operation(TEST_WORKSPACE_ID, "missing-workdir-repository") + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() { let dir = tempfile::tempdir().unwrap(); @@ -25405,7 +25477,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); fn workspace_workdir_response_serializes_shared_occupied_contract() { let response = BrowserWorkingDirectoryListResponse { workspace_id: TEST_WORKSPACE_ID.to_string(), - items: vec![WorkingDirectorySummary { + items: vec![crate::workdir_api::summary(WorkingDirectorySummary { working_directory_id: "wd-1".to_string(), repository_id: "main".to_string(), creation_selector: None, @@ -25425,7 +25497,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); display_name: "Coder".to_string(), linked_at: "2026-08-12T00:00:00Z".to_string(), }), - }], + })], diagnostics: vec![], }; diff --git a/crates/workspace-server/src/workdir_api.rs b/crates/workspace-server/src/workdir_api.rs new file mode 100644 index 00000000..c1a8eb62 --- /dev/null +++ b/crates/workspace-server/src/workdir_api.rs @@ -0,0 +1,91 @@ +use workdir::workspace::{MaterializerKind, WorkingDirectoryStatusKind}; +use workspace_api::{ + WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind, WorkingDirectoryOccupancy, + WorkingDirectoryStatusKind as ApiWorkingDirectoryStatusKind, WorkingDirectorySummary, +}; + +pub(crate) fn summary( + source: workdir::workspace::WorkingDirectorySummary, +) -> WorkingDirectorySummary { + WorkingDirectorySummary { + working_directory_id: source.working_directory_id, + repository_id: source.repository_id, + creation_selector: source.creation_selector, + creation_ref: source.creation_ref, + creation_tree: source.creation_tree, + current_selector: source.current_selector, + current_ref: source.current_ref, + current_tree: source.current_tree, + observed_at_epoch_seconds: source.observed_at_epoch_seconds, + materializer_kind: match source.materializer_kind { + MaterializerKind::RuntimeGitCache => WorkingDirectoryMaterializerKind::RuntimeGitCache, + MaterializerKind::LocalGitWorktree => { + WorkingDirectoryMaterializerKind::LocalGitWorktree + } + }, + cleanup_target: source + .cleanup_target + .map(|target| WorkingDirectoryCleanupTarget { + kind: target.kind, + working_directory_id: target.working_directory_id, + repository_id: target.repository_id, + }), + status: match source.status { + WorkingDirectoryStatusKind::Active => ApiWorkingDirectoryStatusKind::Active, + WorkingDirectoryStatusKind::CleanupPending => { + ApiWorkingDirectoryStatusKind::CleanupPending + } + WorkingDirectoryStatusKind::Corrupted => ApiWorkingDirectoryStatusKind::Corrupted, + WorkingDirectoryStatusKind::NotFound => ApiWorkingDirectoryStatusKind::NotFound, + WorkingDirectoryStatusKind::Unknown => ApiWorkingDirectoryStatusKind::Unknown, + }, + cleanliness: source.cleanliness, + primary_worker_id: source.primary_worker_id, + occupied_by: source + .occupied_by + .map(|occupancy| WorkingDirectoryOccupancy { + runtime_id: occupancy.worker.runtime_id, + worker_id: occupancy.worker.worker_id, + display_name: occupancy.display_name, + linked_at: occupancy.linked_at, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use workdir::workspace::{ + RuntimeWorkerRef, WorkingDirectoryOccupancy as DomainOccupancy, + WorkingDirectorySummary as DomainSummary, + }; + + #[test] + fn conversion_preserves_public_occupancy_subject() { + let converted = summary(DomainSummary { + working_directory_id: "workdir-1".into(), + repository_id: "main".into(), + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: None, + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materializer_kind: MaterializerKind::RuntimeGitCache, + cleanup_target: None, + status: WorkingDirectoryStatusKind::Active, + cleanliness: None, + primary_worker_id: None, + occupied_by: Some(DomainOccupancy { + worker: RuntimeWorkerRef::new("arcadia", "worker-1"), + display_name: "Coder".into(), + linked_at: "2026-01-01T00:00:00Z".into(), + }), + }); + + let occupancy = converted.occupied_by.expect("occupancy"); + assert_eq!(occupancy.runtime_id, "arcadia"); + assert_eq!(occupancy.worker_id, "worker-1"); + } +} diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts new file mode 100644 index 00000000..ef6d4082 --- /dev/null +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -0,0 +1,24 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; + +export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree"; + +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; + +export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, }; + +export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; + +export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; + +export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, }; + +export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; + +export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; + +export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, };