chore: merge current develop into workspace catalog DTO source

# Conflicts:
#	crates/workspace-api/src/lib.rs
#	crates/workspace-server/src/server.rs
#	web/workspace/deno.json
#	web/workspace/src/lib/workspace/sidebar/types.ts
This commit is contained in:
2026-09-01 08:17:25 +09:00
33 changed files with 2597 additions and 649 deletions
-1
View File
@@ -17,7 +17,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync"
tokio-tungstenite = { workspace = true }
uuid = { workspace = true }
workspace-api.workspace = true
workdir = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+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::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
@@ -20,6 +19,11 @@ pub use workspace_api::{
WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -675,8 +679,8 @@ mod tests {
.unwrap()
.occupied_by
.expect("occupied Workdir");
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload;
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
+6 -1
View File
@@ -38,5 +38,10 @@ pub use target::{
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
};
pub use worker_client::WorkerClient;
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
};
pub use workspace_product::BackendWorkspaceProductClient;
+1
View File
@@ -18,6 +18,7 @@ sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
workspace-api = { workspace = true }
[dev-dependencies]
serde_json.workspace = true
+11 -251
View File
@@ -6,7 +6,11 @@
//! [`crate::http`].
use serde::{Deserialize, Serialize};
use std::fmt;
pub use workspace_api::{
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
/// Stable Workspace identity for a Worker hosted by a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -26,83 +30,6 @@ impl RuntimeWorkerRef {
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind {
Active,
CleanupPending,
Corrupted,
NotFound,
Unknown,
}
impl WorkingDirectoryStatusKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::CleanupPending => "cleanup_pending",
Self::Corrupted => "corrupted",
Self::NotFound => "not_found",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for WorkingDirectoryStatusKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[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)]
@@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation {
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 creation_tree: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
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<String>,
#[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(),
creation_tree: self.creation_tree.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(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(),
cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(),
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 runtime_id: String,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -255,88 +88,15 @@ mod tests {
}
#[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();
fn workspace_workdir_projection_reexports_workspace_api_authority() {
assert_eq!(
encoded["items"][0]["occupied_by"]["worker_id"],
"worker-opaque-64"
std::any::TypeId::of::<WorkingDirectorySummary>(),
std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
);
assert!(
encoded["items"][0]["occupied_by"]
.get("runtime_worker_id")
.is_none()
assert_eq!(
std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
);
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);
}
}
@@ -12,11 +12,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -24,6 +20,13 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
@@ -420,9 +423,9 @@ impl WorkspaceHttpWorkdirBackend {
runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(),
selector,
operation_id,
operation_id: Some(operation_id),
};
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
let response = self.execute_json::<WorkdirCreateResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?,
@@ -701,16 +704,6 @@ struct WorkdirCreateInput {
selector: Option<String>,
}
#[derive(Debug, Serialize)]
struct WorkdirCreateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
runtime_id: Option<String>,
repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<String>,
operation_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkdirAttachInput {
+12 -1
View File
@@ -12,7 +12,6 @@ typescript = ["dep:ts-rs"]
[dependencies]
serde = { workspace = true, features = ["derive"] }
ts-rs = { version = "12.0.1", optional = true }
workdir.workspace = true
[[example]]
name = "generate_typescript"
@@ -20,3 +19,15 @@ required-features = ["typescript"]
[dev-dependencies]
serde_json.workspace = true
[[example]]
name = "generate_workdir_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_companion_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_repository_access_types"
required-features = ["typescript"]
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::companion_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::repository_access_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::workdir_api_typescript());
}
+681 -1
View File
@@ -5,7 +5,6 @@
//! callers must explicitly construct these Workspace-authoritative resources.
use serde::{Deserialize, Serialize};
use workdir::workspace::WorkingDirectorySummary;
/// Provider-neutral classification of an authoritative Repository source.
///
@@ -319,12 +318,165 @@ pub enum DiagnosticSeverity {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct Diagnostic {
pub code: String,
pub severity: DiagnosticSeverity,
pub message: String,
}
/// Public Workdir materializer classification.
///
/// The value identifies stable materialization provenance without exposing a
/// provider path, Runtime handle, or session identity.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryMaterializerKind {
#[default]
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,
}
impl WorkingDirectoryStatusKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::CleanupPending => "cleanup_pending",
Self::Corrupted => "corrupted",
Self::NotFound => "not_found",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for WorkingDirectoryStatusKind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[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, 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 creation_tree: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[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, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[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()
}
}
/// 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, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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)]
pub struct ListResponse<T> {
pub workspace_id: String,
@@ -543,6 +695,7 @@ pub struct WorkerCapabilitySummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkspaceWorkerSubject {
RuntimeWorker {
@@ -555,6 +708,7 @@ pub enum WorkspaceWorkerSubject {
/// Runtime placement appears only in the typed subject required by Worker
/// control operations; provider and launch internals are intentionally omitted.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceWorkerDiscoveryItem {
pub subject: WorkspaceWorkerSubject,
pub resource_key: String,
@@ -564,6 +718,137 @@ pub struct WorkspaceWorkerDiscoveryItem {
pub status: Option<String>,
}
/// Public lifecycle projection for the Workspace Companion endpoint.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionLifecycleState {
Idle,
Running,
Stopped,
}
/// Public outcome of a Companion message submission.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionMessageDisposition {
Accepted,
Rejected,
}
/// Public, bounded transport metadata for Companion status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTransportSummary {
pub mode: String,
pub available: bool,
}
/// Public Workspace Companion status response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionStatusResponse {
pub state: CompanionLifecycleState,
pub worker: Option<WorkspaceWorkerDiscoveryItem>,
pub transport: CompanionTransportSummary,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
/// Public Workspace Companion message request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionMessageRequest {
pub content: String,
}
/// Public Workspace Companion cancellation request.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// Public Workspace Companion message response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionMessageResponse {
pub state: CompanionMessageDisposition,
pub message: String,
}
/// User-visible role accepted in the public Companion transcript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionTranscriptRole {
User,
Assistant,
}
/// One allowlisted, user-visible Companion transcript item.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTranscriptItem {
pub sequence: usize,
pub role: CompanionTranscriptRole,
pub content: String,
pub created_at: String,
}
/// Bounded public Companion transcript projection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTranscriptProjection {
pub state: CompanionLifecycleState,
pub start: usize,
pub limit: usize,
pub total: usize,
pub next: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
}
#[cfg(feature = "typescript")]
pub fn companion_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
DiagnosticSeverity::decl(&config),
Diagnostic::decl(&config),
WorkspaceWorkerSubject::decl(&config),
WorkspaceWorkerDiscoveryItem::decl(&config),
CompanionLifecycleState::decl(&config),
CompanionMessageDisposition::decl(&config),
CompanionTransportSummary::decl(&config),
CompanionStatusResponse::decl(&config),
CompanionMessageRequest::decl(&config),
CompanionCancelRequest::decl(&config),
CompanionMessageResponse::decl(&config),
CompanionTranscriptRole::decl(&config),
CompanionTranscriptItem::decl(&config),
CompanionTranscriptProjection::decl(&config),
];
format!(
"// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceWorkerDiscoveryPage {
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
@@ -649,6 +934,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
///
/// Secret references and secret material are deliberately not part of this DTO.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshCredential {
pub credential_id: String,
@@ -656,6 +942,7 @@ pub struct RepositorySshCredential {
pub name: String,
pub public_key_algorithm: String,
pub public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub current_revision: u64,
pub status: String,
pub created_at: String,
@@ -665,6 +952,7 @@ pub struct RepositorySshCredential {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CreateRepositorySshCredentialRequest {
pub operation_id: String,
@@ -676,9 +964,11 @@ pub struct CreateRepositorySshCredentialRequest {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RotateRepositorySshCredentialRequest {
pub operation_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub expected_revision: u64,
pub private_key: String,
#[serde(default)]
@@ -686,14 +976,17 @@ pub struct RotateRepositorySshCredentialRequest {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshCredentialRequest {
pub operation_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub expected_revision: u64,
}
/// Public metadata for an explicitly pinned SSH host key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshHostTrust {
pub host_trust_id: String,
@@ -703,6 +996,7 @@ pub struct RepositorySshHostTrust {
pub key_algorithm: String,
pub host_key: String,
pub fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub current_revision: u64,
pub created_at: String,
pub updated_at: String,
@@ -711,6 +1005,7 @@ pub struct RepositorySshHostTrust {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRepositorySshHostTrustRequest {
pub operation_id: String,
@@ -719,17 +1014,22 @@ pub struct PutRepositorySshHostTrustRequest {
pub port: u16,
pub host_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshHostTrustRequest {
pub operation_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub expected_revision: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
#[serde(rename_all = "snake_case")]
pub enum RepositoryAccessMode {
ReadOnly,
@@ -737,6 +1037,7 @@ pub enum RepositoryAccessMode {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshAccessBinding {
pub repository_id: String,
@@ -747,9 +1048,11 @@ pub struct RepositorySshAccessBinding {
/// Secret-free active Repository access projection consumed by later Runtime work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositoryAccessProjection {
pub workspace_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub config_revision: u64,
pub projection_digest: String,
pub bindings: Vec<RepositorySshAccessBinding>,
@@ -792,6 +1095,88 @@ pub fn catalog_typescript() -> String {
)
}
#[cfg(feature = "typescript")]
pub fn repository_access_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
RepositorySshCredential::decl(&config),
CreateRepositorySshCredentialRequest::decl(&config),
RotateRepositorySshCredentialRequest::decl(&config),
DeleteRepositorySshCredentialRequest::decl(&config),
RepositorySshHostTrust::decl(&config),
PutRepositorySshHostTrustRequest::decl(&config),
DeleteRepositorySshHostTrustRequest::decl(&config),
RepositoryAccessMode::decl(&config),
RepositorySshAccessBinding::decl(&config),
RepositoryAccessProjection::decl(&config),
];
format!(
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
#[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 workdir_typescript_tests {
#[test]
fn generated_workdir_api_contract_is_current() {
let expected = super::workdir_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/workdir-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
assert_eq!(
normalize(&actual),
normalize(&expected),
"regenerate Workdir API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file",
);
}
fn normalize(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
'\r' | '\n' | ' ' | '\t' => None,
_ => Some(character),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -910,4 +1295,299 @@ mod tests {
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
}
fn round_trip<T>(value: T)
where
T: std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>,
{
let encoded = serde_json::to_vec(&value).expect("fixture should serialize");
let decoded: T = serde_json::from_slice(&encoded).expect("fixture should deserialize");
assert_eq!(decoded, value);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker {
runtime_id: "arcadia".to_string(),
worker_id: "worker-7".to_string(),
},
resource_key: "W-7".to_string(),
display_name: "Companion".to_string(),
profile: Some("builtin:companion".to_string()),
status: Some("idle".to_string()),
}
}
#[test]
fn companion_status_fixtures_round_trip() {
for state in [
CompanionLifecycleState::Idle,
CompanionLifecycleState::Running,
CompanionLifecycleState::Stopped,
] {
round_trip(CompanionStatusResponse {
state,
worker: Some(companion_worker()),
transport: CompanionTransportSummary {
mode: "worker_runtime".to_string(),
available: state != CompanionLifecycleState::Stopped,
},
diagnostics: Vec::new(),
});
}
}
#[test]
fn companion_message_fixtures_round_trip() {
for state in [
CompanionMessageDisposition::Accepted,
CompanionMessageDisposition::Rejected,
] {
round_trip(CompanionMessageResponse {
state,
message: if state == CompanionMessageDisposition::Accepted {
"accepted"
} else {
"rejected"
}
.to_string(),
});
}
}
#[test]
fn companion_transcript_fixture_round_trips() {
round_trip(CompanionTranscriptProjection {
state: CompanionLifecycleState::Idle,
start: 0,
limit: 2,
total: 2,
next: None,
items: vec![
CompanionTranscriptItem {
sequence: 1,
role: CompanionTranscriptRole::User,
content: "hello".to_string(),
created_at: "2026-08-31T00:00:00Z".to_string(),
},
CompanionTranscriptItem {
sequence: 2,
role: CompanionTranscriptRole::Assistant,
content: "hi".to_string(),
created_at: "2026-08-31T00:00:01Z".to_string(),
},
],
});
}
#[test]
fn companion_transcript_rejects_system_and_private_fields() {
let public_item = CompanionTranscriptItem {
sequence: 1,
role: CompanionTranscriptRole::Assistant,
content: "visible".to_string(),
created_at: "2026-08-31T00:00:00Z".to_string(),
};
let public_fields = serde_json::to_value(public_item)
.expect("public transcript item should serialize")
.as_object()
.expect("public transcript item should be an object")
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
public_fields,
["content", "created_at", "role", "sequence"]
.into_iter()
.map(str::to_string)
.collect()
);
let system_item = serde_json::json!({
"sequence": 1,
"role": "system",
"content": "raw system prompt",
"created_at": "2026-08-31T00:00:00Z"
});
assert!(serde_json::from_value::<CompanionTranscriptItem>(system_item).is_err());
let private_item = serde_json::json!({
"sequence": 1,
"role": "assistant",
"content": "visible",
"created_at": "2026-08-31T00:00:00Z",
"reasoning": "hidden",
"credential": "secret",
"provider_session_id": "session-private"
});
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_companion_api_contract_is_current() {
let expected = companion_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/companion-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
assert_eq!(
normalize_typescript(&actual),
normalize_typescript(&expected),
"regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
);
}
#[cfg(feature = "typescript")]
fn normalize_typescript(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect::<String>()
.replace(";}", "}")
}
#[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);
let serialized = serde_json::to_value(request).expect("serialize create request");
assert_eq!(serialized, serde_json::json!({"repository_id": "main"}));
}
#[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_summary_omits_absent_optional_fields_on_the_wire() {
let value = serde_json::to_value(WorkingDirectorySummary {
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: WorkingDirectoryMaterializerKind::RuntimeGitCache,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: None,
primary_worker_id: None,
occupied_by: None,
})
.expect("serialize Workdir summary");
let object = value.as_object().expect("Workdir summary object");
for key in [
"creation_selector",
"creation_ref",
"creation_tree",
"current_selector",
"current_ref",
"current_tree",
"observed_at_epoch_seconds",
"cleanup_target",
"cleanliness",
"primary_worker_id",
"occupied_by",
] {
assert!(
!object.contains_key(key),
"absent field {key} must be omitted"
);
}
}
#[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());
}
}
#[cfg(all(test, feature = "typescript"))]
mod typescript_tests {
#[test]
fn generated_repository_access_contract_is_current() {
let expected = super::repository_access_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/repository-access-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 Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file",
);
}
#[test]
fn generated_repository_access_responses_remain_secret_free() {
use ts_rs::TS;
let config = ts_rs::Config::default();
for declaration in [
super::RepositorySshCredential::decl(&config),
super::RepositorySshHostTrust::decl(&config),
super::RepositoryAccessProjection::decl(&config),
] {
for forbidden in ["private_key", "passphrase", "secret_ref"] {
assert!(
!declaration.contains(forbidden),
"Repository Access response declaration must not expose `{forbidden}`"
);
}
}
}
fn normalize(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect()
}
}
+32 -113
View File
@@ -1,77 +1,14 @@
use serde::{Deserialize, Serialize};
use workspace_api::{
CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic,
DiagnosticSeverity,
};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompanionState {
Disabled,
Rejected,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionStatusResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
pub transport: CompanionTransportSummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTransportSummary {
pub kind: String,
pub completion: String,
pub limitation: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageRequest {
pub content: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_item: Option<CompanionTranscriptItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_item: Option<CompanionTranscriptItem>,
pub transcript: CompanionTranscriptProjection,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptProjection {
pub state: CompanionState,
pub start: usize,
pub limit: usize,
pub total_items: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_start: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptItem {
pub sequence: u64,
pub role: String,
pub content: String,
pub created_at: String,
pub source: String,
pub status: String,
}
pub use workspace_api::{
CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
#[derive(Clone, Default)]
pub struct CompanionConsole;
impl CompanionConsole {
@@ -81,68 +18,50 @@ impl CompanionConsole {
pub fn status(&self) -> CompanionStatusResponse {
CompanionStatusResponse {
state: CompanionState::Disabled,
state: CompanionLifecycleState::Stopped,
worker: None,
transport: disabled_transport(),
transport: CompanionTransportSummary {
mode: "disabled".to_string(),
available: false,
},
diagnostics: vec![disabled_diagnostic()],
}
}
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
CompanionTranscriptProjection {
state: CompanionState::Disabled,
state: CompanionLifecycleState::Stopped,
start,
limit,
total_items: 0,
next_start: None,
total: 0,
next: None,
items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
}
}
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
disabled_message_response(CompanionState::Rejected)
disabled_message_response()
}
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
disabled_message_response(CompanionState::Cancelled)
disabled_message_response()
}
}
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
fn disabled_message_response() -> CompanionMessageResponse {
CompanionMessageResponse {
state,
worker: None,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Disabled,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
},
diagnostics: vec![disabled_diagnostic()],
}
}
fn disabled_transport() -> CompanionTransportSummary {
CompanionTransportSummary {
kind: "none".to_string(),
completion: "disabled".to_string(),
limitation:
"Workspace Companion auto-start has been removed; create an explicit Worker instead."
.to_string(),
}
}
fn disabled_diagnostic() -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: "companion_disabled".to_string(),
severity: DiagnosticSeverity::Info,
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
state: CompanionMessageDisposition::Rejected,
message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead."
.to_string(),
}
}
fn disabled_diagnostic() -> Diagnostic {
Diagnostic {
code: "companion_disabled".to_string(),
severity: DiagnosticSeverity::Info,
message:
"Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead."
.to_string(),
}
}
+164 -81
View File
@@ -48,10 +48,7 @@ use workdir::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
};
use workdir::workspace::{
MaterializerKind, WorkingDirectoryCleanupTarget,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy,
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy,
WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
@@ -67,10 +64,15 @@ use workspace_api::{
RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse,
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkspaceCatalogListResponse, WorkspaceCreateResponse,
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspacePermissionSummary,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary,
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
TICKET_RELATIONS_QUERY_PATH,
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse,
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
WorkspaceExtensionPoints, WorkspacePermissionSummary, WorkspaceRepositoryRecord,
WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
};
use crate::auth::{
@@ -3008,18 +3010,6 @@ pub struct WorkingDirectoryRepositoryOption {
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)]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkerWorkingDirectorySelection {
@@ -8816,15 +8806,15 @@ async fn scoped_get_worker_launch_options(
fn working_directory_diagnostics(
diagnostics: Vec<RuntimeDiagnostic>,
) -> Vec<WorkingDirectoryDiagnostic> {
) -> Vec<workspace_api::Diagnostic> {
diagnostics
.into_iter()
.map(|diagnostic| WorkingDirectoryDiagnostic {
.map(|diagnostic| workspace_api::Diagnostic {
code: diagnostic.code,
severity: match diagnostic.severity {
DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info,
DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning,
DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error,
DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
},
message: diagnostic.message,
})
@@ -8848,7 +8838,7 @@ async fn scoped_create_runtime_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory(
&api,
&path.workspace_id,
@@ -8891,7 +8881,7 @@ async fn scoped_create_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory(&api, &path.workspace_id, None, request).await
}
@@ -8984,7 +8974,7 @@ async fn create_workspace_working_directory(
workspace_id: &str,
route_runtime_id: Option<&str>,
request: BrowserWorkingDirectoryCreateRequest,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
validate_workspace_scope(api, workspace_id)?;
if let (Some(route_runtime_id), Some(request_runtime_id)) =
(route_runtime_id, request.runtime_id.as_deref())
@@ -9147,7 +9137,17 @@ async fn create_workspace_working_directory(
&reserved.resolved_runtime_id,
&reserved.working_directory_id,
)
.map(|response| (StatusCode::OK, response));
.map(|Json(response)| {
(
StatusCode::OK,
Json(BrowserWorkingDirectoryCreateResponse {
workspace_id: response.workspace_id,
runtime_id: response.runtime_id,
item: response.item,
diagnostics: response.diagnostics,
}),
)
});
}
let runtime = match api
@@ -9326,7 +9326,7 @@ async fn create_workspace_working_directory(
apply_workdir_occupancy_projection(api, &mut summary)?;
Ok((
StatusCode::CREATED,
Json(BrowserWorkingDirectoryDetailResponse {
Json(BrowserWorkingDirectoryCreateResponse {
workspace_id: workspace_id.to_string(),
runtime_id: reserved.resolved_runtime_id,
item: summary,
@@ -11090,33 +11090,31 @@ async fn get_workspace(
fn companion_console_extension_point(
status: &CompanionStatusResponse,
) -> WorkspaceExtensionPointState {
let completion = status.transport.completion.clone();
let note = match completion.as_str() {
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(),
"not_input_capable" => {
let diagnostic_codes = status
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>()
.join(", ");
if diagnostic_codes.is_empty() {
"Workspace Companion is not input-capable; check provider, config, profile, secret, and authority diagnostics.".to_string()
} else {
format!(
"Workspace Companion is not input-capable; check typed diagnostics: {diagnostic_codes}."
)
}
}
"disabled" => "Workspace Companion auto-start has been removed; create an explicit Worker instead.".to_string(),
other => format!(
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
),
let extension_status = match status.state {
workspace_api::CompanionLifecycleState::Idle => "idle",
workspace_api::CompanionLifecycleState::Running => "running",
workspace_api::CompanionLifecycleState::Stopped => "stopped",
}
.to_string();
let diagnostic_codes = status
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>()
.join(", ");
let note = if status.transport.available {
"Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path."
.to_string()
} else if diagnostic_codes.is_empty() {
"Workspace Companion is unavailable; create or select an explicit Worker instead."
.to_string()
} else {
format!("Workspace Companion is unavailable; check typed diagnostics: {diagnostic_codes}.")
};
WorkspaceExtensionPointState {
status: completion,
status: extension_status,
note,
diagnostics: status.diagnostics.iter().cloned().map(Into::into).collect(),
diagnostics: status.diagnostics.clone(),
}
}
@@ -14147,7 +14145,8 @@ fn merge_worker_registry_projection(
.map(|workdir| {
let mut workdir_summary = workdir_summary_from_record(workdir);
workdir_summary.occupied_by = Some(WorkingDirectoryOccupancy {
worker: record.worker.clone(),
runtime_id: record.worker.runtime_id.clone(),
worker_id: record.worker.worker_id.clone(),
display_name: record.display_name.clone(),
linked_at: link.linked_at.clone(),
});
@@ -14496,7 +14495,8 @@ fn apply_workdir_occupancy_projection(
})?;
summary.primary_worker_id = None;
summary.occupied_by = Some(WorkingDirectoryOccupancy {
worker: link.worker.clone(),
runtime_id: link.worker.runtime_id.clone(),
worker_id: link.worker.worker_id.clone(),
display_name: worker.display_name,
linked_at: link.linked_at.clone(),
});
@@ -15933,7 +15933,8 @@ mod tests {
assert_eq!(working_directory.current_selector, None);
assert_eq!(working_directory.current_ref.as_deref(), Some("fedcba"));
let occupied_by = working_directory.occupied_by.as_ref().unwrap();
assert_eq!(occupied_by.worker, RuntimeWorkerRef::new("embedded", "1"));
assert_eq!(occupied_by.runtime_id, "embedded");
assert_eq!(occupied_by.worker_id, "1");
assert!(working_directory.primary_worker_id.is_none());
let occupancy = serde_json::to_value(occupied_by).unwrap();
assert_eq!(occupancy["runtime_id"], "embedded");
@@ -17138,10 +17139,8 @@ mod tests {
.find(|summary| summary.working_directory_id == "managed")
.unwrap();
let occupied_by = managed.occupied_by.as_ref().unwrap();
assert_eq!(
occupied_by.worker,
RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7")
);
assert_eq!(occupied_by.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
assert_eq!(occupied_by.worker_id, "7");
assert_eq!(occupied_by.display_name, "Worker Seven");
assert_eq!(occupied_by.linked_at, "3");
@@ -22991,6 +22990,77 @@ mod tests {
);
}
#[tokio::test]
async fn browser_workspace_workdir_create_rejects_stale_json_before_side_effects() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "stale-workdir-create-json");
let response = request_json_authenticated(
build_router(api.clone()),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
Some(serde_json::json!({
"runtime_id": "missing-runtime",
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD",
"operation_id": "stale-workdir-create",
"path": "/tmp/legacy-workdir",
})),
&token,
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
assert!(
response["message"]
.as_str()
.unwrap_or_default()
.contains("unknown field")
);
assert!(
api.config_store
.load_workdir_create_operation(TEST_WORKSPACE_ID, "stale-workdir-create")
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn browser_workspace_workdir_create_rejects_unconfigured_repository() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "missing-workdir-repository");
let response = request_json_authenticated(
build_router(api.clone()),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
Some(serde_json::json!({
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID,
"repository_id": "foreign-or-missing-repository",
"selector": "HEAD",
"operation_id": "missing-workdir-repository",
})),
&token,
StatusCode::NOT_FOUND,
)
.await;
assert!(
response["message"]
.as_str()
.unwrap_or_default()
.contains("unknown local repository")
);
assert!(
api.config_store
.load_workdir_create_operation(TEST_WORKSPACE_ID, "missing-workdir-repository")
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -24151,10 +24221,10 @@ mod tests {
);
let companion_status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(companion_status["state"], "disabled");
assert_eq!(companion_status["state"], "stopped");
assert!(companion_status["worker"].is_null());
assert_eq!(companion_status["transport"]["kind"], "none");
assert_eq!(companion_status["transport"]["completion"], "disabled");
assert_eq!(companion_status["transport"]["mode"], "disabled");
assert_eq!(companion_status["transport"]["available"], false);
assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json(
@@ -24164,16 +24234,26 @@ mod tests {
)
.await;
assert_eq!(companion_message["state"], "rejected");
assert_eq!(
companion_message["diagnostics"][0]["code"],
"companion_disabled"
);
assert!(companion_message["user_item"].is_null());
assert!(companion_message["assistant_item"].is_null());
assert!(companion_message.get("accepted").is_none());
assert!(companion_message.get("diagnostics").is_none());
assert!(companion_message.get("user_item").is_none());
assert!(companion_message.get("assistant_item").is_none());
assert!(!companion_message.to_string().contains("/workspace/demo"));
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
assert_eq!(companion_transcript["total_items"], 0);
assert_eq!(companion_transcript["total"], 0);
let empty_window = get_json(app.clone(), "/api/companion/transcript?start=0&limit=0").await;
assert_eq!(
empty_window,
json!({
"state": "stopped",
"start": 0,
"limit": 0,
"total": 0,
"next": null,
"items": [],
})
);
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
assert!(
@@ -24278,7 +24358,7 @@ mod tests {
let workspace = get_json(app.clone(), "/api/workspace").await;
let workspace_companion = &workspace["extension_points"]["companion_console"];
assert_eq!(workspace_companion["status"], "disabled");
assert_eq!(workspace_companion["status"], "stopped");
assert_eq!(
workspace_companion["diagnostics"][0]["code"],
"companion_disabled"
@@ -24287,12 +24367,13 @@ mod tests {
workspace_companion["note"]
.as_str()
.unwrap()
.contains("auto-start has been removed")
.contains("typed diagnostics")
);
let status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(status["state"], "disabled");
assert_eq!(status["transport"]["completion"], "disabled");
assert_eq!(status["state"], "stopped");
assert_eq!(status["transport"]["mode"], "disabled");
assert_eq!(status["transport"]["available"], false);
assert!(status["worker"].is_null());
let response = post_json(
@@ -24302,13 +24383,14 @@ mod tests {
)
.await;
assert_eq!(response["state"], "rejected");
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
assert!(response["user_item"].is_null());
assert!(response["assistant_item"].is_null());
assert!(response.get("accepted").is_none());
assert!(response.get("diagnostics").is_none());
assert!(response.get("user_item").is_none());
assert!(response.get("assistant_item").is_none());
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
assert_eq!(transcript["state"], "disabled");
assert_eq!(transcript["total_items"], 0);
assert_eq!(transcript["state"], "stopped");
assert_eq!(transcript["total"], 0);
let workers = get_json(app, "/api/workers").await;
assert!(
@@ -25435,7 +25517,8 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
cleanliness: Some("clean".to_string()),
primary_worker_id: None,
occupied_by: Some(WorkingDirectoryOccupancy {
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
runtime_id: "arcadia".to_string(),
worker_id: "worker-opaque-64".to_string(),
display_name: "Coder".to_string(),
linked_at: "2026-08-12T00:00:00Z".to_string(),
}),