diff --git a/Cargo.lock b/Cargo.lock index d87247cf..c597c3be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,7 +650,6 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "uuid", - "workdir", "workspace-api", ] @@ -6594,6 +6593,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "workspace-api", ] [[package]] @@ -6687,7 +6687,6 @@ dependencies = [ "serde", "serde_json", "ts-rs", - "workdir", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index e3475d33..bc3aa6bf 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -18,7 +18,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 78f61833..01776566 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -5,7 +5,6 @@ use std::fmt; 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, @@ -14,6 +13,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)] @@ -526,8 +530,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/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 94ce5afb..244025ca 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -2,19 +2,16 @@ use crate::{BackendApiClient, BackendApiClientError}; use reqwest::Method; use serde::{Deserialize, Serialize}; use std::fmt; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; +use workspace_api::{ + WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord, + WorkspaceSummary, +}; const DEFAULT_WORKSPACE_LIMIT: usize = 200; -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct BackendWorkspace { - pub workspace_id: String, - pub owner_account_id: Option, - pub display_name: String, - pub state: String, - pub created_at: String, - pub updated_at: String, -} +pub type BackendWorkspace = WorkspaceSummary; +pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; +pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -32,30 +29,6 @@ pub struct CreateBackendWorkspaceRepository { pub default_ref: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CreateBackendWorkspaceResponse { - pub workspace: BackendWorkspace, - pub repository: CreateBackendWorkspaceRepositoryRecord, - pub config_revision: u64, - pub request_fingerprint: String, - pub replayed: bool, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CreateBackendWorkspaceRepositoryRecord { - pub workspace_id: String, - pub repository_id: String, - pub name: String, - pub kind: String, - pub provider: Option, - pub source: RepositorySource, - pub default_ref: Option, - pub source_revision: u64, - pub source_fingerprint: String, - pub observed_status: RepositoryObservedStatus, - pub observed_at: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendWorkspaceCatalogTarget { pub base_url: String, @@ -118,7 +91,7 @@ async fn list_backend_workspaces_with_client( .send() .await?; client.check_status(response.status())?; - Ok(response.json::>().await?) + Ok(response.json::().await?.0) } pub async fn create_backend_workspace( diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index b3cea8de..3c2e8a51 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -39,5 +39,10 @@ pub use target::{ StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, }; -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 2647bded..bd138eb7 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, @@ -426,9 +429,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)?, @@ -707,16 +710,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 ab4ef23e..e00b8a58 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -12,7 +12,22 @@ 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" +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_typescript.rs b/crates/workspace-api/examples/generate_typescript.rs new file mode 100644 index 00000000..9b4e949d --- /dev/null +++ b/crates/workspace-api/examples/generate_typescript.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::catalog_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 1fd7fa8c..a30c1c5a 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -5,13 +5,13 @@ //! callers must explicitly construct these Workspace-authoritative resources. use serde::{Deserialize, Serialize}; -use workdir::workspace::WorkingDirectorySummary; /// Provider-neutral classification of an authoritative Repository source. /// /// Local paths remain distinct from network Git transports so callers cannot /// accidentally treat an unmaterialized remote as a server-local filesystem path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositorySourceKind { LocalPath, @@ -55,6 +55,7 @@ impl RepositorySourceKind { /// Stable Repository source identity stored by Workspace authority. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RepositorySource { pub kind: RepositorySourceKind, /// Canonical source representation. This is an absolute local path for @@ -84,6 +85,7 @@ pub struct CreateWorkspaceRepositoryResponse { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositoryObservedStatus { Unverified, @@ -110,10 +112,224 @@ impl RepositoryObservedStatus { } } +/// Public Workspace catalog item returned by `GET /api/workspaces`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSummary { + pub workspace_id: String, + pub owner_account_id: Option, + pub display_name: String, + pub state: String, + pub created_at: String, + pub updated_at: String, +} + +/// Public response returned by `GET /api/workspaces`. +/// +/// The transparent newtype keeps the established top-level JSON array while making the +/// complete list response a named cross-crate and generated-TypeScript authority. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct WorkspaceCatalogListResponse(pub Vec); + +/// Public Repository record embedded in Workspace creation responses. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRepositoryRecord { + pub workspace_id: String, + pub repository_id: String, + pub name: String, + pub kind: String, + pub provider: Option, + pub source: RepositorySource, + pub default_ref: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + pub observed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Response returned after atomically creating a Workspace and its first Repository. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCreateResponse { + pub workspace: WorkspaceSummary, + pub repository: WorkspaceRepositoryRecord, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub config_revision: u64, + pub request_fingerprint: String, + pub replayed: bool, +} + +/// Browser authentication configuration exposed by the scoped Workspace summary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum WorkspaceAuthConfig { + Passkey { + rp_id: String, + origin: String, + public_base_url: String, + cookie_name: String, + }, +} + +/// Backend-authoritative permissions for the current Workspace actor. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspacePermissionSummary { + pub manage_repositories: bool, + pub manage_secrets: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceExtensionPointState { + pub status: String, + pub note: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceExtensionPoints { + pub store: String, + pub event_stream: WorkspaceExtensionPointState, + pub host_worker_bridge: WorkspaceExtensionPointState, + pub companion_console: WorkspaceExtensionPointState, +} + +/// Scoped Workspace metadata and current-actor permission projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceResponse { + pub workspace_id: String, + pub display_name: String, + pub record_authority: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub schema_version: i64, + pub auth: WorkspaceAuthConfig, + pub permissions: WorkspacePermissionSummary, + pub extension_points: WorkspaceExtensionPoints, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryDiagnostic { + pub severity: String, + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitRemoteSummary { + pub name: String, + pub fetch_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitRepositorySummary { + pub status: String, + pub head: Option, + pub branch: Option, + pub dirty: bool, + pub remotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySummary { + pub id: String, + pub display_name: String, + pub kind: String, + pub provider: String, + pub source: RepositorySource, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub source_revision: u64, + pub source_fingerprint: String, + pub observed_status: RepositoryObservedStatus, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub observed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub default_selector: Option, + pub record_authority: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub git: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub diagnostics: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GitCommitSummary { + pub hash: String, + pub short_hash: String, + pub summary: String, + pub author_name: String, + pub author_email: String, + pub author_date: String, + pub parents: Vec, + pub refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryDetailResponse { + pub workspace_id: String, + pub item: RepositorySummary, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositoryLogResponse { + pub workspace_id: String, + pub repository_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub default_selector: Option, + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DiagnosticSeverity { Info, @@ -122,12 +338,166 @@ 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, @@ -346,6 +716,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 { @@ -358,6 +729,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, @@ -367,6 +739,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, @@ -452,6 +955,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, @@ -459,6 +963,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, @@ -468,6 +973,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, @@ -479,9 +985,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)] @@ -489,14 +997,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, @@ -506,6 +1017,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, @@ -514,6 +1026,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, @@ -522,17 +1035,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, @@ -540,6 +1058,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, @@ -550,18 +1069,234 @@ 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, } +#[cfg(feature = "typescript")] +pub fn catalog_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + WorkspaceSummary::decl(&config), + WorkspaceCatalogListResponse::decl(&config), + WorkspaceRepositoryRecord::decl(&config), + WorkspaceCreateResponse::decl(&config), + WorkspaceAuthConfig::decl(&config), + WorkspacePermissionSummary::decl(&config), + DiagnosticSeverity::decl(&config), + Diagnostic::decl(&config), + WorkspaceExtensionPointState::decl(&config), + WorkspaceExtensionPoints::decl(&config), + WorkspaceResponse::decl(&config), + RepositorySourceKind::decl(&config), + RepositorySource::decl(&config), + RepositoryObservedStatus::decl(&config), + RepositoryDiagnostic::decl(&config), + GitRemoteSummary::decl(&config), + GitRepositorySummary::decl(&config), + RepositorySummary::decl(&config), + GitCommitSummary::decl(&config), + RepositoryListResponse::decl(&config), + RepositoryDetailResponse::decl(&config), + RepositoryLogResponse::decl(&config), + ] + .map(|declaration| format!("export {declaration}")); + + format!( + "// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n", + declarations.join("\n\n") + ) +} + +#[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::*; + #[test] + fn workspace_and_repository_response_shapes_round_trip() { + let workspace = serde_json::json!({ + "workspace_id": "workspace-test", + "display_name": "Test", + "record_authority": "workspace-control-plane", + "schema_version": 46, + "auth": {"Passkey": { + "rp_id": "example.test", + "origin": "https://example.test", + "public_base_url": "https://example.test", + "cookie_name": "yoi_session" + }}, + "permissions": { + "manage_repositories": true, + "manage_secrets": true + }, + "extension_points": { + "store": "sqlite", + "event_stream": {"status": "available", "note": "ready", "diagnostics": []}, + "host_worker_bridge": {"status": "available", "note": "ready", "diagnostics": []}, + "companion_console": {"status": "available", "note": "ready", "diagnostics": []} + } + }); + let parsed: WorkspaceResponse = serde_json::from_value(workspace.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), workspace); + + let catalog = serde_json::json!([{ + "workspace_id": "workspace-test", + "owner_account_id": "user-test", + "display_name": "Test", + "state": "active", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }]); + let parsed: WorkspaceCatalogListResponse = serde_json::from_value(catalog.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), catalog); + + let repositories = serde_json::json!({ + "workspace_id": "workspace-test", + "items": [{ + "id": "main", + "display_name": "main", + "kind": "git", + "provider": "git", + "source": {"kind": "local_path", "uri": "/srv/project"}, + "source_revision": 1, + "source_fingerprint": "sha256:test", + "observed_status": "ready", + "record_authority": "workspace-control-plane" + }], + "source": "workspace-control-plane", + "diagnostics": [] + }); + let parsed: RepositoryListResponse = serde_json::from_value(repositories.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), repositories); + } + + #[test] + fn repository_response_rejects_stale_field_aliases() { + let stale = serde_json::json!({ + "workspace_id": "workspace-test", + "items": [{ + "repository_id": "main", + "display_name": "main", + "kind": "git", + "provider": "git", + "source": {"kind": "local_path", "uri": "/srv/project"}, + "source_revision": 1, + "source_fingerprint": "sha256:test", + "observed_status": "ready", + "record_authority": "workspace-control-plane" + }], + "source": "workspace-control-plane", + "diagnostics": [] + }); + + assert!(serde_json::from_value::(stale).is_err()); + } + + #[cfg(feature = "typescript")] + #[test] + fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { + let output = catalog_typescript(); + assert!( + output.contains("export type WorkspaceCatalogListResponse = Array") + ); + assert!(output.contains("export type WorkspaceResponse =")); + assert!(output.contains("permissions: WorkspacePermissionSummary")); + assert!(output.contains("export type RepositoryListResponse =")); + assert!(output.contains("items: Array")); + assert!(output.contains("observed_at?: string | null")); + assert!(!output.contains("repository_id: string, display_name")); + } + #[test] fn worker_resource_key_is_required() { let payload = serde_json::json!({ @@ -581,4 +1316,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/lib.rs b/crates/workspace-server/src/lib.rs index 6e268cef..30b301d6 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -42,19 +42,19 @@ pub use authority::{ pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; -pub use repositories::{ - ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, - RepositoryLogRead, RepositoryRegistryReader, RepositorySummary, -}; +pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader}; pub use server::{ AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, build_workspace_server_router, serve, serve_workspace_catalog, }; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; -pub use workspace_catalog::{ - InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, +pub use workspace_api::{ + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, WorkspaceCreateResponse, }; +pub use workspace_catalog::{ + InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult, +}; use worker_runtime::identity::RuntimeWorkerRef; diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 8e8b5d80..7e7f9160 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -4,8 +4,10 @@ use std::{ process::Command, }; -use serde::{Deserialize, Serialize}; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; +use workspace_api::{ + Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, + RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary, +}; pub type RepositoryId = String; pub type RepositorySelector = String; @@ -24,74 +26,19 @@ pub struct ConfiguredRepository { pub default_selector: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepositorySummary { - pub id: RepositoryId, - pub display_name: String, - pub kind: String, - pub provider: String, - pub source: RepositorySource, - pub source_revision: u64, - pub source_fingerprint: String, - pub observed_status: RepositoryObservedStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub observed_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_selector: Option, - pub record_authority: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRepositorySummary { - pub status: String, - pub head: Option, - pub branch: Option, - pub dirty: bool, - pub remotes: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRemoteSummary { - pub name: String, - pub fetch_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepositoryDiagnostic { - pub severity: String, - pub code: String, - pub message: String, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositoryListProjection { pub items: Vec, - pub diagnostics: Vec, + pub diagnostics: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositoryLogRead { pub repository_id: RepositoryId, pub default_selector: Option, pub limit: usize, pub commits: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitCommitSummary { - pub hash: String, - pub short_hash: String, - pub summary: String, - pub author_name: String, - pub author_email: String, - pub author_date: String, - pub parents: Vec, - pub refs: Vec, + pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -131,8 +78,8 @@ impl RepositoryRegistryReader { if self.repositories.is_empty() { return RepositoryListProjection { items: Vec::new(), - diagnostics: vec![RepositoryDiagnostic { - severity: "warning".to_string(), + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Warning, code: "repository_config_empty".to_string(), message: "No repositories are configured for this workspace backend." .to_string(), @@ -177,8 +124,8 @@ impl RepositoryRegistryReader { let commits = match self.git_log(repository, limit) { Ok(commits) => commits, Err(message) => { - diagnostics.push(RepositoryDiagnostic { - severity: "warning".to_string(), + diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Warning, code: "repository_git_log_unavailable".to_string(), message, }); @@ -379,7 +326,7 @@ impl RepositoryRegistryReader { default_selector: repository.default_selector.clone(), record_authority: "workspace-control-plane".to_string(), git, - diagnostics, + diagnostics: (!diagnostics.is_empty()).then_some(diagnostics), } } @@ -675,7 +622,10 @@ mod tests { RepositoryObservedStatus::Unverified ); assert!(summary.git.is_none()); - assert_eq!(summary.diagnostics[0].code, "repository_source_unverified"); + assert_eq!( + summary.diagnostics.as_ref().unwrap()[0].code, + "repository_source_unverified" + ); let repository = reader.merge_repository("remote").unwrap(); let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e1bc94c1..6a7c7d13 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, }; @@ -65,9 +62,17 @@ use workspace_api::{ DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, + RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource, WorkspaceWorkerDiscoveryItem, + 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, }; @@ -116,7 +121,7 @@ use crate::records::{ }; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, - RepositoryRegistryReader, RepositorySummary, + RepositoryRegistryReader, }; use crate::repository_access::{ RepositoryAccessConfigSchemaProvider, RepositorySecretService, @@ -152,17 +157,7 @@ use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum AuthConfig { - /// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions; - /// CLI/TUI auth uses API tokens obtained through the device login flow. - Passkey { - rp_id: String, - origin: String, - public_base_url: String, - cookie_name: String, - }, -} +pub use workspace_api::WorkspaceAuthConfig as AuthConfig; #[derive(Clone)] pub struct ServerConfig { @@ -989,7 +984,9 @@ async fn list_server_workspaces( let owner = match resolve_server_actor(&api, &headers).await { Ok(Some(actor)) => Some(actor.account_id), Ok(None) => match api.catalog.is_empty() { - Ok(true) => return Json(Vec::::new()).into_response(), + Ok(true) => { + return Json(WorkspaceCatalogListResponse(Vec::new())).into_response(); + } Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), Err(error) => return server_error_response(error), }, @@ -999,7 +996,10 @@ async fn list_server_workspaces( .catalog .list(owner.as_deref(), query.limit.unwrap_or(100)) { - Ok(workspaces) => Json(workspaces).into_response(), + Ok(workspaces) => Json(WorkspaceCatalogListResponse( + workspaces.into_iter().map(workspace_summary).collect(), + )) + .into_response(), Err(error) => server_error_response(error), } } @@ -1031,7 +1031,48 @@ async fn create_server_workspace( } else { StatusCode::CREATED }; - (status, Json(created)).into_response() + (status, Json(workspace_create_response(created))).into_response() +} + +fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary { + WorkspaceSummary { + workspace_id: record.workspace_id, + owner_account_id: record.owner_account_id, + display_name: record.display_name, + state: record.state, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryRecord { + WorkspaceRepositoryRecord { + workspace_id: record.workspace_id, + repository_id: record.repository_id, + name: record.name, + kind: record.kind, + provider: record.provider, + source: record.source, + default_ref: record.default_ref, + source_revision: record.source_revision, + source_fingerprint: record.source_fingerprint, + observed_status: record.observed_status, + observed_at: record.observed_at, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn workspace_create_response( + created: crate::workspace_catalog::WorkspaceCreateResult, +) -> WorkspaceCreateResponse { + WorkspaceCreateResponse { + workspace: workspace_summary(created.workspace), + repository: workspace_repository_record(created.repository), + config_revision: created.config_revision, + request_fingerprint: created.request_fingerprint, + replayed: created.replayed, + } } async fn resolve_server_actor( @@ -2743,31 +2784,6 @@ pub async fn serve( Ok(()) } -#[derive(Debug, Serialize, Deserialize)] -pub struct WorkspaceResponse { - pub workspace_id: String, - pub display_name: String, - pub record_authority: String, - pub schema_version: i64, - pub auth: AuthConfig, - pub extension_points: ExtensionPoints, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ExtensionPoints { - pub store: String, - pub event_stream: ExtensionPointState, - pub host_worker_bridge: ExtensionPointState, - pub companion_console: ExtensionPointState, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ExtensionPointState { - pub status: String, - pub note: String, - pub diagnostics: Vec, -} - #[derive(Debug, Serialize, Deserialize)] pub struct ListResponse { pub workspace_id: String, @@ -3003,18 +3019,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 { @@ -3071,32 +3075,6 @@ pub struct BrowserCreateWorkerResponse { pub diagnostics: Vec, } -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryListResponse { - pub workspace_id: String, - pub items: Vec, - pub source: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryDetailResponse { - pub workspace_id: String, - pub item: RepositorySummary, - pub source: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryLogResponse { - pub workspace_id: String, - pub repository_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_selector: Option, - pub limit: usize, - pub items: Vec, - pub diagnostics: Vec, -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, @@ -3361,11 +3339,12 @@ async fn scoped_get_flow( } async fn scoped_get_workspace( + headers: HeaderMap, State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - get_workspace(State(api)).await + get_workspace(headers, State(api)).await } async fn scoped_get_workspace_settings( @@ -8947,15 +8926,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, }) @@ -8979,7 +8958,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, @@ -9022,7 +9001,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 } @@ -9115,7 +9094,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()) @@ -9278,7 +9257,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 @@ -9457,7 +9446,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, @@ -11171,9 +11160,20 @@ async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult) -> ApiResult> { +async fn get_workspace( + headers: HeaderMap, + State(api): State, +) -> ApiResult> { + let cookie_name = auth_public_config(&api.config).cookie_name; + let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?; let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; + let is_owner = actor.as_ref().is_some_and(|actor| { + stored + .as_ref() + .and_then(|workspace| workspace.owner_account_id.as_ref()) + == Some(&actor.account_id) + }); let display_name = stored .as_ref() .map(|record| record.display_name.clone()) @@ -11186,14 +11186,18 @@ async fn get_workspace(State(api): State) -> ApiResult) -> ApiResult ExtensionPointState { - 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." - ), +fn companion_console_extension_point( + status: &CompanionStatusResponse, +) -> WorkspaceExtensionPointState { + 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}.") }; - ExtensionPointState { - status: completion, + WorkspaceExtensionPointState { + status: extension_status, note, diagnostics: status.diagnostics.clone(), } @@ -11313,7 +11317,7 @@ async fn list_repositories( workspace_id: api.config.workspace_id, items, source: "workspace-control-plane".to_string(), - diagnostics: repository_diagnostics(diagnostics), + diagnostics, })) } @@ -11350,7 +11354,7 @@ async fn repository_log( default_selector, limit, items: commits, - diagnostics: repository_diagnostics(diagnostics), + diagnostics, })) } @@ -14261,7 +14265,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(), }); @@ -14610,7 +14615,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(), }); @@ -15057,23 +15063,6 @@ fn sanitize_backend_error(message: &str) -> String { message.to_string() } -fn repository_diagnostics( - diagnostics: Vec, -) -> Vec { - diagnostics - .into_iter() - .map(|diagnostic| RuntimeDiagnostic { - code: diagnostic.code, - severity: match diagnostic.severity.as_str() { - "error" => DiagnosticSeverity::Error, - "warning" => DiagnosticSeverity::Warning, - _ => DiagnosticSeverity::Info, - }, - message: diagnostic.message, - }) - .collect() -} - fn repository_lookup(result: std::result::Result) -> ApiResult { result.map_err(|error| match error { RepositoryLookupError::UnknownRepository { id } => { @@ -16110,7 +16099,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"); @@ -17315,10 +17305,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"); @@ -17928,6 +17916,12 @@ mod tests { .await .unwrap(); assert_eq!(authenticated_legacy.status(), StatusCode::OK); + let workspace_body = to_bytes(authenticated_legacy.into_body(), usize::MAX) + .await + .unwrap(); + let typed_workspace: workspace_api::WorkspaceResponse = + serde_json::from_slice(&workspace_body).unwrap(); + assert!(typed_workspace.permissions.manage_repositories); let anonymous_catalog = app .clone() @@ -17952,6 +17946,15 @@ mod tests { .await .unwrap(); assert_eq!(authenticated_catalog.status(), StatusCode::OK); + let catalog_body = to_bytes(authenticated_catalog.into_body(), usize::MAX) + .await + .unwrap(); + let typed_catalog: workspace_api::WorkspaceCatalogListResponse = + serde_json::from_slice(&catalog_body).unwrap(); + assert_eq!( + typed_catalog.0[0].workspace_id, + workspace.workspace.workspace_id + ); for path in ["/api/workspaces", "/api/auth/device-login/approve"] { let cross_site = app @@ -23290,6 +23293,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(); @@ -24188,6 +24262,9 @@ mod tests { let workspace = get_json(app.clone(), "/api/workspace").await; assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID); assert_eq!(workspace["display_name"], "Test Workspace"); + let typed_workspace: workspace_api::WorkspaceResponse = + serde_json::from_value(workspace.clone()).unwrap(); + assert!(!typed_workspace.permissions.manage_repositories); assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!( workspace["extension_points"]["host_worker_bridge"]["status"], @@ -24374,6 +24451,9 @@ mod tests { ); let repositories = get_json(app.clone(), "/api/repositories").await; + let typed_repositories: workspace_api::RepositoryListResponse = + serde_json::from_value(repositories.clone()).unwrap(); + assert_eq!(typed_repositories.items[0].id, TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!( @@ -24387,6 +24467,8 @@ mod tests { ); let repository_detail = get_json(app.clone(), "/api/repositories/main").await; + let _: workspace_api::RepositoryDetailResponse = + serde_json::from_value(repository_detail.clone()).unwrap(); assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); let scoped_repository_detail = get_json( app.clone(), @@ -24396,6 +24478,8 @@ mod tests { assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID); let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; + let _: workspace_api::RepositoryLogResponse = + serde_json::from_value(repository_log.clone()).unwrap(); assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); @@ -24460,10 +24544,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( @@ -24473,16 +24557,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!( @@ -24587,7 +24681,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" @@ -24596,12 +24690,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( @@ -24611,13 +24706,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!( @@ -25744,7 +25840,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/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index ed70673a..5fb5f313 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -35,8 +35,8 @@ pub struct WorkspaceCreateRequest { pub repository: InitialRepositoryIntent, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkspaceCreateResponse { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceCreateResult { pub workspace: WorkspaceRecord, pub repository: RepositoryRecord, pub config_revision: u64, @@ -81,7 +81,7 @@ impl WorkspaceCatalogService { &self, request: WorkspaceCreateRequest, owner_account_id: String, - ) -> Result { + ) -> Result { self.create_internal(request, owner_account_id, None) } @@ -90,7 +90,7 @@ impl WorkspaceCatalogService { request: WorkspaceCreateRequest, owner_account_id: String, requested_workspace_id: Option, - ) -> Result { + ) -> Result { let operation_key = normalize_required( "operation_key", request.operation_key, @@ -165,7 +165,7 @@ impl WorkspaceCatalogService { updated_at: now, }, })?; - Ok(WorkspaceCreateResponse { + Ok(WorkspaceCreateResult { workspace: result.workspace, repository: result.repository, config_revision: result.config_revision, diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 21cbc0bc..ed06b0d6 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 --allow-env=LOG,VSCODE_TEXTMATE_DEBUG 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 test/repository-access/ui.test.ts test/repositories/ui.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 test/repositories/ui.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/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts new file mode 100644 index 00000000..d753ca65 --- /dev/null +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -0,0 +1,166 @@ +// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`. +// Do not edit this file directly. + +export type WorkspaceSummary = { + workspace_id: string; + owner_account_id: string | null; + display_name: string; + state: string; + created_at: string; + updated_at: string; +}; + +export type WorkspaceCatalogListResponse = Array; + +export type WorkspaceRepositoryRecord = { + workspace_id: string; + repository_id: string; + name: string; + kind: string; + provider: string | null; + source: RepositorySource; + default_ref: string | null; + source_revision: number; + source_fingerprint: string; + observed_status: RepositoryObservedStatus; + observed_at: string | null; + created_at: string; + updated_at: string; +}; + +export type WorkspaceCreateResponse = { + workspace: WorkspaceSummary; + repository: WorkspaceRepositoryRecord; + config_revision: number; + request_fingerprint: string; + replayed: boolean; +}; + +export type WorkspaceAuthConfig = { + "Passkey": { + rp_id: string; + origin: string; + public_base_url: string; + cookie_name: string; + }; +}; + +export type WorkspacePermissionSummary = { + manage_repositories: boolean; + manage_secrets: boolean; +}; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; + +export type WorkspaceExtensionPointState = { + status: string; + note: string; + diagnostics: Array; +}; + +export type WorkspaceExtensionPoints = { + store: string; + event_stream: WorkspaceExtensionPointState; + host_worker_bridge: WorkspaceExtensionPointState; + companion_console: WorkspaceExtensionPointState; +}; + +export type WorkspaceResponse = { + workspace_id: string; + display_name: string; + record_authority: string; + schema_version: number; + auth: WorkspaceAuthConfig; + permissions: WorkspacePermissionSummary; + extension_points: WorkspaceExtensionPoints; +}; + +export type RepositorySourceKind = + | "local_path" + | "file" + | "ssh" + | "http" + | "https" + | "invalid"; + +export type RepositorySource = { + kind: RepositorySourceKind; + /** + * Canonical source representation. This is an absolute local path for + * `local_path`, and a normalized URI/remote specification otherwise. + */ + uri: string; +}; + +export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; + +export type RepositoryDiagnostic = { + severity: string; + code: string; + message: string; +}; + +export type GitRemoteSummary = { name: string; fetch_url: string }; + +export type GitRepositorySummary = { + status: string; + head: string | null; + branch: string | null; + dirty: boolean; + remotes: Array; +}; + +export type RepositorySummary = { + id: string; + display_name: string; + kind: string; + provider: string; + source: RepositorySource; + source_revision: number; + source_fingerprint: string; + observed_status: RepositoryObservedStatus; + observed_at?: string | null; + default_selector?: string | null; + record_authority: string; + git?: GitRepositorySummary | null; + diagnostics?: Array | null; +}; + +export type GitCommitSummary = { + hash: string; + short_hash: string; + summary: string; + author_name: string; + author_email: string; + author_date: string; + parents: Array; + refs: Array; +}; + +export type RepositoryListResponse = { + workspace_id: string; + items: Array; + source: string; + diagnostics: Array; +}; + +export type RepositoryDetailResponse = { + workspace_id: string; + item: RepositorySummary; + source: string; +}; + +export type RepositoryLogResponse = { + workspace_id: string; + repository_id: string; + default_selector?: string | null; + limit: number; + items: Array; + 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/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 6878faf4..7555ba52 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -1,41 +1,18 @@ -export type WorkspaceCatalogRecord = { - workspace_id: string; - owner_account_id: string | null; - display_name: string; - state: string; - created_at: string; - updated_at: string; -}; - -export type RepositorySourceKind = - | "local_path" - | "file" - | "ssh" - | "http" - | "https" - | "invalid"; - -export type WorkspaceRepositoryRecord = { - workspace_id: string; - repository_id: string; - name: string; - kind: string; - provider: string | null; - source: { - kind: RepositorySourceKind; - uri: string; - }; - default_ref: string | null; - source_revision: number; - source_fingerprint: string; - observed_status: "unverified" | "ready" | "invalid"; - observed_at: string | null; -}; +import { + parseRepositoryListResponse, + parseWorkspaceCatalogResponse, + parseWorkspaceCreateResponse, + type RepositorySummary, + type WorkspaceCreateResponse, + type WorkspaceSummary, +} from "$lib/workspace/api/workspace-model"; +export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { - repositories: WorkspaceRepositoryRecord[]; + repositories: RepositorySummary[]; repository_error?: string; }; +export type CreateWorkspaceResponse = WorkspaceCreateResponse; export type CreateWorkspaceRequest = { operation_key: string; @@ -47,14 +24,6 @@ export type CreateWorkspaceRequest = { }; }; -export type CreateWorkspaceResponse = { - workspace: WorkspaceCatalogRecord; - repository: WorkspaceRepositoryRecord; - config_revision: number; - request_fingerprint: string; - replayed: boolean; -}; - export class WorkspaceCatalogError extends Error { constructor( public readonly status: number | null, @@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch; export async function listWorkspaces( fetcher: Fetch, ): Promise { - return await fetchJson( - fetcher, - "/api/workspaces?limit=200", + return parseWorkspaceCatalogResponse( + await fetchJson(fetcher, "/api/workspaces?limit=200"), ); } export async function listWorkspaceRepositories( fetcher: Fetch, workspaceId: string, -): Promise { - return await fetchJson( - fetcher, - `/api/w/${encodeURIComponent(workspaceId)}/repositories`, - ); +): Promise { + return parseRepositoryListResponse( + await fetchJson( + fetcher, + `/api/w/${encodeURIComponent(workspaceId)}/repositories`, + ), + ).items; } export async function loadWorkspaceCatalog( @@ -115,11 +85,13 @@ export async function createWorkspace( fetcher: Fetch, request: CreateWorkspaceRequest, ): Promise { - return await fetchJson(fetcher, "/api/workspaces", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(request), - }); + return parseWorkspaceCreateResponse( + await fetchJson(fetcher, "/api/workspaces", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }), + ); } export function creationErrorMessage(error: unknown): string { @@ -150,11 +122,11 @@ export function createOperationKey(): string { }`; } -async function fetchJson( +async function fetchJson( fetcher: Fetch, input: string, init?: RequestInit, -): Promise { +): Promise { let response: Response; try { response = await fetcher(input, init); @@ -172,7 +144,7 @@ async function fetchJson( } throw new WorkspaceCatalogError(response.status, detail); } - return await response.json() as T; + return await response.json() as unknown; } function errorMessage(error: unknown): string { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts new file mode 100644 index 00000000..2721396c --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -0,0 +1,610 @@ +import type { ApiResult } from "$lib/workspace/api/http"; +import type { + Diagnostic, + GitCommitSummary, + GitRemoteSummary, + GitRepositorySummary, + RepositoryDetailResponse, + RepositoryDiagnostic, + RepositoryListResponse, + RepositoryLogResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, + WorkspaceAuthConfig, + WorkspaceCatalogListResponse, + WorkspaceCreateResponse, + WorkspaceExtensionPoints, + WorkspaceExtensionPointState, + WorkspacePermissionSummary, + WorkspaceRepositoryRecord, + WorkspaceResponse, + WorkspaceSummary, +} from "$lib/generated/workspace-api.ts"; + +export type { + GitCommitSummary, + GitRemoteSummary, + GitRepositorySummary, + RepositoryDetailResponse, + RepositoryListResponse, + RepositoryLogResponse, + RepositorySummary, + WorkspaceCatalogListResponse, + WorkspaceCreateResponse, + WorkspacePermissionSummary, + WorkspaceResponse, + WorkspaceSummary, +} from "$lib/generated/workspace-api.ts"; + +type JsonObject = Record; + +const SOURCE_KINDS = new Set([ + "local_path", + "file", + "ssh", + "http", + "https", + "invalid", +]); +const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]); +const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + return value as JsonObject; +} + +function array(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value; +} + +function string(value: unknown, path: string): string { + if (typeof value !== "string") throw new Error(`${path} must be a string`); + return value; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); + return value; +} + +function integer(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new Error(`${path} must be a safe integer`); + } + return value; +} + +function nullableString(value: unknown, path: string): string | null { + return value === null ? null : string(value, path); +} + +function optionalNullableString( + value: unknown, + path: string, +): string | null | undefined { + return value === undefined ? undefined : nullableString(value, path); +} + +function exactKeys( + value: JsonObject, + keys: readonly string[], + path: string, +): void { + const allowed = new Set(keys); + const unexpected = Object.keys(value).find((key) => !allowed.has(key)); + if (unexpected) { + throw new Error(`${path}.${unexpected} is not part of the wire contract`); + } +} + +function diagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], path); + const severity = string(item.severity, `${path}.severity`); + if (!DIAGNOSTIC_SEVERITIES.has(severity)) { + throw new Error(`${path}.severity is invalid`); + } + return { + code: string(item.code, `${path}.code`), + severity: severity as Diagnostic["severity"], + message: string(item.message, `${path}.message`), + }; +} + +function repositoryDiagnostic( + value: unknown, + path: string, +): RepositoryDiagnostic { + const item = object(value, path); + exactKeys(item, ["severity", "code", "message"], path); + return { + severity: string(item.severity, `${path}.severity`), + code: string(item.code, `${path}.code`), + message: string(item.message, `${path}.message`), + }; +} + +function repositorySource(value: unknown, path: string): RepositorySource { + const source = object(value, path); + exactKeys(source, ["kind", "uri"], path); + const kind = string(source.kind, `${path}.kind`); + if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) { + throw new Error(`${path}.kind is invalid`); + } + return { + kind: kind as RepositorySourceKind, + uri: string(source.uri, `${path}.uri`), + }; +} + +function gitRemote(value: unknown, path: string): GitRemoteSummary { + const remote = object(value, path); + exactKeys(remote, ["name", "fetch_url"], path); + return { + name: string(remote.name, `${path}.name`), + fetch_url: string(remote.fetch_url, `${path}.fetch_url`), + }; +} + +function gitSummary(value: unknown, path: string): GitRepositorySummary { + const git = object(value, path); + exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path); + return { + status: string(git.status, `${path}.status`), + head: nullableString(git.head, `${path}.head`), + branch: nullableString(git.branch, `${path}.branch`), + dirty: boolean(git.dirty, `${path}.dirty`), + remotes: array(git.remotes, `${path}.remotes`).map((item, index) => + gitRemote(item, `${path}.remotes[${index}]`) + ), + }; +} + +function repositorySummary(value: unknown, path: string): RepositorySummary { + const item = object(value, path); + exactKeys( + item, + [ + "id", + "display_name", + "kind", + "provider", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "observed_at", + "default_selector", + "record_authority", + "git", + "diagnostics", + ], + path, + ); + const observedStatus = string( + item.observed_status, + `${path}.observed_status`, + ); + if (!OBSERVED_STATUSES.has(observedStatus)) { + throw new Error(`${path}.observed_status is invalid`); + } + const diagnostics = + item.diagnostics === undefined || item.diagnostics === null + ? item.diagnostics + : array(item.diagnostics, `${path}.diagnostics`).map((entry, index) => + repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) + ); + return { + id: string(item.id, `${path}.id`), + display_name: string(item.display_name, `${path}.display_name`), + kind: string(item.kind, `${path}.kind`), + provider: string(item.provider, `${path}.provider`), + source: repositorySource(item.source, `${path}.source`), + source_revision: integer(item.source_revision, `${path}.source_revision`), + source_fingerprint: string( + item.source_fingerprint, + `${path}.source_fingerprint`, + ), + observed_status: observedStatus as RepositorySummary["observed_status"], + observed_at: optionalNullableString( + item.observed_at, + `${path}.observed_at`, + ), + default_selector: optionalNullableString( + item.default_selector, + `${path}.default_selector`, + ), + record_authority: string(item.record_authority, `${path}.record_authority`), + git: item.git === undefined || item.git === null + ? item.git + : gitSummary(item.git, `${path}.git`), + diagnostics, + }; +} + +function workspaceSummary(value: unknown, path: string): WorkspaceSummary { + const item = object(value, path); + exactKeys( + item, + [ + "workspace_id", + "owner_account_id", + "display_name", + "state", + "created_at", + "updated_at", + ], + path, + ); + return { + workspace_id: string(item.workspace_id, `${path}.workspace_id`), + owner_account_id: nullableString( + item.owner_account_id, + `${path}.owner_account_id`, + ), + display_name: string(item.display_name, `${path}.display_name`), + state: string(item.state, `${path}.state`), + created_at: string(item.created_at, `${path}.created_at`), + updated_at: string(item.updated_at, `${path}.updated_at`), + }; +} + +function workspaceRepositoryRecord( + value: unknown, + path: string, +): WorkspaceRepositoryRecord { + const item = object(value, path); + exactKeys( + item, + [ + "workspace_id", + "repository_id", + "name", + "kind", + "provider", + "source", + "default_ref", + "source_revision", + "source_fingerprint", + "observed_status", + "observed_at", + "created_at", + "updated_at", + ], + path, + ); + const observedStatus = string( + item.observed_status, + `${path}.observed_status`, + ); + if (!OBSERVED_STATUSES.has(observedStatus)) { + throw new Error(`${path}.observed_status is invalid`); + } + return { + workspace_id: string(item.workspace_id, `${path}.workspace_id`), + repository_id: string(item.repository_id, `${path}.repository_id`), + name: string(item.name, `${path}.name`), + kind: string(item.kind, `${path}.kind`), + provider: nullableString(item.provider, `${path}.provider`), + source: repositorySource(item.source, `${path}.source`), + default_ref: nullableString(item.default_ref, `${path}.default_ref`), + source_revision: integer(item.source_revision, `${path}.source_revision`), + source_fingerprint: string( + item.source_fingerprint, + `${path}.source_fingerprint`, + ), + observed_status: + observedStatus as WorkspaceRepositoryRecord["observed_status"], + observed_at: nullableString(item.observed_at, `${path}.observed_at`), + created_at: string(item.created_at, `${path}.created_at`), + updated_at: string(item.updated_at, `${path}.updated_at`), + }; +} + +function extensionPoint( + value: unknown, + path: string, +): WorkspaceExtensionPointState { + const item = object(value, path); + exactKeys(item, ["status", "note", "diagnostics"], path); + return { + status: string(item.status, `${path}.status`), + note: string(item.note, `${path}.note`), + diagnostics: array(item.diagnostics, `${path}.diagnostics`).map(( + entry, + index, + ) => diagnostic(entry, `${path}.diagnostics[${index}]`)), + }; +} + +function extensionPoints( + value: unknown, + path: string, +): WorkspaceExtensionPoints { + const item = object(value, path); + exactKeys(item, [ + "store", + "event_stream", + "host_worker_bridge", + "companion_console", + ], path); + return { + store: string(item.store, `${path}.store`), + event_stream: extensionPoint(item.event_stream, `${path}.event_stream`), + host_worker_bridge: extensionPoint( + item.host_worker_bridge, + `${path}.host_worker_bridge`, + ), + companion_console: extensionPoint( + item.companion_console, + `${path}.companion_console`, + ), + }; +} + +function authConfig(value: unknown, path: string): WorkspaceAuthConfig { + const auth = object(value, path); + exactKeys(auth, ["Passkey"], path); + const passkey = object(auth.Passkey, `${path}.Passkey`); + exactKeys( + passkey, + ["rp_id", "origin", "public_base_url", "cookie_name"], + `${path}.Passkey`, + ); + return { + Passkey: { + rp_id: string(passkey.rp_id, `${path}.Passkey.rp_id`), + origin: string(passkey.origin, `${path}.Passkey.origin`), + public_base_url: string( + passkey.public_base_url, + `${path}.Passkey.public_base_url`, + ), + cookie_name: string(passkey.cookie_name, `${path}.Passkey.cookie_name`), + }, + }; +} + +function permissions(value: unknown, path: string): WorkspacePermissionSummary { + const item = object(value, path); + exactKeys(item, ["manage_repositories", "manage_secrets"], path); + return { + manage_repositories: boolean( + item.manage_repositories, + `${path}.manage_repositories`, + ), + manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`), + }; +} + +function commitSummary(value: unknown, path: string): GitCommitSummary { + const item = object(value, path); + exactKeys( + item, + [ + "hash", + "short_hash", + "summary", + "author_name", + "author_email", + "author_date", + "parents", + "refs", + ], + path, + ); + return { + hash: string(item.hash, `${path}.hash`), + short_hash: string(item.short_hash, `${path}.short_hash`), + summary: string(item.summary, `${path}.summary`), + author_name: string(item.author_name, `${path}.author_name`), + author_email: string(item.author_email, `${path}.author_email`), + author_date: string(item.author_date, `${path}.author_date`), + parents: array(item.parents, `${path}.parents`).map((entry, index) => + string(entry, `${path}.parents[${index}]`) + ), + refs: array(item.refs, `${path}.refs`).map((entry, index) => + string(entry, `${path}.refs[${index}]`) + ), + }; +} + +export function parseWorkspaceCatalogResponse( + value: unknown, +): WorkspaceCatalogListResponse { + return array(value, "workspaces").map((item, index) => + workspaceSummary(item, `workspaces[${index}]`) + ); +} + +export function parseWorkspaceCreateResponse( + value: unknown, +): WorkspaceCreateResponse { + const response = object(value, "workspace create response"); + exactKeys( + response, + [ + "workspace", + "repository", + "config_revision", + "request_fingerprint", + "replayed", + ], + "workspace create response", + ); + return { + workspace: workspaceSummary( + response.workspace, + "workspace create response.workspace", + ), + repository: workspaceRepositoryRecord( + response.repository, + "workspace create response.repository", + ), + config_revision: integer( + response.config_revision, + "workspace create response.config_revision", + ), + request_fingerprint: string( + response.request_fingerprint, + "workspace create response.request_fingerprint", + ), + replayed: boolean(response.replayed, "workspace create response.replayed"), + }; +} + +export function parseWorkspaceResponse(value: unknown): WorkspaceResponse { + const response = object(value, "workspace response"); + exactKeys( + response, + [ + "workspace_id", + "display_name", + "record_authority", + "schema_version", + "auth", + "permissions", + "extension_points", + ], + "workspace response", + ); + return { + workspace_id: string( + response.workspace_id, + "workspace response.workspace_id", + ), + display_name: string( + response.display_name, + "workspace response.display_name", + ), + record_authority: string( + response.record_authority, + "workspace response.record_authority", + ), + schema_version: integer( + response.schema_version, + "workspace response.schema_version", + ), + auth: authConfig(response.auth, "workspace response.auth"), + permissions: permissions( + response.permissions, + "workspace response.permissions", + ), + extension_points: extensionPoints( + response.extension_points, + "workspace response.extension_points", + ), + }; +} + +export function parseRepositoryListResponse( + value: unknown, +): RepositoryListResponse { + const response = object(value, "repository list response"); + exactKeys( + response, + ["workspace_id", "items", "source", "diagnostics"], + "repository list response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository list response.workspace_id", + ), + items: array(response.items, "repository list response.items").map(( + item, + index, + ) => repositorySummary(item, `repository list response.items[${index}]`)), + source: string(response.source, "repository list response.source"), + diagnostics: array( + response.diagnostics, + "repository list response.diagnostics", + ).map( + (item, index) => + diagnostic(item, `repository list response.diagnostics[${index}]`), + ), + }; +} + +export function parseRepositoryListApiResult( + result: ApiResult, +): ApiResult { + if (result.data === null) return { data: null, error: result.error }; + try { + return { data: parseRepositoryListResponse(result.data), error: null }; + } catch (cause) { + return { + data: null, + error: cause instanceof Error + ? cause.message + : "invalid repository list response", + }; + } +} + +export function parseRepositoryDetailResponse( + value: unknown, +): RepositoryDetailResponse { + const response = object(value, "repository detail response"); + exactKeys( + response, + ["workspace_id", "item", "source"], + "repository detail response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository detail response.workspace_id", + ), + item: repositorySummary(response.item, "repository detail response.item"), + source: string(response.source, "repository detail response.source"), + }; +} + +export function parseRepositoryLogResponse( + value: unknown, +): RepositoryLogResponse { + const response = object(value, "repository log response"); + exactKeys( + response, + [ + "workspace_id", + "repository_id", + "default_selector", + "limit", + "items", + "diagnostics", + ], + "repository log response", + ); + return { + workspace_id: string( + response.workspace_id, + "repository log response.workspace_id", + ), + repository_id: string( + response.repository_id, + "repository log response.repository_id", + ), + default_selector: optionalNullableString( + response.default_selector, + "repository log response.default_selector", + ), + limit: integer(response.limit, "repository log response.limit"), + items: array(response.items, "repository log response.items").map(( + item, + index, + ) => commitSummary(item, `repository log response.items[${index}]`)), + diagnostics: array( + response.diagnostics, + "repository log response.diagnostics", + ).map( + (item, index) => + diagnostic(item, `repository log response.diagnostics[${index}]`), + ), + }; +} 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 0cf9cf23..51f4c34f 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -1,27 +1,39 @@ +import type { + WorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryListResponse, + WorkingDirectoryOccupancy, + WorkingDirectorySummary, +} from "$lib/generated/workdir-api"; import type { Event as PodProtocolEvent, Method as PodProtocolMethod, Segment as PodProtocolSegment, } from "$lib/generated/protocol"; +import type { + GitCommitSummary as SharedGitCommitSummary, + GitRemoteSummary as SharedGitRemoteSummary, + GitRepositorySummary as SharedGitRepositorySummary, + RepositoryDetailResponse as SharedRepositoryDetailResponse, + RepositoryListResponse as SharedRepositoryListResponse, + RepositoryLogResponse as SharedRepositoryLogResponse, + RepositorySummary as SharedRepositorySummary, + WorkspaceResponse as SharedWorkspaceResponse, +} from "$lib/workspace/api/workspace-model"; -export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; - -export type ExtensionPoint = { - status: string; - note: string; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceResponse = { - workspace_id: string; - display_name: string; - record_authority: string; - extension_points: { - event_stream: ExtensionPoint; - host_worker_bridge: ExtensionPoint; - companion_console: ExtensionPoint; - }; +export type { + PodProtocolEvent, + PodProtocolMethod, + PodProtocolSegment, + WorkingDirectoryCreateRequest, + WorkingDirectoryCreateResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryListResponse, + WorkingDirectoryOccupancy, + WorkingDirectorySummary, }; +export type WorkspaceResponse = SharedWorkspaceResponse; export type Diagnostic = { code: string; @@ -111,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" @@ -217,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[]; @@ -257,70 +225,13 @@ export type ListResponse = { diagnostics: Diagnostic[]; }; -export type RepositorySummary = { - id: string; - display_name: string; - kind: string; - provider: string; - source: { - kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid"; - uri: string; - }; - source_revision: number; - source_fingerprint: string; - observed_status: "unverified" | "ready" | "invalid"; - observed_at?: string | null; - default_selector?: string | null; - record_authority: string; - git?: GitRepositorySummary | null; - diagnostics?: Diagnostic[]; -}; - -export type GitRepositorySummary = { - status: string; - branch?: string | null; - head?: string | null; - dirty: boolean; - remotes: GitRemoteSummary[]; -}; - -export type GitRemoteSummary = { - name: string; - fetch_url: string; -}; - -export type GitCommitSummary = { - hash: string; - short_hash: string; - summary: string; - author_name: string; - author_email: string; - author_date: string; - parents: string[]; - refs: string[]; -}; - -export type RepositoryListResponse = { - workspace_id: string; - items: RepositorySummary[]; - source: string; - diagnostics: Diagnostic[]; -}; - -export type RepositoryDetailResponse = { - workspace_id: string; - item: RepositorySummary; - source: string; -}; - -export type RepositoryLogResponse = { - workspace_id: string; - repository_id: string; - default_selector?: string | null; - limit: number; - items: GitCommitSummary[]; - diagnostics: Diagnostic[]; -}; +export type RepositorySummary = SharedRepositorySummary; +export type GitRepositorySummary = SharedGitRepositorySummary; +export type GitRemoteSummary = SharedGitRemoteSummary; +export type GitCommitSummary = SharedGitCommitSummary; +export type RepositoryListResponse = SharedRepositoryListResponse; +export type RepositoryDetailResponse = SharedRepositoryDetailResponse; +export type RepositoryLogResponse = SharedRepositoryLogResponse; export type MemoryDocumentResponse = { body_md: string; @@ -448,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/+page.svelte b/web/workspace/src/routes/+page.svelte index 807a51b9..75bf8187 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -132,9 +132,9 @@ {workspace.workspace_id} {#if workspace.repositories[0]} - {workspace.repositories[0].name} + {workspace.repositories[0].display_name} - {workspace.repositories[0].default_ref ?? "repository default"} · + {workspace.repositories[0].default_selector ?? "repository default"} · {workspace.repositories[0].kind} diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.ts b/web/workspace/src/routes/w/[workspaceId]/+layout.ts index 7ec62de3..ad0a58dd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.ts @@ -1,34 +1,51 @@ import { error } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + parseRepositoryListResponse, + parseWorkspaceResponse, +} from "$lib/workspace/api/workspace-model"; import type { LayoutLoad } from "./$types"; -import type { - RepositoryListResponse, - WorkspaceResponse, -} from "$lib/workspace/sidebar/types"; export const load: LayoutLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; - const [workspace, repositories] = await Promise.all([ - loadJson( - fetch, - workspaceApiPath(workspaceId, "/workspace"), - ), - loadJson( - fetch, - workspaceApiPath(workspaceId, "/repositories"), - ), + const [workspaceResult, repositoryResult] = await Promise.all([ + loadJson(fetch, workspaceApiPath(workspaceId, "/workspace")), + loadJson(fetch, workspaceApiPath(workspaceId, "/repositories")), ]); - if (!workspace.data) { + let workspace = null; + let workspaceError = workspaceResult.error; + if (workspaceResult.data !== null) { + try { + workspace = parseWorkspaceResponse(workspaceResult.data); + } catch (cause) { + workspaceError = cause instanceof Error + ? cause.message + : "invalid workspace response"; + } + } + if (!workspace) { error(404, { - message: workspace.error ?? `Workspace ${workspaceId} is unavailable`, + message: workspaceError ?? `Workspace ${workspaceId} is unavailable`, }); } + let repositories = null; + let repositoriesError = repositoryResult.error; + if (repositoryResult.data !== null) { + try { + repositories = parseRepositoryListResponse(repositoryResult.data); + } catch (cause) { + repositoriesError = cause instanceof Error + ? cause.message + : "invalid repository list response"; + } + } + return { - workspace: workspace.data, + workspace, workspaceError: null, - repositories: repositories.data, - repositoriesError: repositories.error, + repositories, + repositoriesError, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts index 2b04bdde..b423bbdb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts @@ -1,29 +1,59 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { - RepositoryDetailResponse, - RepositoryLogResponse, -} from "$lib/workspace/sidebar/types"; +import { + parseRepositoryDetailResponse, + parseRepositoryLogResponse, +} from "$lib/workspace/api/workspace-model"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { - const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); + const workspaceId = params.workspaceId; const repositoryId = params.repositoryId; - const [repository, log] = await Promise.all([ - loadJson( + const [repositoryResult, logResult] = await Promise.all([ + loadJson( fetch, - apiPath(`/repositories/${encodeURIComponent(repositoryId)}`), + workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryId)}`, + ), ), - loadJson( + loadJson( fetch, - apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`), + workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryId)}/log`, + ), ), ]); + let repository = null; + let repositoryError = repositoryResult.error; + if (repositoryResult.data !== null) { + try { + repository = parseRepositoryDetailResponse(repositoryResult.data); + } catch (cause) { + repositoryError = cause instanceof Error + ? cause.message + : "invalid repository detail response"; + } + } + + let log = null; + let logError = logResult.error; + if (logResult.data !== null) { + try { + log = parseRepositoryLogResponse(logResult.data); + } catch (cause) { + logError = cause instanceof Error + ? cause.message + : "invalid repository log response"; + } + } + return { repositoryId, - repository: repository.data, - repositoryError: repository.error, - repositoryLog: log.data, - repositoryLogError: log.error, + repository, + repositoryError, + repositoryLog: log, + repositoryLogError: logError, }; }; 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 @@