refactor: centralize workdir REST DTOs

This commit is contained in:
2026-09-01 01:42:05 +09:00
parent 9756174676
commit 996b7f2468
13 changed files with 473 additions and 168 deletions
Generated
-2
View File
@@ -649,7 +649,6 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"uuid", "uuid",
"workdir",
"workspace-api", "workspace-api",
] ]
@@ -6684,7 +6683,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"ts-rs", "ts-rs",
"workdir",
] ]
[[package]] [[package]]
-1
View File
@@ -17,7 +17,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync"
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
workspace-api.workspace = true workspace-api.workspace = true
workdir = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }
+7 -3
View File
@@ -11,7 +11,6 @@ use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
@@ -20,6 +19,11 @@ pub use workspace_api::{
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -675,8 +679,8 @@ mod tests {
.unwrap() .unwrap()
.occupied_by .occupied_by
.expect("occupied Workdir"); .expect("occupied Workdir");
assert_eq!(occupied_by.worker.runtime_id, "arcadia"); assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload; let mut stale = payload;
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
-118
View File
@@ -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<WorkingDirectorySummary>,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[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<WorkingDirectoryDiagnostic>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -253,91 +220,6 @@ mod tests {
assert_eq!(serde_json::to_value(status).unwrap(), expected); 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::<WorkingDirectoryListResponse>(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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -12,11 +12,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{ use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -24,6 +20,11 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
use workspace_api::{
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
};
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDeclaration,
-1
View File
@@ -12,7 +12,6 @@ typescript = ["dep:ts-rs"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
workdir.workspace = true
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::workdir_api_typescript());
}
+232 -1
View File
@@ -5,7 +5,6 @@
//! callers must explicitly construct these Workspace-authoritative resources. //! callers must explicitly construct these Workspace-authoritative resources.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use workdir::workspace::WorkingDirectorySummary;
/// Provider-neutral classification of an authoritative Repository source. /// 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"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity { pub enum DiagnosticSeverity {
Info, Info,
@@ -101,12 +101,140 @@ pub enum DiagnosticSeverity {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct Diagnostic { pub struct Diagnostic {
pub code: String, pub code: String,
pub severity: DiagnosticSeverity, pub severity: DiagnosticSeverity,
pub message: String, 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<String>,
#[serde(default)]
pub creation_ref: Option<String>,
#[serde(default)]
pub creation_tree: Option<String>,
#[serde(default)]
pub current_selector: Option<String>,
#[serde(default)]
pub current_ref: Option<String>,
#[serde(default)]
pub current_tree: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: WorkingDirectoryMaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
pub status: WorkingDirectoryStatusKind,
#[serde(default)]
pub cleanliness: Option<String>,
#[serde(default)]
pub primary_worker_id: Option<String>,
#[serde(default)]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
/// 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<String>,
pub repository_id: String,
#[serde(default)]
pub selector: Option<String>,
#[serde(default)]
pub operation_id: Option<String>,
}
#[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<WorkingDirectorySummary>,
pub diagnostics: Vec<Diagnostic>,
}
#[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<Diagnostic>,
}
#[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<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ListResponse<T> { pub struct ListResponse<T> {
pub workspace_id: String, pub workspace_id: String,
@@ -537,6 +665,61 @@ pub struct RepositoryAccessProjection {
pub bindings: Vec<RepositorySshAccessBinding>, pub bindings: Vec<RepositorySshAccessBinding>,
} }
#[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::<Vec<_>>()
.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -560,4 +743,52 @@ mod tests {
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err()); assert!(serde_json::from_value::<WorkerSummary>(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::<WorkingDirectoryCreateRequest>(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::<WorkingDirectoryCreateRequest>(stale).is_err());
let incomplete = serde_json::json!({
"runtime_id": "arcadia",
"operation_id": "operation-1"
});
assert!(serde_json::from_value::<WorkingDirectoryCreateRequest>(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::<WorkingDirectoryListResponse>(stale).is_err());
}
} }
+1 -1
View File
@@ -341,7 +341,7 @@ pub(crate) fn workspace_worker_summary(
can_stop: summary.capabilities.can_stop, can_stop: summary.capabilities.can_stop,
can_spawn_followup: summary.capabilities.can_spawn_followup, 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(), diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(),
} }
} }
+1
View File
@@ -29,6 +29,7 @@ pub mod runtime_subscription;
pub mod server; pub mod server;
pub mod skills; pub mod skills;
pub mod store; pub mod store;
mod workdir_api;
pub mod workdir_create_operations; pub mod workdir_create_operations;
pub mod worker_source; pub mod worker_source;
pub mod workspace_catalog; pub mod workspace_catalog;
+108 -36
View File
@@ -48,10 +48,7 @@ use workdir::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
}; };
use workdir::workspace::{ use workdir::workspace::{
MaterializerKind, WorkingDirectoryCleanupTarget, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy,
WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence, WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest, WorkspaceWorkdirSessionOperationRequest,
}; };
@@ -66,8 +63,12 @@ use workspace_api::{
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource, WorkspaceWorkerDiscoveryItem, TICKET_RELATIONS_QUERY_PATH,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkspaceRuntimeResource,
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
}; };
use crate::auth::{ use crate::auth::{
@@ -2994,18 +2995,6 @@ pub struct WorkingDirectoryRepositoryOption {
pub default_selector: Option<String>, pub default_selector: Option<String>,
} }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkingDirectoryCreateRequest {
#[serde(default)]
pub runtime_id: Option<String>,
pub repository_id: String,
#[serde(default)]
pub selector: Option<String>,
#[serde(default)]
pub operation_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct BrowserWorkerWorkingDirectorySelection { pub struct BrowserWorkerWorkingDirectorySelection {
@@ -8827,15 +8816,15 @@ async fn scoped_get_worker_launch_options(
fn working_directory_diagnostics( fn working_directory_diagnostics(
diagnostics: Vec<RuntimeDiagnostic>, diagnostics: Vec<RuntimeDiagnostic>,
) -> Vec<WorkingDirectoryDiagnostic> { ) -> Vec<workspace_api::Diagnostic> {
diagnostics diagnostics
.into_iter() .into_iter()
.map(|diagnostic| WorkingDirectoryDiagnostic { .map(|diagnostic| workspace_api::Diagnostic {
code: diagnostic.code, code: diagnostic.code,
severity: match diagnostic.severity { severity: match diagnostic.severity {
DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info, DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning, DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error, DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
}, },
message: diagnostic.message, 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)?; let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?;
Ok(Json(BrowserWorkingDirectoryListResponse { Ok(Json(BrowserWorkingDirectoryListResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
items, items: items.into_iter().map(crate::workdir_api::summary).collect(),
diagnostics: working_directory_diagnostics(diagnostics), diagnostics: working_directory_diagnostics(diagnostics),
})) }))
} }
@@ -8859,7 +8848,7 @@ async fn scoped_create_runtime_working_directory(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>, AxumPath(path): AxumPath<ScopedRuntimePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>, Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> { ) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory( create_workspace_working_directory(
&api, &api,
&path.workspace_id, &path.workspace_id,
@@ -8893,7 +8882,7 @@ async fn scoped_list_working_directories(
let items = working_directory_summaries(&api)?; let items = working_directory_summaries(&api)?;
Ok(Json(BrowserWorkingDirectoryListResponse { Ok(Json(BrowserWorkingDirectoryListResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
items, items: items.into_iter().map(crate::workdir_api::summary).collect(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
})) }))
} }
@@ -8902,7 +8891,7 @@ async fn scoped_create_working_directory(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>, Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> { ) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory(&api, &path.workspace_id, None, request).await create_workspace_working_directory(&api, &path.workspace_id, None, request).await
} }
@@ -8995,7 +8984,7 @@ async fn create_workspace_working_directory(
workspace_id: &str, workspace_id: &str,
route_runtime_id: Option<&str>, route_runtime_id: Option<&str>,
request: BrowserWorkingDirectoryCreateRequest, request: BrowserWorkingDirectoryCreateRequest,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> { ) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
validate_workspace_scope(api, workspace_id)?; validate_workspace_scope(api, workspace_id)?;
if let (Some(route_runtime_id), Some(request_runtime_id)) = if let (Some(route_runtime_id), Some(request_runtime_id)) =
(route_runtime_id, request.runtime_id.as_deref()) (route_runtime_id, request.runtime_id.as_deref())
@@ -9158,7 +9147,17 @@ async fn create_workspace_working_directory(
&reserved.resolved_runtime_id, &reserved.resolved_runtime_id,
&reserved.working_directory_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 let runtime = match api
@@ -9337,10 +9336,10 @@ async fn create_workspace_working_directory(
apply_workdir_occupancy_projection(api, &mut summary)?; apply_workdir_occupancy_projection(api, &mut summary)?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(BrowserWorkingDirectoryDetailResponse { Json(BrowserWorkingDirectoryCreateResponse {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
runtime_id: reserved.resolved_runtime_id, runtime_id: reserved.resolved_runtime_id,
item: summary, item: crate::workdir_api::summary(summary),
diagnostics: working_directory_diagnostics(result.diagnostics), diagnostics: working_directory_diagnostics(result.diagnostics),
}), }),
)) ))
@@ -9363,7 +9362,7 @@ fn working_directory_detail_for_runtime(
return Ok(Json(BrowserWorkingDirectoryDetailResponse { return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
item: summary, item: crate::workdir_api::summary(summary),
diagnostics: working_directory_diagnostics(result.diagnostics), diagnostics: working_directory_diagnostics(result.diagnostics),
})); }));
} }
@@ -9374,7 +9373,9 @@ fn working_directory_detail_for_runtime(
return Ok(Json(BrowserWorkingDirectoryDetailResponse { return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: runtime_id.to_string(), 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), diagnostics: working_directory_diagnostics(result.diagnostics),
})); }));
} }
@@ -9433,7 +9434,7 @@ fn cleanup_working_directory_for_runtime(
Ok(Json(BrowserWorkingDirectoryDetailResponse { Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
item: summary, item: crate::workdir_api::summary(summary),
diagnostics: working_directory_diagnostics(result.diagnostics), 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] #[tokio::test]
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() { async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
let dir = tempfile::tempdir().unwrap(); 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() { fn workspace_workdir_response_serializes_shared_occupied_contract() {
let response = BrowserWorkingDirectoryListResponse { let response = BrowserWorkingDirectoryListResponse {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
items: vec![WorkingDirectorySummary { items: vec![crate::workdir_api::summary(WorkingDirectorySummary {
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
creation_selector: None, creation_selector: None,
@@ -25425,7 +25497,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
display_name: "Coder".to_string(), display_name: "Coder".to_string(),
linked_at: "2026-08-12T00:00:00Z".to_string(), linked_at: "2026-08-12T00:00:00Z".to_string(),
}), }),
}], })],
diagnostics: vec![], diagnostics: vec![],
}; };
@@ -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");
}
}
@@ -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<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };