workdir: centralize workspace inventory contract
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: String,
|
||||
#[serde(default)]
|
||||
pub cleanup_target: Option<BackendWorkingDirectoryCleanupTarget>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub occupied_by: Option<BackendWorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[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::<BackendWorkerSummary>(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");
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod http;
|
||||
mod local;
|
||||
mod operation;
|
||||
pub mod workspace;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -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<String>, worker_id: impl Into<String>) -> 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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
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<WorkingDirectorySummary>,
|
||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryDetailResponse {
|
||||
pub workspace_id: String,
|
||||
pub item: WorkingDirectorySummary,
|
||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[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::<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(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,11 @@ pub struct WorkingDirectoryRepository {
|
||||
pub selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Provider-specific immutable ref resolved when this Workdir was created.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
/// Selector currently observed from the materialized Workdir, when one exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
/// Provider-specific immutable ref currently observed from the materialized Workdir.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<WorkerId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryStatus {
|
||||
pub summary: WorkingDirectorySummary,
|
||||
|
||||
@@ -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<String>, worker_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_worker_ref(&self) -> Result<WorkerRef, std::num::ParseIntError> {
|
||||
self.worker_id
|
||||
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> {
|
||||
value
|
||||
.worker_id
|
||||
.parse::<u64>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,8 +467,9 @@ impl Runtime {
|
||||
mut status: CatalogWorkingDirectoryStatus,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<WorkdirSummary>,
|
||||
diagnostics: Vec<WorkdirDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirDetailResponse {
|
||||
workspace_id: String,
|
||||
item: WorkdirSummary,
|
||||
diagnostics: Vec<WorkdirDiagnostic>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_ref: Option<String>,
|
||||
materializer_kind: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cleanup_target: Option<WorkdirCleanupTarget>,
|
||||
status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cleanliness: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "primary_worker_id",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
attached_worker_id: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
occupied_by: Option<WorkdirOccupancy>,
|
||||
}
|
||||
|
||||
#[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::<WorkdirListResponse>(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(),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BrowserWorkingDirectoryListResponse {
|
||||
pub workspace_id: String,
|
||||
pub items: Vec<WorkingDirectorySummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BrowserWorkingDirectoryDetailResponse {
|
||||
pub workspace_id: String,
|
||||
pub item: WorkingDirectorySummary,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[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<RuntimeDiagnostic>,
|
||||
) -> Vec<WorkingDirectoryDiagnostic> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user