From b910efd9459d7a2b55b356691de421361645727b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 15 Aug 2026 03:29:53 +0900 Subject: [PATCH] workdir: centralize workspace inventory contract --- Cargo.lock | 1 + crates/client/Cargo.toml | 1 + crates/client/src/backend_runtime.rs | 81 ++--- crates/workdir/src/lib.rs | 1 + crates/workdir/src/workspace.rs | 280 ++++++++++++++++++ crates/worker-runtime/src/catalog.rs | 64 +--- crates/worker-runtime/src/identity.rs | 32 +- crates/worker-runtime/src/runtime.rs | 5 +- crates/worker-runtime/src/worker_backend.rs | 7 +- .../src/feature/builtin/manage_workdir.rs | 102 +++---- crates/workspace-server/src/server.rs | 93 ++++-- 11 files changed, 449 insertions(+), 218 deletions(-) create mode 100644 crates/workdir/src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index b4a6768f..e2cebe1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,6 +539,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "uuid", + "workdir", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 2487f248..08e075ee 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -16,6 +16,7 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } tokio-tungstenite = { workspace = true } uuid = { 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 d86aa606..50419378 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -7,6 +7,7 @@ use std::fmt; use tokio::sync::mpsc; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendRuntimeTarget { @@ -98,46 +99,6 @@ pub struct BackendWorkerCapabilitySummary { pub can_spawn_followup: bool, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BackendWorkingDirectoryCleanupTarget { - pub kind: String, - pub working_directory_id: String, - pub repository_id: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BackendWorkingDirectoryOccupancy { - pub runtime_id: String, - pub runtime_worker_id: u64, - pub worker_id: String, - pub display_name: String, - pub linked_at: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BackendWorkingDirectorySummary { - pub working_directory_id: String, - pub repository_id: String, - #[serde(default)] - pub creation_selector: Option, - #[serde(default)] - pub creation_ref: Option, - #[serde(default)] - pub current_selector: Option, - #[serde(default)] - pub current_ref: Option, - pub materializer_kind: String, - #[serde(default)] - pub cleanup_target: Option, - pub status: String, - #[serde(default)] - pub cleanliness: Option, - #[serde(default)] - pub primary_worker_id: Option, - #[serde(default)] - pub occupied_by: Option, -} - #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct BackendWorkerSummary { pub runtime_id: String, @@ -618,6 +579,46 @@ mod tests { ); } + #[test] + fn backend_worker_summary_decodes_current_occupied_workdir_contract() { + let payload = serde_json::json!({ + "runtime_id": "arcadia", + "worker_id": "worker-opaque-64", + "host_id": "host", + "display_name": "Coder", + "label": "Coder", + "workspace": {"visibility": "workspace", "identity": "workspace"}, + "state": "idle", + "implementation": {"kind": "worker", "display_hint": "Coder"}, + "capabilities": {"can_stop": true, "can_spawn_followup": false}, + "working_directory": { + "working_directory_id": "wd-1", + "repository_id": "main", + "materializer_kind": "local_git_worktree", + "status": "active", + "occupied_by": { + "runtime_id": "arcadia", + "worker_id": "worker-opaque-64", + "display_name": "Coder", + "linked_at": "2026-08-12T00:00:00Z" + } + } + }); + + let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap(); + let occupied_by = worker + .working_directory + .unwrap() + .occupied_by + .expect("occupied Workdir"); + assert_eq!(occupied_by.worker.runtime_id, "arcadia"); + assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); + + let mut stale = payload; + stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); + assert!(serde_json::from_value::(stale).is_err()); + } + #[test] fn workers_path_can_be_workspace_scoped_for_status_queries() { let path = backend_runtime_workers_path(Some("team main"), "runtime/one"); diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index f90b25c6..9be023a6 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -8,6 +8,7 @@ pub mod http; mod local; mod operation; +pub mod workspace; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs new file mode 100644 index 00000000..10912c2c --- /dev/null +++ b/crates/workdir/src/workspace.rs @@ -0,0 +1,280 @@ +//! Shared Workspace-facing Workdir inventory transport contracts. +//! +//! These projections describe Workspace inventory and durable occupancy. They +//! intentionally do not expose provider/session handles, host paths, Runtime +//! URLs, or credentials. Runtime-local Workdir operation contracts live in +//! [`crate::http`]. + +use serde::{Deserialize, Serialize}; + +/// Stable Workspace identity for a Worker hosted by a Runtime. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerRef { + pub runtime_id: String, + /// Runtime-owned opaque Worker id. Consumers must not assume a numeric id. + pub worker_id: String, +} + +impl RuntimeWorkerRef { + pub fn new(runtime_id: impl Into, worker_id: impl Into) -> Self { + Self { + runtime_id: runtime_id.into(), + worker_id: worker_id.into(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MaterializerKind { + #[default] + LocalGitWorktree, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryStatusKind { + Active, + CleanupPending, + Corrupted, + NotFound, + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[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(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct WorkingDirectoryOccupancy { + #[serde(flatten)] + pub worker: RuntimeWorkerRef, + pub display_name: String, + pub linked_at: String, +} + +impl<'de> Deserialize<'de> for WorkingDirectoryOccupancy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + runtime_id: String, + worker_id: String, + display_name: String, + linked_at: String, + } + + let wire = Wire::deserialize(deserializer)?; + Ok(Self { + worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id), + display_name: wire.display_name, + linked_at: wire.linked_at, + }) + } +} + +/// Immutable materialization provenance retained by Workspace inventory. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + pub materializer_kind: MaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, +} + +/// Latest provider-neutral observation attached to Workspace inventory. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCurrentObservation { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectorySummary { + pub working_directory_id: String, + pub repository_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + pub materializer_kind: MaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + +impl WorkingDirectorySummary { + /// Workspace-managed inventory rows carry explicit cleanup authority. + pub fn is_workspace_managed(&self) -> bool { + self.cleanup_target.is_some() + } + + pub fn provenance(&self) -> WorkingDirectoryProvenance { + WorkingDirectoryProvenance { + creation_selector: self.creation_selector.clone(), + creation_ref: self.creation_ref.clone(), + materializer_kind: self.materializer_kind.clone(), + cleanup_target: self.cleanup_target.clone(), + } + } + + pub fn current_observation(&self) -> WorkingDirectoryCurrentObservation { + WorkingDirectoryCurrentObservation { + current_selector: self.current_selector.clone(), + current_ref: self.current_ref.clone(), + status: self.status.clone(), + cleanliness: self.cleanliness.clone(), + primary_worker_id: self.primary_worker_id, + occupied_by: self.occupied_by.clone(), + } + } +} + +#[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 item: WorkingDirectorySummary, + pub diagnostics: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()), + current_selector: Some("work/ticket".to_string()), + current_ref: Some("def456".to_string()), + 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, + current_selector: None, + current_ref: Some("987fed".to_string()), + 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(), + 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); + } +} diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index e32f450d..e9263a76 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -92,12 +92,11 @@ pub struct WorkingDirectoryRepository { pub selector: Option, } -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MaterializerKind { - #[default] - LocalGitWorktree, -} +pub use workdir::workspace::{ + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryCurrentObservation, + WorkingDirectoryOccupancy, WorkingDirectoryProvenance, WorkingDirectoryStatusKind, + WorkingDirectorySummary, +}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkingDirectoryRequest { @@ -117,59 +116,6 @@ pub struct WorkingDirectoryClaim { pub relative_cwd: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkingDirectoryStatusKind { - Active, - CleanupPending, - Corrupted, - NotFound, - Unknown, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkingDirectoryCleanupTarget { - pub kind: String, - pub working_directory_id: String, - pub repository_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkingDirectoryOccupancy { - #[serde(flatten)] - pub worker: RuntimeWorkerRef, - pub display_name: String, - pub linked_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkingDirectorySummary { - pub working_directory_id: String, - pub repository_id: String, - /// Selector used to create this Workdir, retained as immutable materialization evidence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_selector: Option, - /// Provider-specific immutable ref resolved when this Workdir was created. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_ref: Option, - /// Selector currently observed from the materialized Workdir, when one exists. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_selector: Option, - /// Provider-specific immutable ref currently observed from the materialized Workdir. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_ref: Option, - pub materializer_kind: MaterializerKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cleanup_target: Option, - pub status: WorkingDirectoryStatusKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cleanliness: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub primary_worker_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub occupied_by: Option, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkingDirectoryStatus { pub summary: WorkingDirectorySummary, diff --git a/crates/worker-runtime/src/identity.rs b/crates/worker-runtime/src/identity.rs index ce3969a6..65366995 100644 --- a/crates/worker-runtime/src/identity.rs +++ b/crates/worker-runtime/src/identity.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fmt; +pub use workdir::workspace::RuntimeWorkerRef; /// Runtime-local Worker identity. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -30,29 +31,16 @@ impl fmt::Display for WorkerId { } } -/// Backend-visible Worker identity, namespaced by the Runtime that owns the Worker record. -/// -/// This is intentionally distinct from [`WorkerRef`], which is meaningful only inside one -/// Runtime. Do not flatten this reference into a concatenated string for authority decisions. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct RuntimeWorkerRef { - pub runtime_id: String, - pub worker_id: String, -} +/// Convert an opaque Workspace Worker reference only at the Runtime-local boundary. +impl TryFrom<&RuntimeWorkerRef> for WorkerRef { + type Error = std::num::ParseIntError; -impl RuntimeWorkerRef { - pub fn new(runtime_id: impl Into, worker_id: impl Into) -> Self { - Self { - runtime_id: runtime_id.into(), - worker_id: worker_id.into(), - } - } - - pub fn local_worker_ref(&self) -> Result { - self.worker_id + fn try_from(value: &RuntimeWorkerRef) -> Result { + value + .worker_id .parse::() .map(WorkerId::new) - .map(WorkerRef::new) + .map(Self::new) } } @@ -78,7 +66,7 @@ mod tests { assert_eq!(worker.runtime_id, "arcadia"); assert_eq!(worker.worker_id, "30"); assert_eq!( - worker.local_worker_ref().unwrap(), + WorkerRef::try_from(&worker).unwrap(), WorkerRef::new(WorkerId::new(30)) ); assert_eq!( @@ -90,6 +78,6 @@ mod tests { #[test] fn runtime_worker_ref_does_not_treat_composite_text_as_local_worker_id() { let worker = RuntimeWorkerRef::new("arcadia", "embedded-worker-runtime-5"); - assert!(worker.local_worker_ref().is_err()); + assert!(WorkerRef::try_from(&worker).is_err()); } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 360280de..239f1c63 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -467,8 +467,9 @@ impl Runtime { mut status: CatalogWorkingDirectoryStatus, ) -> Result { let state = self.lock()?; - status.summary.primary_worker_id = - state.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str()); + status.summary.primary_worker_id = state + .primary_worker_id_for_workdir(status.summary.working_directory_id.as_str()) + .map(|worker_id| worker_id.as_u64()); Ok(status) } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a57661a3..75fcb079 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -154,7 +154,7 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider { if grant.runtime_id != self.runtime_id { continue; } - let Ok(worker_ref) = grant.local_worker_ref() else { + let Ok(worker_ref) = WorkerRef::try_from(grant) else { continue; }; let Some((workspace_id, state, _)) = self.hub.get(&worker_ref) else { @@ -192,9 +192,8 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider { if !self.grants.contains(&grant) || runtime_id != &self.runtime_id { return Err(WorkerObservationError::NotFound); } - let worker_ref = grant - .local_worker_ref() - .map_err(|_| WorkerObservationError::NotFound)?; + let worker_ref = + WorkerRef::try_from(&grant).map_err(|_| WorkerObservationError::NotFound)?; let (workspace_id, _, sink) = self .hub .get(&worker_ref) diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index b5c7d5bd..2f4a5392 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -14,6 +14,10 @@ use llm_engine::tool::{ use serde::{Deserialize, Serialize}; use serde_json::json; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; +use workdir::workspace::{ + WorkingDirectoryDetailResponse as WorkdirDetailResponse, + WorkingDirectoryListResponse as WorkdirListResponse, +}; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, @@ -644,71 +648,6 @@ struct WorkdirDeleteInput { working_directory_id: String, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirListResponse { - workspace_id: String, - items: Vec, - diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirDetailResponse { - workspace_id: String, - item: WorkdirSummary, - diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirSummary { - working_directory_id: String, - repository_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - creation_selector: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - creation_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - current_selector: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - current_ref: Option, - materializer_kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - cleanup_target: Option, - status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - cleanliness: Option, - #[serde( - default, - alias = "primary_worker_id", - skip_serializing_if = "Option::is_none" - )] - attached_worker_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - occupied_by: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirCleanupTarget { - kind: String, - working_directory_id: String, - repository_id: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirOccupancy { - runtime_id: String, - runtime_worker_id: u64, - worker_id: String, - display_name: String, - linked_at: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct WorkdirDiagnostic { - code: String, - severity: String, - message: String, -} - #[cfg(test)] mod tests { use std::sync::Mutex; @@ -783,7 +722,13 @@ mod tests { "repository_id": "main" }, "status": "active", - "cleanliness": "clean" + "cleanliness": "clean", + "occupied_by": { + "runtime_id": "arcadia", + "worker_id": "worker-opaque-64", + "display_name": "Coder", + "linked_at": "2026-08-12T00:00:00Z" + } }) } @@ -889,6 +834,18 @@ mod tests { assert_eq!(delete_schema()["required"], json!(["working_directory_id"])); } + #[test] + fn legacy_runtime_worker_id_is_rejected_by_workspace_workdir_contract() { + let mut response = json!({ + "workspace_id": "workspace/one", + "items": [workdir_json("wd-1")], + "diagnostics": [] + }); + response["items"][0]["occupied_by"]["runtime_worker_id"] = json!(64); + + assert!(serde_json::from_value::(response).is_err()); + } + #[test] fn list_create_explicit_attach_detach_and_delete_use_scoped_workspace_authority_paths() { let client = Arc::new(RecordingWorkspaceClient::new(vec![ @@ -925,7 +882,18 @@ mod tests { ])); let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); - backend.list().unwrap(); + let listed = backend.list().unwrap(); + let listed: serde_json::Value = + serde_json::from_str(listed.content.as_deref().unwrap()).unwrap(); + assert_eq!( + listed["items"][0]["occupied_by"]["worker_id"], + "worker-opaque-64" + ); + assert!( + listed["items"][0]["occupied_by"] + .get("runtime_worker_id") + .is_none() + ); let created = backend .create(WorkdirCreateInput { runtime_id: "runtime/one".to_string(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8afb4280..7e80d47a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -44,6 +44,13 @@ use webauthn_rs::prelude::{ }; use workdir::WorkdirSessionHandle; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; +use workdir::workspace::{ + MaterializerKind, WorkingDirectoryCleanupTarget, + WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, + WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity, + WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy, + WorkingDirectoryStatusKind, WorkingDirectorySummary, +}; use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; @@ -101,10 +108,8 @@ use crate::store::{ }; use crate::{Error, Result}; use worker_runtime::catalog::{ - ConfigBundleRef, MaterializerKind, ProfileSelector, - RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim, - WorkingDirectoryOccupancy, WorkingDirectoryRepository, WorkingDirectoryRequest, - WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceApiRef, + ConfigBundleRef, ProfileSelector, RepositorySelector as RuntimeRepositorySelector, + WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, }; use worker_runtime::config_bundle::ConfigBundle; use worker_runtime::http_server::{ @@ -2027,20 +2032,6 @@ pub struct BrowserWorkingDirectoryCreateRequest { pub selector: Option, } -#[derive(Debug, Serialize, Deserialize)] -pub struct BrowserWorkingDirectoryListResponse { - pub workspace_id: String, - pub items: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct BrowserWorkingDirectoryDetailResponse { - pub workspace_id: String, - pub item: WorkingDirectorySummary, - pub diagnostics: Vec, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserWorkerWorkingDirectorySelection { @@ -5859,6 +5850,23 @@ async fn scoped_get_worker_launch_options( get_worker_launch_options(State(api)).await } +fn working_directory_diagnostics( + diagnostics: Vec, +) -> Vec { + diagnostics + .into_iter() + .map(|diagnostic| WorkingDirectoryDiagnostic { + code: diagnostic.code, + severity: match diagnostic.severity { + DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info, + DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning, + DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error, + }, + message: diagnostic.message, + }) + .collect() +} + async fn scoped_list_runtime_working_directories( State(api): State, AxumPath(path): AxumPath, @@ -5868,7 +5876,7 @@ async fn scoped_list_runtime_working_directories( Ok(Json(BrowserWorkingDirectoryListResponse { workspace_id: api.config.workspace_id.clone(), items, - diagnostics, + diagnostics: working_directory_diagnostics(diagnostics), })) } @@ -6020,7 +6028,7 @@ fn create_working_directory_for_runtime( Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item: summary, - diagnostics: result.diagnostics, + diagnostics: working_directory_diagnostics(result.diagnostics), })) } @@ -6041,7 +6049,7 @@ fn working_directory_detail_for_runtime( return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item: summary, - diagnostics: result.diagnostics, + diagnostics: working_directory_diagnostics(result.diagnostics), })); } if let Some(record) = api @@ -6051,7 +6059,7 @@ fn working_directory_detail_for_runtime( return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item: projected_workdir_summary_from_record(&api, &record)?, - diagnostics: result.diagnostics, + diagnostics: working_directory_diagnostics(result.diagnostics), })); } Err(ApiError::with_diagnostics( @@ -6110,7 +6118,7 @@ fn cleanup_working_directory_for_runtime( Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item: summary, - diagnostics: result.diagnostics, + diagnostics: working_directory_diagnostics(result.diagnostics), })) } @@ -10859,7 +10867,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto current_selector: record.current_selector.clone(), current_ref: record.current_ref.clone(), materializer_kind: MaterializerKind::LocalGitWorktree, - cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget { + cleanup_target: Some(WorkingDirectoryCleanupTarget { kind: "local_git_worktree".to_string(), working_directory_id: record.workdir_id.clone(), repository_id: record.repository_id.clone(), @@ -17412,6 +17420,43 @@ mod tests { assert_eq!(mutation.status(), StatusCode::METHOD_NOT_ALLOWED); } + #[test] + fn workspace_workdir_response_serializes_shared_occupied_contract() { + let response = BrowserWorkingDirectoryListResponse { + workspace_id: TEST_WORKSPACE_ID.to_string(), + items: vec![WorkingDirectorySummary { + working_directory_id: "wd-1".to_string(), + repository_id: "main".to_string(), + creation_selector: None, + creation_ref: None, + current_selector: Some("work/ticket".to_string()), + current_ref: Some("abc123".to_string()), + materializer_kind: MaterializerKind::LocalGitWorktree, + cleanup_target: None, + 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(), + }), + }], + diagnostics: vec![], + }; + + let value = serde_json::to_value(response).unwrap(); + assert_eq!( + value["items"][0]["occupied_by"]["worker_id"], + "worker-opaque-64" + ); + assert!( + value["items"][0]["occupied_by"] + .get("runtime_worker_id") + .is_none() + ); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())