diff --git a/Cargo.lock b/Cargo.lock index 28870bb0..45c6d9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,7 +649,6 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "uuid", - "workdir", "workspace-api", ] @@ -6591,6 +6590,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "workspace-api", ] [[package]] @@ -6684,7 +6684,6 @@ dependencies = [ "serde", "serde_json", "ts-rs", - "workdir", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index a17997e7..11b020c3 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -17,7 +17,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync" tokio-tungstenite = { workspace = true } uuid = { workspace = true } workspace-api.workspace = true -workdir = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 8cb67625..064211d6 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -11,7 +11,6 @@ use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary; pub use workspace_api::{ Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, @@ -20,6 +19,11 @@ pub use workspace_api::{ WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, + WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse, + WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse, + WorkingDirectorySummary as BackendWorkingDirectorySummary, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -675,8 +679,8 @@ mod tests { .unwrap() .occupied_by .expect("occupied Workdir"); - assert_eq!(occupied_by.worker.runtime_id, "arcadia"); - assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); + assert_eq!(occupied_by.runtime_id, "arcadia"); + assert_eq!(occupied_by.worker_id, "worker-opaque-64"); let mut stale = payload; stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index b373747f..164f5e8b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -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; diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index db2527c9..8bd23773 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -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 diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index e76fdf76..825e5b16 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -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(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct Wire { - runtime_id: String, - worker_id: String, - display_name: String, - linked_at: String, - } - - let wire = Wire::deserialize(deserializer)?; - Ok(Self { - worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id), - display_name: wire.display_name, - linked_at: wire.linked_at, - }) - } -} - /// Immutable materialization provenance retained by Workspace inventory. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation { pub occupied_by: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkingDirectorySummary { - pub working_directory_id: String, - pub repository_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_selector: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creation_tree: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_selector: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_tree: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observed_at_epoch_seconds: Option, - pub materializer_kind: MaterializerKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cleanup_target: Option, - pub status: WorkingDirectoryStatusKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cleanliness: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub primary_worker_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub occupied_by: Option, -} - -impl WorkingDirectorySummary { - /// Workspace-managed inventory rows carry explicit cleanup authority. - pub fn is_workspace_managed(&self) -> bool { - self.cleanup_target.is_some() - } - - pub fn provenance(&self) -> WorkingDirectoryProvenance { - WorkingDirectoryProvenance { - creation_selector: self.creation_selector.clone(), - creation_ref: self.creation_ref.clone(), - 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, - pub diagnostics: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkingDirectoryDetailResponse { - pub workspace_id: String, - pub runtime_id: String, - pub item: WorkingDirectorySummary, - pub diagnostics: Vec, -} - #[cfg(test)] mod tests { use super::*; @@ -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::(), + std::any::TypeId::of::() ); - assert!( - encoded["items"][0]["occupied_by"] - .get("runtime_worker_id") - .is_none() + assert_eq!( + std::any::TypeId::of::(), + std::any::TypeId::of::() ); - assert!(encoded["items"][1].get("occupied_by").is_none()); - - let mut stale = encoded.clone(); - stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); - assert!(serde_json::from_value::(stale).is_err()); - - let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, response); - - let detail = WorkingDirectoryDetailResponse { - workspace_id: decoded.workspace_id.clone(), - runtime_id: "arcadia".to_string(), - item: decoded.items[0].clone(), - diagnostics: decoded.diagnostics.clone(), - }; - let encoded = serde_json::to_value(&detail).unwrap(); - let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, detail); } } diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 46397ea0..677a7721 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -12,11 +12,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::json; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; -use workdir::workspace::{ - WorkingDirectoryDetailResponse as WorkdirDetailResponse, - WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence, - WorkspaceWorkdirSessionOperationRequest, -}; +use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest}; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, @@ -24,6 +20,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::(WorkspaceRequest::json( + let response = self.execute_json::(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, } -#[derive(Debug, Serialize)] -struct WorkdirCreateRequest { - #[serde(skip_serializing_if = "Option::is_none")] - runtime_id: Option, - repository_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - selector: Option, - operation_id: String, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WorkdirAttachInput { diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index 8002805a..e00b8a58 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -12,7 +12,6 @@ typescript = ["dep:ts-rs"] [dependencies] serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } -workdir.workspace = true [[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"] diff --git a/crates/workspace-api/examples/generate_companion_api_types.rs b/crates/workspace-api/examples/generate_companion_api_types.rs new file mode 100644 index 00000000..8c27410d --- /dev/null +++ b/crates/workspace-api/examples/generate_companion_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::companion_api_typescript()); +} diff --git a/crates/workspace-api/examples/generate_repository_access_types.rs b/crates/workspace-api/examples/generate_repository_access_types.rs new file mode 100644 index 00000000..48cc2665 --- /dev/null +++ b/crates/workspace-api/examples/generate_repository_access_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::repository_access_api_typescript()); +} diff --git a/crates/workspace-api/examples/generate_workdir_api_types.rs b/crates/workspace-api/examples/generate_workdir_api_types.rs new file mode 100644 index 00000000..53371add --- /dev/null +++ b/crates/workspace-api/examples/generate_workdir_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::workdir_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 524e8680..9141b02e 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -5,7 +5,6 @@ //! callers must explicitly construct these Workspace-authoritative resources. use serde::{Deserialize, Serialize}; -use workdir::workspace::WorkingDirectorySummary; /// Provider-neutral classification of an authoritative Repository source. /// @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + pub observed_at_epoch_seconds: Option, + pub materializer_kind: WorkingDirectoryMaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + +impl WorkingDirectorySummary { + /// Workspace-managed inventory rows carry explicit cleanup authority. + pub fn is_workspace_managed(&self) -> bool { + self.cleanup_target.is_some() + } +} + +/// 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, + pub repository_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryDetailResponse { + pub workspace_id: String, + pub runtime_id: String, + pub item: WorkingDirectorySummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCreateResponse { + pub workspace_id: String, + pub runtime_id: String, + pub item: WorkingDirectorySummary, + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ListResponse { pub workspace_id: String, @@ -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, } +/// 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, + pub transport: CompanionTransportSummary, + #[serde(default)] + pub diagnostics: Vec, +} + +/// 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, +} + +/// 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, + pub items: Vec, +} + +#[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::>() + .join("\n\n") + ) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceWorkerDiscoveryPage { pub workers: Vec, @@ -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, } #[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, @@ -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::>() + .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::>() + .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::(payload).is_err()); } + + fn round_trip(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::>(); + 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::(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::(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::() + .replace(";}", "}") + } + + #[test] + fn workdir_create_request_preserves_optional_operation_fields() { + let payload = serde_json::json!({"repository_id": "main"}); + let request = serde_json::from_value::(payload) + .expect("optional create fields may be absent"); + + assert_eq!(request.runtime_id, None); + assert_eq!(request.selector, None); + assert_eq!(request.operation_id, None); + + 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::(stale).is_err()); + + let incomplete = serde_json::json!({ + "runtime_id": "arcadia", + "operation_id": "operation-1" + }); + assert!(serde_json::from_value::(incomplete).is_err()); + } + + #[test] + fn workdir_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::(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() + } } diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 4a605bf9..08271a74 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -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, - pub transport: CompanionTransportSummary, - pub diagnostics: Vec, -} - -#[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, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionMessageResponse { - pub state: CompanionState, - #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_item: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub assistant_item: Option, - pub transcript: CompanionTranscriptProjection, - pub diagnostics: Vec, -} - -#[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, - pub items: Vec, - pub diagnostics: Vec, -} - -#[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(), + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9bdc867b..c71da5ef 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -48,10 +48,7 @@ use workdir::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, }; use workdir::workspace::{ - MaterializerKind, WorkingDirectoryCleanupTarget, - WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, - WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity, - WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy, + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest, }; @@ -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, } -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BrowserWorkingDirectoryCreateRequest { - #[serde(default)] - pub runtime_id: Option, - pub repository_id: String, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub operation_id: Option, -} - #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserWorkerWorkingDirectorySelection { @@ -8816,15 +8806,15 @@ async fn scoped_get_worker_launch_options( fn working_directory_diagnostics( diagnostics: Vec, -) -> Vec { +) -> Vec { diagnostics .into_iter() - .map(|diagnostic| WorkingDirectoryDiagnostic { + .map(|diagnostic| workspace_api::Diagnostic { code: diagnostic.code, severity: match diagnostic.severity { - DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info, - DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning, - DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error, + DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info, + DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning, + DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error, }, message: diagnostic.message, }) @@ -8848,7 +8838,7 @@ async fn scoped_create_runtime_working_directory( State(api): State, AxumPath(path): AxumPath, Json(request): Json, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { create_workspace_working_directory( &api, &path.workspace_id, @@ -8891,7 +8881,7 @@ async fn scoped_create_working_directory( State(api): State, AxumPath(path): AxumPath, Json(request): Json, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { create_workspace_working_directory(&api, &path.workspace_id, None, request).await } @@ -8984,7 +8974,7 @@ async fn create_workspace_working_directory( workspace_id: &str, route_runtime_id: Option<&str>, request: BrowserWorkingDirectoryCreateRequest, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<(StatusCode, Json)> { validate_workspace_scope(api, workspace_id)?; if let (Some(route_runtime_id), Some(request_runtime_id)) = (route_runtime_id, request.runtime_id.as_deref()) @@ -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::>() - .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::>() + .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(), }), diff --git a/web/workspace/deno.json b/web/workspace/deno.json index f883f528..4d632777 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts new file mode 100644 index 00000000..ad25a30e --- /dev/null +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -0,0 +1,64 @@ +// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`. +// Do not edit manually. + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; + +export type WorkspaceWorkerSubject = { + "kind": "runtime_worker"; + runtime_id: string; + worker_id: string; +}; + +export type WorkspaceWorkerDiscoveryItem = { + subject: WorkspaceWorkerSubject; + resource_key: string; + display_name: string; + profile: string | null; + status?: string | null; +}; + +export type CompanionLifecycleState = "idle" | "running" | "stopped"; + +export type CompanionMessageDisposition = "accepted" | "rejected"; + +export type CompanionTransportSummary = { mode: string; available: boolean }; + +export type CompanionStatusResponse = { + state: CompanionLifecycleState; + worker: WorkspaceWorkerDiscoveryItem | null; + transport: CompanionTransportSummary; + diagnostics: Array; +}; + +export type CompanionMessageRequest = { content: string }; + +export type CompanionCancelRequest = { reason?: string | null }; + +export type CompanionMessageResponse = { + state: CompanionMessageDisposition; + message: string; +}; + +export type CompanionTranscriptRole = "user" | "assistant"; + +export type CompanionTranscriptItem = { + sequence: number; + role: CompanionTranscriptRole; + content: string; + created_at: string; +}; + +export type CompanionTranscriptProjection = { + state: CompanionLifecycleState; + start: number; + limit: number; + total: number; + next: number | null; + items: Array; +}; diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts new file mode 100644 index 00000000..fa2f9d44 --- /dev/null +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -0,0 +1,79 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts + +export type RepositorySshCredential = { + credential_id: string; + workspace_id: string; + name: string; + public_key_algorithm: string; + public_key_fingerprint: string; + current_revision: number; + status: string; + created_at: string; + rotated_at: string | null; + referenced_repositories: Array; +}; + +export type CreateRepositorySshCredentialRequest = { + operation_id: string; + credential_id: string; + name: string; + private_key: string; + passphrase: string | null; +}; + +export type RotateRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; + private_key: string; + passphrase: string | null; +}; + +export type DeleteRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositorySshHostTrust = { + host_trust_id: string; + workspace_id: string; + hostname: string; + port: number; + key_algorithm: string; + host_key: string; + fingerprint: string; + current_revision: number; + created_at: string; + updated_at: string; + referenced_repositories: Array; +}; + +export type PutRepositorySshHostTrustRequest = { + operation_id: string; + host_trust_id: string; + hostname: string; + port: number; + host_key: string; + expected_revision: number | null; +}; + +export type DeleteRepositorySshHostTrustRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositoryAccessMode = "read_only" | "read_write"; + +export type RepositorySshAccessBinding = { + repository_id: string; + credential_id: string; + host_trust_id: string; + access: RepositoryAccessMode; +}; + +export type RepositoryAccessProjection = { + workspace_id: string; + config_revision: number; + projection_digest: string; + bindings: Array; +}; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts new file mode 100644 index 00000000..ef6d4082 --- /dev/null +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -0,0 +1,24 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; + +export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree"; + +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; + +export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, }; + +export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; + +export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; + +export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, }; + +export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; + +export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; + +export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 235cd6fb..bd5c51b8 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -113,6 +113,7 @@ export async function loadJson( fetchFn: typeof fetch, path: string, init?: RequestInit, + parse: (value: unknown) => T = (value) => value as T, ): Promise> { try { const response = await fetchFn(path, init); @@ -123,7 +124,8 @@ export async function loadJson( error: text || `${path} request failed (${response.status})`, }; } - return { data: (await response.json()) as T, error: null }; + const payload: unknown = await response.json(); + return { data: parse(payload), error: null }; } catch (error) { return { data: null, diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts new file mode 100644 index 00000000..7908a844 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -0,0 +1,45 @@ +import { error } from "@sveltejs/kit"; +import { RepositoryAccessSchemaError } from "./repository-access.ts"; + +export async function loadRepositoryAccessJson( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, +): Promise { + let response: Response; + try { + response = await fetcher(path, { headers: { accept: "application/json" } }); + } catch { + error(503, { message: "Repository Access is temporarily unavailable." }); + } + + if (response.status === 401 || response.status === 403) { + error(403, { + message: "Repository Access is unavailable for this account.", + }); + } + if (!response.ok) { + error(502, { + message: + `Repository Access request failed with status ${response.status}.`, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + error(502, { + message: "Repository Access returned an invalid JSON response.", + }); + } + + try { + return parse(payload); + } catch (cause) { + if (cause instanceof RepositoryAccessSchemaError) { + error(502, { message: cause.message }); + } + throw cause; + } +} diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts new file mode 100644 index 00000000..0a9f0d5d --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -0,0 +1,228 @@ +import type { + RepositoryAccessProjection, + RepositorySshCredential, + RepositorySshHostTrust, +} from "../../generated/repository-access-api.ts"; + +export class RepositoryAccessSchemaError extends Error { + constructor(path: string, expected: string) { + super( + `Repository Access response schema mismatch at ${path}: expected ${expected}`, + ); + this.name = "RepositoryAccessSchemaError"; + } +} + +export function parseRepositorySshCredentials( + value: unknown, +): RepositorySshCredential[] { + return readArray(value, "credentials").map((entry, index) => + parseRepositorySshCredential(entry, `credentials[${index}]`) + ); +} + +export function parseRepositorySshCredential( + value: unknown, + path = "credential", +): RepositorySshCredential { + const record = readRecord(value, path, [ + "credential_id", + "workspace_id", + "name", + "public_key_algorithm", + "public_key_fingerprint", + "current_revision", + "status", + "created_at", + "rotated_at", + "referenced_repositories", + ]); + readString(record, "credential_id", path); + readString(record, "workspace_id", path); + readString(record, "name", path); + readString(record, "public_key_algorithm", path); + readString(record, "public_key_fingerprint", path); + readRevision(record, "current_revision", path); + readString(record, "status", path); + readString(record, "created_at", path); + readNullableString(record, "rotated_at", path); + readStringArray(record, "referenced_repositories", path); + return record as RepositorySshCredential; +} + +export function parseRepositorySshHostTrusts( + value: unknown, +): RepositorySshHostTrust[] { + return readArray(value, "host_trusts").map((entry, index) => + parseRepositorySshHostTrust(entry, `host_trusts[${index}]`) + ); +} + +export function parseRepositorySshHostTrust( + value: unknown, + path = "host_trust", +): RepositorySshHostTrust { + const record = readRecord(value, path, [ + "host_trust_id", + "workspace_id", + "hostname", + "port", + "key_algorithm", + "host_key", + "fingerprint", + "current_revision", + "created_at", + "updated_at", + "referenced_repositories", + ]); + readString(record, "host_trust_id", path); + readString(record, "workspace_id", path); + readString(record, "hostname", path); + const port = readInteger(record, "port", path); + if (port < 1 || port > 65_535) { + throw new RepositoryAccessSchemaError( + `${path}.port`, + "an integer from 1 to 65535", + ); + } + readString(record, "key_algorithm", path); + readString(record, "host_key", path); + readString(record, "fingerprint", path); + readRevision(record, "current_revision", path); + readString(record, "created_at", path); + readString(record, "updated_at", path); + readStringArray(record, "referenced_repositories", path); + return record as RepositorySshHostTrust; +} + +export function parseRepositoryAccessProjection( + value: unknown, +): RepositoryAccessProjection { + const path = "access_projection"; + const record = readRecord(value, path, [ + "workspace_id", + "config_revision", + "projection_digest", + "bindings", + ]); + readString(record, "workspace_id", path); + readRevision(record, "config_revision", path); + readString(record, "projection_digest", path); + const bindings = readArray(record.bindings, `${path}.bindings`); + bindings.forEach((binding, index) => { + const bindingPath = `${path}.bindings[${index}]`; + const bindingRecord = readRecord(binding, bindingPath, [ + "repository_id", + "credential_id", + "host_trust_id", + "access", + ]); + readString(bindingRecord, "repository_id", bindingPath); + readString(bindingRecord, "credential_id", bindingPath); + readString(bindingRecord, "host_trust_id", bindingPath); + const access = readString(bindingRecord, "access", bindingPath); + if (access !== "read_only" && access !== "read_write") { + throw new RepositoryAccessSchemaError( + `${bindingPath}.access`, + '"read_only" or "read_write"', + ); + } + }); + return record as RepositoryAccessProjection; +} + +function readRecord( + value: unknown, + path: string, + allowedKeys: readonly string[], +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new RepositoryAccessSchemaError(path, "an object"); + } + const record = value as Record; + const unknownKey = Object.keys(record).find((key) => + !allowedKeys.includes(key) + ); + if (unknownKey !== undefined) { + throw new RepositoryAccessSchemaError( + `${path}.${unknownKey}`, + "no unknown field", + ); + } + return record; +} + +function readArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) { + throw new RepositoryAccessSchemaError(path, "an array"); + } + return value; +} + +function readString( + record: Record, + key: string, + path: string, +): string { + const value = record[key]; + if (typeof value !== "string") { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string"); + } + return value; +} + +function readNullableString( + record: Record, + key: string, + path: string, +): string | null { + const value = record[key]; + if (value !== null && typeof value !== "string") { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null"); + } + return value; +} + +function readStringArray( + record: Record, + key: string, + path: string, +): string[] { + const values = readArray(record[key], `${path}.${key}`); + values.forEach((value, index) => { + if (typeof value !== "string") { + throw new RepositoryAccessSchemaError( + `${path}.${key}[${index}]`, + "a string", + ); + } + }); + return values as string[]; +} + +function readInteger( + record: Record, + key: string, + path: string, +): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a safe integer"); + } + return value; +} + +function readRevision( + record: Record, + key: string, + path: string, +): number { + const revision = readInteger(record, key, path); + if (revision < 0) { + throw new RepositoryAccessSchemaError( + `${path}.${key}`, + "a non-negative safe integer", + ); + } + return revision; +} diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts new file mode 100644 index 00000000..4ca39ced --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -0,0 +1,250 @@ +import type { + Diagnostic, + WorkingDirectoryCleanupTarget, + WorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryListResponse, + WorkingDirectoryOccupancy, + WorkingDirectorySummary, +} from "../../generated/workdir-api"; + +const SUMMARY_KEYS = new Set([ + "working_directory_id", + "repository_id", + "creation_selector", + "creation_ref", + "creation_tree", + "current_selector", + "current_ref", + "current_tree", + "observed_at_epoch_seconds", + "materializer_kind", + "cleanup_target", + "status", + "cleanliness", + "primary_worker_id", + "occupied_by", +]); +const CREATE_REQUEST_KEYS = new Set([ + "runtime_id", + "repository_id", + "selector", + "operation_id", +]); +const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]); +const CLEANUP_TARGET_KEYS = new Set([ + "kind", + "working_directory_id", + "repository_id", +]); +const OCCUPANCY_KEYS = new Set([ + "runtime_id", + "worker_id", + "display_name", + "linked_at", +]); + +export function parseWorkingDirectoryListResponse( + value: unknown, +): WorkingDirectoryListResponse { + const record = exactRecord( + value, + new Set(["workspace_id", "items", "diagnostics"]), + "Workdir list response", + ); + return { + workspace_id: stringField(record, "workspace_id"), + items: arrayField(record, "items").map(parseSummary), + diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), + }; +} + +export function parseWorkingDirectoryDetailResponse( + value: unknown, +): WorkingDirectoryDetailResponse { + return parseDetailLike(value, "Workdir detail response"); +} + +export function parseWorkingDirectoryCreateResponse( + value: unknown, +): WorkingDirectoryCreateResponse { + return parseDetailLike(value, "Workdir create response"); +} + +export function validateWorkingDirectoryCreateRequest( + value: unknown, +): WorkingDirectoryCreateRequest { + const record = exactRecord( + value, + CREATE_REQUEST_KEYS, + "Workdir create request", + ); + const request: WorkingDirectoryCreateRequest = { + repository_id: stringField(record, "repository_id"), + }; + assignOptionalString(request, record, "runtime_id"); + assignOptionalString(request, record, "selector"); + assignOptionalString(request, record, "operation_id"); + return request; +} + +function parseDetailLike( + value: unknown, + label: string, +): WorkingDirectoryDetailResponse { + const record = exactRecord( + value, + new Set(["workspace_id", "runtime_id", "item", "diagnostics"]), + label, + ); + return { + workspace_id: stringField(record, "workspace_id"), + runtime_id: stringField(record, "runtime_id"), + item: parseSummary(record.item), + diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), + }; +} + +function parseSummary(value: unknown): WorkingDirectorySummary { + const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary"); + const summary: WorkingDirectorySummary = { + working_directory_id: stringField(record, "working_directory_id"), + repository_id: stringField(record, "repository_id"), + materializer_kind: enumField(record, "materializer_kind", [ + "runtime_git_cache", + "local_git_worktree", + ]), + status: enumField(record, "status", [ + "active", + "cleanup_pending", + "corrupted", + "not_found", + "unknown", + ]), + }; + assignOptionalString(summary, record, "creation_selector"); + assignOptionalString(summary, record, "creation_ref"); + assignOptionalString(summary, record, "creation_tree"); + assignOptionalString(summary, record, "current_selector"); + assignOptionalString(summary, record, "current_ref"); + assignOptionalString(summary, record, "current_tree"); + assignOptionalString(summary, record, "cleanliness"); + assignOptionalString(summary, record, "primary_worker_id"); + if (record.observed_at_epoch_seconds !== undefined) { + const observedAt = record.observed_at_epoch_seconds; + if (observedAt === null) { + summary.observed_at_epoch_seconds = null; + } else { + if (!Number.isSafeInteger(observedAt) || Number(observedAt) < 0) { + throw new Error( + "Workdir summary.observed_at_epoch_seconds must be a non-negative safe integer or null", + ); + } + summary.observed_at_epoch_seconds = Number(observedAt); + } + } + if (record.cleanup_target !== undefined) { + summary.cleanup_target = record.cleanup_target === null + ? null + : parseCleanupTarget(record.cleanup_target); + } + if (record.occupied_by !== undefined) { + summary.occupied_by = record.occupied_by === null + ? null + : parseOccupancy(record.occupied_by); + } + return summary; +} + +function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { + const record = exactRecord( + value, + CLEANUP_TARGET_KEYS, + "Workdir cleanup target", + ); + return { + kind: stringField(record, "kind"), + working_directory_id: stringField(record, "working_directory_id"), + repository_id: stringField(record, "repository_id"), + }; +} + +function parseOccupancy(value: unknown): WorkingDirectoryOccupancy { + const record = exactRecord(value, OCCUPANCY_KEYS, "Workdir occupancy"); + return { + runtime_id: stringField(record, "runtime_id"), + worker_id: stringField(record, "worker_id"), + display_name: stringField(record, "display_name"), + linked_at: stringField(record, "linked_at"), + }; +} + +function parseDiagnostic(value: unknown): Diagnostic { + const record = exactRecord(value, DIAGNOSTIC_KEYS, "Workdir diagnostic"); + return { + code: stringField(record, "code"), + severity: enumField(record, "severity", ["info", "warning", "error"]), + message: stringField(record, "message"), + }; +} + +function exactRecord( + value: unknown, + keys: ReadonlySet, + label: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (!keys.has(key)) { + throw new Error(`${label} contains unknown field ${key}`); + } + } + return record; +} + +function stringField(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${key} must be a non-empty string`); + } + return value; +} + +function arrayField(record: Record, key: string): unknown[] { + const value = record[key]; + if (!Array.isArray(value)) throw new Error(`${key} must be an array`); + return value; +} + +function enumField( + record: Record, + key: string, + values: readonly T[], +): T { + const value = record[key]; + if (typeof value !== "string" || !values.includes(value as T)) { + throw new Error(`${key} has an unsupported value`); + } + return value as T; +} + +function assignOptionalString( + target: T, + source: Record, + key: string, +): void { + const value = source[key]; + if (value === undefined) return; + if (value === null) { + (target as Record)[key] = null; + return; + } + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${key} must be a non-empty string or null`); + } + (target as Record)[key] = value; +} diff --git a/web/workspace/src/lib/workspace/companion/api.test.ts b/web/workspace/src/lib/workspace/companion/api.test.ts new file mode 100644 index 00000000..f37f86f2 --- /dev/null +++ b/web/workspace/src/lib/workspace/companion/api.test.ts @@ -0,0 +1,195 @@ +import { + parseCompanionMessageResponse, + parseCompanionStatusResponse, + parseCompanionTranscriptProjection, +} from "./api.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +function assertThrows(fn: () => unknown, message: string): void { + try { + fn(); + } catch { + return; + } + throw new Error(message); +} + +const worker = { + subject: { + kind: "runtime_worker", + runtime_id: "arcadia", + worker_id: "worker-7", + }, + resource_key: "W-7", + display_name: "Companion", + profile: "builtin:companion", + status: "idle", +}; + +Deno.test("Companion status boundary accepts every public lifecycle state", () => { + for (const state of ["idle", "running", "stopped"] as const) { + const parsed = parseCompanionStatusResponse({ + state, + worker, + transport: { + mode: "worker_runtime", + available: state !== "stopped", + }, + diagnostics: [], + }); + assertEquals(parsed.state, state); + assertEquals(parsed.worker?.subject, worker.subject); + assertEquals(parsed.worker?.resource_key, "W-7"); + assertEquals(parsed.worker?.display_name, "Companion"); + } +}); + +Deno.test("Companion message boundary accepts accepted and rejected fixtures", () => { + assertEquals( + parseCompanionMessageResponse({ + state: "accepted", + message: "accepted", + }), + { state: "accepted", message: "accepted" }, + ); + assertEquals( + parseCompanionMessageResponse({ + state: "rejected", + message: "rejected", + }), + { state: "rejected", message: "rejected" }, + ); + assertThrows( + () => + parseCompanionMessageResponse({ + state: "accepted", + message: "accepted", + provider_request_id: "private-request", + }), + "private message response fields should be rejected", + ); +}); + +Deno.test("Companion transcript boundary accepts only bounded user-visible items", () => { + const fixture = { + state: "idle" as const, + start: 0, + limit: 2, + total: 2, + next: null, + items: [ + { + sequence: 1, + role: "user" as const, + content: "hello", + created_at: "2026-08-31T00:00:00Z", + }, + { + sequence: 2, + role: "assistant" as const, + content: "hi", + created_at: "2026-08-31T00:00:01Z", + }, + ], + }; + assertEquals(parseCompanionTranscriptProjection(fixture), fixture); + assertEquals( + parseCompanionTranscriptProjection({ + state: "stopped", + start: 0, + limit: 0, + total: 0, + next: null, + items: [], + }), + { + state: "stopped", + start: 0, + limit: 0, + total: 0, + next: null, + items: [], + }, + ); + + assertThrows( + () => + parseCompanionTranscriptProjection({ + ...fixture, + items: [...fixture.items, fixture.items[0]], + }), + "items beyond the declared limit should be rejected", + ); +}); + +Deno.test("Companion transcript boundary rejects system and private fields", () => { + const base = { + state: "idle", + start: 0, + limit: 1, + total: 1, + next: null, + }; + assertThrows( + () => + parseCompanionTranscriptProjection({ + ...base, + items: [{ + sequence: 1, + role: "system", + content: "raw system prompt", + created_at: "2026-08-31T00:00:00Z", + }], + }), + "system transcript content should be rejected", + ); + assertThrows( + () => + parseCompanionTranscriptProjection({ + ...base, + items: [{ + sequence: 1, + role: "assistant", + content: "visible", + created_at: "2026-08-31T00:00:00Z", + reasoning: "hidden", + credential: "secret", + provider_session_id: "private-session", + }], + }), + "private transcript fields should be rejected", + ); +}); + +Deno.test("Companion status boundary does not use display_name as Worker identity", () => { + const fixture = { + state: "idle", + worker: { ...worker, display_name: "W-999" }, + transport: { mode: "worker_runtime", available: true }, + diagnostics: [], + }; + const parsed = parseCompanionStatusResponse(fixture); + assertEquals(parsed.worker?.subject, worker.subject); + assertEquals(parsed.worker?.resource_key, "W-7"); + assertEquals(parsed.worker?.display_name, "W-999"); + + assertThrows( + () => + parseCompanionStatusResponse({ + ...fixture, + worker: { ...worker, resource_key: "Companion" }, + }), + "display names must not substitute for canonical Worker resource keys", + ); +}); diff --git a/web/workspace/src/lib/workspace/companion/api.ts b/web/workspace/src/lib/workspace/companion/api.ts new file mode 100644 index 00000000..ba7584aa --- /dev/null +++ b/web/workspace/src/lib/workspace/companion/api.ts @@ -0,0 +1,225 @@ +import type { + CompanionLifecycleState, + CompanionMessageDisposition, + CompanionMessageResponse, + CompanionStatusResponse, + CompanionTranscriptItem, + CompanionTranscriptProjection, + Diagnostic, + DiagnosticSeverity, + WorkspaceWorkerDiscoveryItem, + WorkspaceWorkerSubject, +} from "$lib/generated/companion-api"; + +const MAX_TRANSCRIPT_ITEMS = 200; +const MAX_DIAGNOSTICS = 100; +const MAX_CONTENT_LENGTH = 64 * 1024; + +export function parseCompanionStatusResponse( + value: unknown, +): CompanionStatusResponse { + const record = strictRecord(value, [ + "state", + "worker", + "transport", + "diagnostics", + ]); + const transport = strictRecord(record.transport, ["mode", "available"]); + const diagnostics = boundedArray(record.diagnostics, MAX_DIAGNOSTICS).map( + parseDiagnostic, + ); + + return { + state: lifecycleState(record.state), + worker: record.worker === null ? null : parseWorker(record.worker), + transport: { + mode: boundedString(transport.mode, 100), + available: booleanValue(transport.available), + }, + diagnostics, + }; +} + +export function parseCompanionMessageResponse( + value: unknown, +): CompanionMessageResponse { + const record = strictRecord(value, ["state", "message"]); + return { + state: messageDisposition(record.state), + message: boundedString(record.message, 8 * 1024), + }; +} + +export function parseCompanionTranscriptProjection( + value: unknown, +): CompanionTranscriptProjection { + const record = strictRecord(value, [ + "state", + "start", + "limit", + "total", + "next", + "items", + ]); + const start = boundedInteger(record.start); + const limit = boundedInteger(record.limit); + if (limit > MAX_TRANSCRIPT_ITEMS) { + throw new TypeError("Companion transcript limit is out of range"); + } + const items = boundedArray(record.items, limit).map(parseTranscriptItem); + const total = boundedInteger(record.total); + if (total < items.length) { + throw new TypeError("Companion transcript total is smaller than its items"); + } + const next = record.next === null ? null : boundedInteger(record.next); + + return { + state: lifecycleState(record.state), + start, + limit, + total, + next, + items, + }; +} + +function parseTranscriptItem(value: unknown): CompanionTranscriptItem { + const record = strictRecord(value, [ + "sequence", + "role", + "content", + "created_at", + ]); + const role = record.role; + if (role !== "user" && role !== "assistant") { + throw new TypeError("Companion transcript role is not user-visible"); + } + return { + sequence: boundedInteger(record.sequence), + role, + content: boundedString(record.content, MAX_CONTENT_LENGTH), + created_at: boundedString(record.created_at, 100), + }; +} + +function parseWorker(value: unknown): WorkspaceWorkerDiscoveryItem { + const record = strictRecord(value, [ + "subject", + "resource_key", + "display_name", + "profile", + "status", + ], ["status"]); + const subject = parseWorkerSubject(record.subject); + const resourceKey = boundedString(record.resource_key, 100); + if (!/^W-[1-9][0-9]*$/.test(resourceKey)) { + throw new TypeError("Companion worker resource_key is not canonical"); + } + return { + subject, + resource_key: resourceKey, + display_name: boundedString(record.display_name, 256), + profile: nullableString(record.profile, 256), + ...(record.status === undefined + ? {} + : { status: nullableString(record.status, 100) }), + }; +} + +function parseWorkerSubject(value: unknown): WorkspaceWorkerSubject { + const record = strictRecord(value, ["kind", "runtime_id", "worker_id"]); + if (record.kind !== "runtime_worker") { + throw new TypeError("Companion worker subject kind is invalid"); + } + return { + kind: "runtime_worker", + runtime_id: boundedString(record.runtime_id, 256), + worker_id: boundedString(record.worker_id, 256), + }; +} + +function parseDiagnostic(value: unknown): Diagnostic { + const record = strictRecord(value, ["code", "severity", "message"]); + return { + code: boundedString(record.code, 256), + severity: diagnosticSeverity(record.severity), + message: boundedString(record.message, 4 * 1024), + }; +} + +function lifecycleState(value: unknown): CompanionLifecycleState { + if (value !== "idle" && value !== "running" && value !== "stopped") { + throw new TypeError("Companion lifecycle state is invalid"); + } + return value; +} + +function messageDisposition(value: unknown): CompanionMessageDisposition { + if (value !== "accepted" && value !== "rejected") { + throw new TypeError("Companion message disposition is invalid"); + } + return value; +} + +function diagnosticSeverity(value: unknown): DiagnosticSeverity { + if (value !== "info" && value !== "warning" && value !== "error") { + throw new TypeError("Companion diagnostic severity is invalid"); + } + return value; +} + +function strictRecord( + value: unknown, + keys: readonly string[], + optionalKeys: readonly string[] = [], +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Companion API value is not an object"); + } + const record = value as Record; + const allowed = new Set(keys); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new TypeError(`Companion API field is not public: ${key}`); + } + } + const optional = new Set(optionalKeys); + for (const key of keys) { + if (!optional.has(key) && !(key in record)) { + throw new TypeError(`Companion API field is missing: ${key}`); + } + } + return record; +} + +function boundedArray(value: unknown, limit: number): unknown[] { + if (!Array.isArray(value) || value.length > limit) { + throw new TypeError("Companion API array is invalid or exceeds its limit"); + } + return value; +} + +function boundedString(value: unknown, limit: number): string { + if (typeof value !== "string" || value.length > limit) { + throw new TypeError("Companion API string is invalid or exceeds its limit"); + } + return value; +} + +function nullableString(value: unknown, limit: number): string | null { + return value === null ? null : boundedString(value, limit); +} + +function booleanValue(value: unknown): boolean { + if (typeof value !== "boolean") { + throw new TypeError("Companion API value is not a boolean"); + } + return value; +} + +function boundedInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError("Companion API value is not a non-negative integer"); + } + return value as number; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index c5d5a37c..51f4c34f 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -1,3 +1,11 @@ +import type { + WorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryListResponse, + WorkingDirectoryOccupancy, + WorkingDirectorySummary, +} from "$lib/generated/workdir-api"; import type { Event as PodProtocolEvent, Method as PodProtocolMethod, @@ -14,7 +22,17 @@ import type { WorkspaceResponse as SharedWorkspaceResponse, } from "$lib/workspace/api/workspace-model"; -export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; +export type { + PodProtocolEvent, + PodProtocolMethod, + PodProtocolSegment, + WorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryListResponse, + WorkingDirectoryOccupancy, + WorkingDirectorySummary, +}; export type WorkspaceResponse = SharedWorkspaceResponse; export type Diagnostic = { @@ -105,44 +123,6 @@ export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; }; -export type WorkingDirectoryOccupancy = { - runtime_id: string; - worker_id: string; - display_name: string; - linked_at: string; -}; - -export type WorkingDirectorySummary = { - working_directory_id: string; - repository_id: string; - creation_selector?: string | null; - creation_ref?: string | null; - current_selector?: string | null; - current_ref?: string | null; - materializer_kind: string; - status: string; - cleanliness?: string | null; - primary_worker_id?: string | null; - occupied_by?: WorkingDirectoryOccupancy | null; - cleanup_target: { - kind: string; - working_directory_id: string; - repository_id: string; - }; -}; - -export type BrowserWorkingDirectoryCreateResponse = { - workspace_id: string; - item: WorkingDirectorySummary; - diagnostics: Diagnostic[]; -}; - -export type BrowserWorkingDirectoryListResponse = { - workspace_id: string; - items: WorkingDirectorySummary[]; - diagnostics: Diagnostic[]; -}; - export type CleanupTargetKind = | "worker_delete" | "workdir_clean_cleanup" @@ -211,12 +191,6 @@ export type BrowserWorkerWorkingDirectorySelection = { relative_cwd?: string | null; }; -export type BrowserWorkingDirectoryCreateRequest = { - runtime_id: string; - repository_id: string; - selector?: string | null; -}; - export type WorkerLaunchOptionsResponse = { workspace_id: string; runtimes: WorkerLaunchRuntimeOption[]; @@ -385,56 +359,15 @@ export type ObjectiveListResponse = { record_authority: string; }; -export type CompanionState = - | "ready" - | "busy" - | "error" - | "timeout" - | "cancelled" - | "accepted" - | "rejected"; - -export type CompanionTransportSummary = { - kind: string; - completion: string; - limitation: string; -}; - -export type CompanionStatusResponse = { - state: CompanionState; - worker?: Worker | null; - transport: CompanionTransportSummary; - diagnostics: Diagnostic[]; -}; - -export type CompanionTranscriptItem = { - sequence: number; - role: "user" | "assistant" | "system" | string; - content: string; - created_at: string; - source: string; - status: string; -}; - -export type CompanionTranscriptProjection = { - state: CompanionState; - start: number; - limit: number; - total_items: number; - next_start?: number | null; - items: CompanionTranscriptItem[]; - diagnostics: Diagnostic[]; -}; - -export type CompanionMessageRequest = { - content: string; -}; - -export type CompanionMessageResponse = { - state: CompanionState; - worker?: Worker | null; - user_item?: CompanionTranscriptItem | null; - assistant_item?: CompanionTranscriptItem | null; - transcript: CompanionTranscriptProjection; - diagnostics: Diagnostic[]; -}; +export type { + CompanionCancelRequest, + CompanionLifecycleState, + CompanionMessageDisposition, + CompanionMessageRequest, + CompanionMessageResponse, + CompanionStatusResponse, + CompanionTranscriptItem, + CompanionTranscriptProjection, + CompanionTranscriptRole, + CompanionTransportSummary, +} from "$lib/generated/companion-api"; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index 3852a6fc..f5603349 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -1,11 +1,24 @@