diff --git a/Cargo.lock b/Cargo.lock index dda192e6..bd0014b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6706,6 +6706,7 @@ dependencies = [ name = "workspace-api" version = "0.1.0" dependencies = [ + "protocol", "serde", "serde_json", "ts-rs", diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index fb96a602..c4a41f20 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,6 +1,6 @@ use reqwest::Method; +use serde::Serialize; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; use ticket::{ MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck, @@ -9,39 +9,17 @@ use ticket::{ TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary, }; use workspace_api::{ - ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, - ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, + BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, + CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail, + ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + WorkerLaunchOptionsResponse, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; -#[derive(Debug, Deserialize)] -struct BackendWorkerLaunchOptions { - runtimes: Vec, -} - -#[derive(Debug, Deserialize)] -struct BackendWorkerLaunchRuntime { - runtime_id: String, - worker_creation_available: bool, - working_directory_required: bool, -} - -#[derive(Debug, Deserialize)] -struct BackendCreateWorkerResponse { - runtime_id: String, - worker_id: String, -} - -#[derive(Debug, Deserialize)] -struct BackendWorkspaceOrchestratorResponse { - disposition: String, - worker: Option, -} - /// Workspace-scoped Backend client for Ticket and Objective product state. /// /// Construction requires both the selected Backend URL and Workspace identity. @@ -267,7 +245,7 @@ impl BackendWorkspaceProductClient { &self, ticket_id: &str, ) -> Result { - let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?; + let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?; let runtime = options .runtimes .iter() @@ -278,19 +256,19 @@ impl BackendWorkspaceProductClient { .to_string(), ) })?; - let response: BackendCreateWorkerResponse = self.send_json( - Method::POST, - "/workers", - Some(&serde_json::json!({ - "runtime_id": runtime.runtime_id, - "display_name": format!("intake-{ticket_id}"), - "profile": "builtin:intake", - "initial_submit": [{ - "kind": "text", - "content": format!("Please handle intake for Ticket {ticket_id}.") - }] - })), - )?; + let request = CreateWorkspaceWorkerRequest { + runtime_id: runtime.runtime_id.clone(), + display_name: format!("intake-{ticket_id}"), + profile: Some("builtin:intake".to_string()), + ticket_assignment: None, + initial_submit: vec![protocol::Segment::Text { + content: format!("Please handle intake for Ticket {ticket_id}."), + }], + working_directory: None, + control_operation_id: None, + }; + let response: BrowserCreateWorkerResponse = + self.send_json(Method::POST, "/workers", Some(&request))?; Ok(format!( "Started Intake Worker {}/{} for Ticket {ticket_id}", response.runtime_id, response.worker_id @@ -298,7 +276,7 @@ impl BackendWorkspaceProductClient { } pub fn start_workspace_orchestrator(&self) -> Result { - let response: BackendWorkspaceOrchestratorResponse = + let response: BrowserWorkspaceOrchestratorResponse = self.send_json::<(), _>(Method::POST, "/orchestrator", None)?; let worker = response.worker.ok_or_else(|| { BackendWorkspaceClientError::InvalidTarget( @@ -792,11 +770,11 @@ mod tests { let (base_url, requests, handle) = response_sequence_server(vec![ ( "200 OK", - r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#, + r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#, ), ( "200 OK", - r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#, + r#"{"workspace_id":"workspace-a","runtime_id":"embedded","worker_id":"worker-1","console_href":"/w/workspace-a/workers/worker-1","worker":{"runtime_id":"embedded","worker_id":"worker-1","host_id":"embedded","display_name":"Intake","label":"worker-1","profile":"builtin:intake","singleton_key":null,"tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":false,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#, ), ]); let client = BackendWorkspaceProductClient::new_with_access_token( @@ -824,7 +802,7 @@ mod tests { #[test] fn workspace_orchestrator_launch_uses_scoped_backend_route() { - let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#; + let body = r#"{"workspace_id":"workspace-a","online":true,"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2","host_id":"embedded","display_name":"Orchestrator","label":"worker-2","profile":"builtin:orchestrator","singleton_key":"workspace-orchestrator","tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":true,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#; let (base_url, request, handle) = one_response_server("200 OK", body); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index e00b8a58..979231a2 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -7,9 +7,10 @@ publish = false [features] default = [] -typescript = ["dep:ts-rs"] +typescript = ["dep:ts-rs", "protocol/typescript"] [dependencies] +protocol.workspace = true serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } @@ -24,6 +25,10 @@ serde_json.workspace = true name = "generate_workdir_api_types" required-features = ["typescript"] +[[example]] +name = "generate_worker_launch_api_types" +required-features = ["typescript"] + [[example]] name = "generate_companion_api_types" required-features = ["typescript"] diff --git a/crates/workspace-api/examples/generate_worker_launch_api_types.rs b/crates/workspace-api/examples/generate_worker_launch_api_types.rs new file mode 100644 index 00000000..ffcf6248 --- /dev/null +++ b/crates/workspace-api/examples/generate_worker_launch_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::worker_launch_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 14bfb26a..f6aa4e78 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -579,6 +579,8 @@ pub struct WorkingDirectoryOccupancy { /// retains the Backend-generated Repository id and is never a Workspace public /// 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 RuntimeWorkingDirectoryCleanupTarget { pub kind: String, @@ -590,6 +592,8 @@ pub struct RuntimeWorkingDirectoryCleanupTarget { /// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is /// replaced with `repository_key`. #[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 RuntimeWorkingDirectorySummary { pub working_directory_id: String, @@ -607,6 +611,7 @@ pub struct RuntimeWorkingDirectorySummary { #[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(type = "number | null"))] pub observed_at_epoch_seconds: Option, pub materializer_kind: WorkingDirectoryMaterializerKind, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -908,6 +913,7 @@ pub struct RuntimeConnectionTestResponse { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerWorkspaceSummary { pub visibility: String, pub identity: String, @@ -916,12 +922,14 @@ pub struct WorkerWorkspaceSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerImplementationSummary { pub kind: String, pub display_hint: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerCapabilitySummary { pub can_stop: bool, pub can_spawn_followup: bool, @@ -1095,6 +1103,7 @@ pub struct WorkspaceWorkerDiscoveryPage { /// do not carry one. The Workspace Server must resolve it from Workspace /// authority before constructing this response. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerSummary { pub runtime_id: String, pub worker_id: String, @@ -1122,6 +1131,142 @@ pub struct WorkerSummary { pub diagnostics: Vec, } +/// Runtime-owned Worker summary embedded in Worker launch responses. +/// +/// This preserves the existing launch wire shape. Workspace-owned Worker list +/// and detail responses use [`WorkerSummary`] instead. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerLaunchWorkerSummary { + pub runtime_id: String, + pub worker_id: String, + pub host_id: String, + pub display_name: String, + pub label: String, + pub profile: Option, + pub singleton_key: Option, + pub tags: Vec, + pub workspace: WorkerWorkspaceSummary, + pub state: String, + pub last_seen_at: Option, + pub pinned: bool, + pub retention_state: String, + pub implementation: WorkerImplementationSummary, + pub capabilities: WorkerCapabilitySummary, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub working_directory: Option, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerLaunchOptionsResponse { + pub workspace_id: String, + pub runtimes: Vec, + pub default_profile: Option, + pub profiles: Vec, + pub repositories: Vec, + pub working_directories: 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 WorkerLaunchRuntimeOption { + pub runtime_id: String, + pub display_name: String, + pub built_in: bool, + pub worker_creation_available: bool, + pub working_directory_required: bool, + pub status: 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 WorkerLaunchProfileCandidate { + pub id: String, + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryRepositoryOption { + pub repository_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub default_selector: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserWorkerWorkingDirectorySelection { + pub working_directory_id: String, + #[serde(default)] + pub relative_cwd: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceWorkerTicketAssignmentRequest { + pub ticket_id: String, + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceWorkerRequest { + pub runtime_id: String, + pub display_name: String, + #[serde(default)] + pub profile: Option, + #[serde(default)] + pub ticket_assignment: Option, + #[serde(default)] + pub initial_submit: Vec, + #[serde(default)] + pub working_directory: Option, + /// Backend idempotency key used only for authenticated Worker-owned spawn/control. + #[serde(default)] + pub control_operation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserCreateWorkerResponse { + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub console_href: String, + pub worker: WorkerLaunchWorkerSummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserWorkspaceOrchestratorResponse { + pub workspace_id: String, + pub online: bool, + pub disposition: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub worker: Option, + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkerOperationState { @@ -1390,6 +1535,74 @@ pub fn workdir_api_typescript() -> String { ) } +#[cfg(feature = "typescript")] +pub fn worker_launch_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), + RuntimeWorkingDirectoryCleanupTarget::decl(&config), + RuntimeWorkingDirectorySummary::decl(&config), + WorkingDirectoryOccupancy::decl(&config), + WorkingDirectorySummary::decl(&config), + WorkerWorkspaceSummary::decl(&config), + WorkerImplementationSummary::decl(&config), + WorkerCapabilitySummary::decl(&config), + WorkerLaunchWorkerSummary::decl(&config), + WorkerLaunchRuntimeOption::decl(&config), + WorkerLaunchProfileCandidate::decl(&config), + WorkingDirectoryRepositoryOption::decl(&config), + WorkerLaunchOptionsResponse::decl(&config), + BrowserWorkerWorkingDirectorySelection::decl(&config), + CreateWorkspaceWorkerTicketAssignmentRequest::decl(&config), + CreateWorkspaceWorkerRequest::decl(&config), + BrowserCreateWorkerResponse::decl(&config), + BrowserWorkspaceOrchestratorResponse::decl(&config), + ]; + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + +#[cfg(all(test, feature = "typescript"))] +mod worker_launch_typescript_tests { + #[test] + fn generated_worker_launch_api_contract_is_current() { + let expected = super::worker_launch_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/worker-launch-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 Worker launch API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file", + ); + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect::() + .replace("=|", "=") + } +} + #[cfg(all(test, feature = "typescript"))] mod workdir_typescript_tests { #[test] @@ -1423,6 +1636,113 @@ mod workdir_typescript_tests { mod tests { use super::*; + fn worker_launch_summary() -> WorkerLaunchWorkerSummary { + WorkerLaunchWorkerSummary { + runtime_id: "runtime-a".to_string(), + worker_id: "worker-a".to_string(), + host_id: "host-a".to_string(), + display_name: "Worker A".to_string(), + label: "worker-a".to_string(), + profile: None, + singleton_key: None, + tags: Vec::new(), + workspace: WorkerWorkspaceSummary { + visibility: "workspace".to_string(), + identity: "workspace-a".to_string(), + workspace_id: Some("workspace-a".to_string()), + }, + state: "idle".to_string(), + last_seen_at: None, + pinned: false, + retention_state: "active".to_string(), + implementation: WorkerImplementationSummary { + kind: "runtime".to_string(), + display_hint: "Runtime Worker".to_string(), + }, + capabilities: WorkerCapabilitySummary { + can_stop: true, + can_spawn_followup: false, + }, + working_directory: None, + diagnostics: Vec::new(), + } + } + + #[test] + fn worker_launch_optional_omission_and_request_shape_are_stable() { + assert_eq!( + serde_json::to_value(WorkingDirectoryRepositoryOption { + repository_key: "main".to_string(), + default_selector: None, + }) + .unwrap(), + serde_json::json!({ "repository_key": "main" }) + ); + + let orchestrator = serde_json::to_value(BrowserWorkspaceOrchestratorResponse { + workspace_id: "workspace-a".to_string(), + online: false, + disposition: "unavailable".to_string(), + worker: None, + diagnostics: Vec::new(), + }) + .unwrap(); + assert_eq!( + orchestrator, + serde_json::json!({ + "workspace_id": "workspace-a", + "online": false, + "disposition": "unavailable", + "diagnostics": [], + }) + ); + + let worker = serde_json::to_value(worker_launch_summary()).unwrap(); + assert!( + !worker + .as_object() + .unwrap() + .contains_key("working_directory") + ); + assert_eq!(worker["profile"], serde_json::Value::Null); + assert_eq!(worker["singleton_key"], serde_json::Value::Null); + assert_eq!(worker["last_seen_at"], serde_json::Value::Null); + + let request = serde_json::to_value(CreateWorkspaceWorkerRequest { + runtime_id: "runtime-a".to_string(), + display_name: "Worker A".to_string(), + profile: None, + ticket_assignment: None, + initial_submit: Vec::new(), + working_directory: None, + control_operation_id: None, + }) + .unwrap(); + assert_eq!( + request, + serde_json::json!({ + "runtime_id": "runtime-a", + "display_name": "Worker A", + "profile": null, + "ticket_assignment": null, + "initial_submit": [], + "working_directory": null, + "control_operation_id": null, + }) + ); + } + + #[test] + fn worker_launch_request_rejects_unknown_fields() { + let error = serde_json::from_value::(serde_json::json!({ + "runtime_id": "runtime-a", + "display_name": "Worker A", + "unexpected": true, + })) + .unwrap_err(); + assert!(error.to_string().contains("unknown field")); + } + #[test] fn repository_key_validation_is_canonical_and_bounded() { let max = "a".repeat(64); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2377ee59..54379aa8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -58,25 +58,28 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ - CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, - CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, - DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, - ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, - RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, - RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, + BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateRemoteRuntimeRequest, + CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest, + CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest, + DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, + ObjectiveLinkTicketRequest, ObjectiveStateRequest, ProfileSettingsResponse, + PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse, + RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - UpdateWorkspaceMetadataRequest, + UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryRemovalDisposition, WorkingDirectoryRemovalRequest, - WorkingDirectoryRemovalResponse, WorkspaceCatalogListResponse, WorkspaceCreateResponse, - WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, - WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord, - WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, + WorkingDirectoryRemovalResponse, WorkingDirectoryRepositoryOption, + WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState, + WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse, + WorkspacePermissionSummary, WorkspaceRepositoryRecord, WorkspaceResponse, + WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; @@ -3097,98 +3100,6 @@ pub struct WorkerRetentionResponse { pub retention_state: String, } -#[derive(Debug, Serialize, Deserialize)] -pub struct WorkerLaunchOptionsResponse { - pub workspace_id: String, - pub runtimes: Vec, - pub default_profile: Option, - pub profiles: Vec, - pub repositories: Vec, - pub working_directories: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct WorkerLaunchRuntimeOption { - pub runtime_id: String, - pub display_name: String, - pub built_in: bool, - pub worker_creation_available: bool, - pub working_directory_required: bool, - pub status: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkerLaunchProfileCandidate { - pub id: String, - pub label: String, - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkingDirectoryRepositoryOption { - pub repository_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_selector: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BrowserWorkerWorkingDirectorySelection { - pub working_directory_id: String, - #[serde(default)] - pub relative_cwd: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct BrowserWorkspaceOrchestratorResponse { - pub workspace_id: String, - pub online: bool, - pub disposition: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CreateWorkspaceWorkerTicketAssignmentRequest { - pub ticket_id: String, - pub operation_id: String, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CreateWorkspaceWorkerRequest { - pub runtime_id: String, - pub display_name: String, - #[serde(default)] - pub profile: Option, - #[serde(default)] - pub ticket_assignment: Option, - #[serde(default)] - pub initial_submit: Vec, - #[serde(default)] - pub working_directory: Option, - /// Backend idempotency key used only for authenticated Worker-owned spawn/control. - #[serde(default)] - pub control_operation_id: Option, - /// Trusted resolution populated only by the authenticated worker-control handler. - #[serde(skip, default)] - pub resolved_control_operation: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct BrowserCreateWorkerResponse { - pub workspace_id: String, - #[serde(flatten)] - pub worker_ref: RuntimeWorkerRef, - pub console_href: String, - pub worker: WorkerSummary, - pub diagnostics: Vec, -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, @@ -7758,9 +7669,9 @@ fn start_memory_staging_consolidation( resolved_config_bundle, resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), - resolved_control_operation: None, resolved_workspace_api: None, resolved_memory_settings: None, + resolved_control_operation: None, }, )?; if result.state != WorkerOperationState::Accepted { @@ -8711,18 +8622,20 @@ async fn spawn_known_worker( .map(|byte| format!("{byte:02x}")) .collect::() ); - request.resolved_control_operation = Some(WorkerControlOperation { + let resolved_control_operation = Some(WorkerControlOperation { operation_id: scoped_worker_control_operation_id(&controller, &operation_id), input_fingerprint, }); - let response = create_workspace_worker(State(api.clone()), headers, Json(request)).await?; + let response = + create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation) + .await?; if let Err(error) = api .store .create_worker_control_grant(&WorkerControlGrantRecord { workspace_id: path.workspace_id.clone(), grant_id: new_id("wcg"), controller, - subject: response.0.worker_ref.clone(), + subject: RuntimeWorkerRef::new(&response.0.runtime_id, &response.0.worker_id), relation: relation.to_string(), origin: "worker_spawn".to_string(), permissions: vec![ @@ -9055,9 +8968,9 @@ async fn scoped_start_workspace_orchestrator( resolved_config_bundle: None, resolved_worker_observation_enabled: true, resolved_worker_observation_grants: Vec::new(), - resolved_control_operation: None, resolved_workspace_api: None, resolved_memory_settings: None, + resolved_control_operation: None, }, )?; if result.state != WorkerOperationState::Accepted || result.worker.is_none() { @@ -9085,6 +8998,42 @@ async fn scoped_start_workspace_orchestrator( Ok(Json(workspace_orchestrator_response(&api, "created"))) } +fn worker_launch_worker_summary(worker: WorkerSummary) -> WorkerLaunchWorkerSummary { + WorkerLaunchWorkerSummary { + runtime_id: worker.worker.runtime_id, + worker_id: worker.worker.worker_id, + host_id: worker.host_id, + display_name: worker.display_name, + label: worker.label, + profile: worker.profile, + singleton_key: worker.singleton_key, + tags: worker.tags, + workspace: workspace_api::WorkerWorkspaceSummary { + visibility: worker.workspace.visibility, + identity: worker.workspace.identity, + workspace_id: worker.workspace.workspace_id, + }, + state: worker.state, + last_seen_at: worker.last_seen_at, + pinned: worker.pinned, + retention_state: worker.retention_state, + implementation: workspace_api::WorkerImplementationSummary { + kind: worker.implementation.kind, + display_hint: worker.implementation.display_hint, + }, + capabilities: workspace_api::WorkerCapabilitySummary { + can_stop: worker.capabilities.can_stop, + can_spawn_followup: worker.capabilities.can_spawn_followup, + }, + working_directory: worker.working_directory, + diagnostics: worker + .diagnostics + .into_iter() + .map(workspace_api::Diagnostic::from) + .collect(), + } +} + fn workspace_orchestrator_response( api: &WorkspaceApi, disposition: &str, @@ -9095,13 +9044,20 @@ fn workspace_orchestrator_response( .is_some_and(workspace_orchestrator_is_online); let diagnostics = worker .as_ref() - .map(|worker| worker.diagnostics.clone()) + .map(|worker| { + worker + .diagnostics + .iter() + .cloned() + .map(workspace_api::Diagnostic::from) + .collect() + }) .unwrap_or_default(); BrowserWorkspaceOrchestratorResponse { workspace_id: api.config.workspace_id.clone(), online, disposition: disposition.to_string(), - worker, + worker: worker.map(worker_launch_worker_summary), diagnostics, } } @@ -12458,6 +12414,15 @@ async fn create_workspace_worker( State(api): State, headers: HeaderMap, Json(request): Json, +) -> ApiResult> { + create_workspace_worker_inner(api, headers, request, None).await +} + +async fn create_workspace_worker_inner( + api: WorkspaceApi, + headers: HeaderMap, + request: CreateWorkspaceWorkerRequest, + resolved_control_operation: Option, ) -> ApiResult> { let CreateWorkspaceWorkerRequest { runtime_id, @@ -12467,7 +12432,6 @@ async fn create_workspace_worker( initial_submit, working_directory, control_operation_id: _, - resolved_control_operation, } = request; let config_state = api .config_store @@ -12765,10 +12729,14 @@ fn browser_worker_response_from_summary( ); Ok(BrowserCreateWorkerResponse { workspace_id, - worker_ref: RuntimeWorkerRef::new(&runtime_id, &worker_id), + runtime_id, + worker_id, console_href, - worker, - diagnostics, + worker: worker_launch_worker_summary(worker), + diagnostics: diagnostics + .into_iter() + .map(workspace_api::Diagnostic::from) + .collect(), }) } @@ -14810,7 +14778,11 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult web/workspace/src/lib/generated/worker-launch-api.ts + +import type { Segment } from "./protocol"; + +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_key: string; +}; + +export type RuntimeWorkingDirectoryCleanupTarget = { + kind: string; + working_directory_id: string; + repository_id: string; +}; + +export type RuntimeWorkingDirectorySummary = { + 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?: RuntimeWorkingDirectoryCleanupTarget | null; + status: WorkingDirectoryStatusKind; + cleanliness?: string | null; + primary_worker_id?: string | null; + occupied_by?: WorkingDirectoryOccupancy | null; +}; + +export type WorkingDirectoryOccupancy = { + runtime_id: string; + worker_id: string; + display_name: string; + linked_at: string; +}; + +export type WorkingDirectorySummary = { + working_directory_id: string; + repository_key: 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 WorkerWorkspaceSummary = { + visibility: string; + identity: string; + workspace_id?: string | null; +}; + +export type WorkerImplementationSummary = { + kind: string; + display_hint: string; +}; + +export type WorkerCapabilitySummary = { + can_stop: boolean; + can_spawn_followup: boolean; +}; + +export type WorkerLaunchWorkerSummary = { + runtime_id: string; + worker_id: string; + host_id: string; + display_name: string; + label: string; + profile: string | null; + singleton_key: string | null; + tags: Array; + workspace: WorkerWorkspaceSummary; + state: string; + last_seen_at: string | null; + pinned: boolean; + retention_state: string; + implementation: WorkerImplementationSummary; + capabilities: WorkerCapabilitySummary; + working_directory?: RuntimeWorkingDirectorySummary | null; + diagnostics: Array; +}; + +export type WorkerLaunchRuntimeOption = { + runtime_id: string; + display_name: string; + built_in: boolean; + worker_creation_available: boolean; + working_directory_required: boolean; + status: string; + diagnostics: Array; +}; + +export type WorkerLaunchProfileCandidate = { + id: string; + label: string; + description: string; +}; + +export type WorkingDirectoryRepositoryOption = { + repository_key: string; + default_selector?: string | null; +}; + +export type WorkerLaunchOptionsResponse = { + workspace_id: string; + runtimes: Array; + default_profile: string | null; + profiles: Array; + repositories: Array; + working_directories: Array; + diagnostics: Array; +}; + +export type BrowserWorkerWorkingDirectorySelection = { + working_directory_id: string; + relative_cwd: string | null; +}; + +export type CreateWorkspaceWorkerTicketAssignmentRequest = { + ticket_id: string; + operation_id: string; +}; + +export type CreateWorkspaceWorkerRequest = { + runtime_id: string; + display_name: string; + profile: string | null; + ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null; + initial_submit: Array; + working_directory: BrowserWorkerWorkingDirectorySelection | null; + /** + * Backend idempotency key used only for authenticated Worker-owned spawn/control. + */ + control_operation_id: string | null; +}; + +export type BrowserCreateWorkerResponse = { + workspace_id: string; + runtime_id: string; + worker_id: string; + console_href: string; + worker: WorkerLaunchWorkerSummary; + diagnostics: Array; +}; + +export type BrowserWorkspaceOrchestratorResponse = { + workspace_id: string; + online: boolean; + disposition: string; + worker?: WorkerLaunchWorkerSummary | null; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index 4381ab27..d8769eb6 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -55,7 +55,7 @@ export function parseWorkingDirectoryListResponse( ); return { workspace_id: stringField(record, "workspace_id"), - items: arrayField(record, "items").map(parseSummary), + items: arrayField(record, "items").map(parseWorkingDirectorySummary), diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), }; } @@ -101,12 +101,14 @@ function parseDetailLike( return { workspace_id: stringField(record, "workspace_id"), runtime_id: stringField(record, "runtime_id"), - item: parseSummary(record.item), + item: parseWorkingDirectorySummary(record.item), diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), }; } -function parseSummary(value: unknown): WorkingDirectorySummary { +export function parseWorkingDirectorySummary( + value: unknown, +): WorkingDirectorySummary { const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary"); const summary: WorkingDirectorySummary = { working_directory_id: stringField(record, "working_directory_id"), diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts new file mode 100644 index 00000000..8548b311 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -0,0 +1,162 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +function assertEquals(actual: unknown, expected: unknown): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`Expected ${expectedJson}, received ${actualJson}`); + } +} + +function assertThrows( + operation: () => unknown, + errorClass: typeof Error, + message: string, +): void { + try { + operation(); + } catch (error) { + if (!(error instanceof errorClass) || !error.message.includes(message)) { + throw error; + } + return; + } + throw new Error(`Expected operation to throw ${errorClass.name}: ${message}`); +} + +import { + parseBrowserCreateWorkerResponse, + parseBrowserWorkspaceOrchestratorResponse, + parseCreateWorkspaceWorkerRequest, + parseWorkerLaunchOptionsResponse, +} from "./workers.ts"; + +const worker = { + runtime_id: "runtime-a", + worker_id: "worker-a", + host_id: "host-a", + display_name: "Worker A", + label: "worker-a", + profile: "builtin:coder", + singleton_key: null, + tags: [], + workspace: { + visibility: "workspace", + identity: "workspace-a", + workspace_id: "workspace-a", + }, + state: "idle", + last_seen_at: null, + pinned: false, + retention_state: "active", + implementation: { + kind: "runtime", + display_hint: "Runtime Worker", + }, + capabilities: { + can_stop: true, + can_spawn_followup: false, + }, + diagnostics: [], +}; + +Deno.test("Worker launch options parser accepts the generated wire shape", () => { + const parsed = parseWorkerLaunchOptionsResponse({ + workspace_id: "workspace-a", + runtimes: [{ + runtime_id: "runtime-a", + display_name: "Runtime A", + built_in: false, + worker_creation_available: true, + working_directory_required: true, + status: "connected", + diagnostics: [], + }], + default_profile: null, + profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }], + repositories: [{ repository_key: "main" }], + working_directories: [], + diagnostics: [], + }); + + assertEquals(parsed.runtimes[0].runtime_id, "runtime-a"); + assertEquals(parsed.repositories[0].default_selector, undefined); +}); + +Deno.test("Worker launch response parsers reject missing and unknown fields", () => { + assertThrows( + () => + parseWorkerLaunchOptionsResponse({ + workspace_id: "workspace-a", + runtimes: [], + profiles: [], + repositories: [], + working_directories: [], + diagnostics: [], + }), + Error, + "default_profile", + ); + + assertThrows( + () => + parseBrowserCreateWorkerResponse({ + workspace_id: "workspace-a", + runtime_id: "runtime-a", + worker_id: "worker-a", + console_href: "/workers/worker-a", + worker, + diagnostics: [], + unexpected: true, + }), + Error, + "unknown field unexpected", + ); + + assertThrows( + () => + parseBrowserWorkspaceOrchestratorResponse({ + workspace_id: "workspace-a", + online: false, + disposition: "missing", + diagnostics: [], + extra: false, + }), + Error, + "unknown field extra", + ); +}); + +Deno.test("Worker create request parser requires the complete shared request", () => { + const request = { + runtime_id: "runtime-a", + display_name: "Worker A", + profile: "builtin:coder", + ticket_assignment: null, + initial_submit: [{ kind: "text", content: "Implement T-565." }], + working_directory: { + working_directory_id: "workdir-a", + relative_cwd: null, + }, + control_operation_id: null, + }; + + assertEquals(parseCreateWorkspaceWorkerRequest(request), request); + assertThrows( + () => + parseCreateWorkspaceWorkerRequest({ + ...request, + operation_id: "legacy-literal", + }), + Error, + "unknown field operation_id", + ); + const { initial_submit: _initialSubmit, ...missingInitialSubmit } = request; + assertThrows( + () => parseCreateWorkspaceWorkerRequest(missingInitialSubmit), + Error, + "initial_submit", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts new file mode 100644 index 00000000..269af37a --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -0,0 +1,535 @@ +import type { + BrowserCreateWorkerResponse, + BrowserWorkerWorkingDirectorySelection, + BrowserWorkspaceOrchestratorResponse, + CreateWorkspaceWorkerRequest, + CreateWorkspaceWorkerTicketAssignmentRequest, + Diagnostic, + DiagnosticSeverity, + RuntimeWorkingDirectoryCleanupTarget, + RuntimeWorkingDirectorySummary, + WorkerCapabilitySummary, + WorkerImplementationSummary, + WorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption, + WorkerLaunchWorkerSummary, + WorkerWorkspaceSummary, + WorkingDirectoryRepositoryOption, +} from "$lib/generated/worker-launch-api"; +import type { Segment } from "$lib/generated/protocol"; +import { parseWorkingDirectorySummary } from "$lib/workspace/api/workdirs"; + +const DIAGNOSTIC_SEVERITIES = new Set([ + "info", + "warning", + "error", +]); + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + allowed: readonly string[], + label: string, +): void { + const unexpected = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unexpected.length > 0) { + throw new Error(`${label} contains unknown field ${unexpected[0]}`); + } +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function number(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null ? null : string(value, label); +} + +function array( + value: unknown, + label: string, + parse: (item: unknown, label: string) => T, +): T[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map((item, index) => parse(item, `${label}[${index}]`)); +} + +function optional( + value: unknown, + label: string, + parse: (item: unknown, label: string) => T, +): T | null | undefined { + return value === undefined + ? undefined + : value === null + ? null + : parse(value, label); +} + +function diagnostic(value: unknown, label: string): Diagnostic { + const item = record(value, label); + exact(item, ["code", "severity", "message"], label); + const severity = string(item.severity, `${label}.severity`); + if (!DIAGNOSTIC_SEVERITIES.has(severity as DiagnosticSeverity)) { + throw new Error(`${label}.severity is invalid`); + } + return { + code: string(item.code, `${label}.code`), + severity: severity as DiagnosticSeverity, + message: string(item.message, `${label}.message`), + }; +} + +function runtimeOption( + value: unknown, + label: string, +): WorkerLaunchRuntimeOption { + const item = record(value, label); + exact( + item, + [ + "runtime_id", + "display_name", + "built_in", + "worker_creation_available", + "working_directory_required", + "status", + "diagnostics", + ], + label, + ); + return { + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + display_name: string(item.display_name, `${label}.display_name`), + built_in: boolean(item.built_in, `${label}.built_in`), + worker_creation_available: boolean( + item.worker_creation_available, + `${label}.worker_creation_available`, + ), + working_directory_required: boolean( + item.working_directory_required, + `${label}.working_directory_required`, + ), + status: string(item.status, `${label}.status`), + diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic), + }; +} + +function profileCandidate( + value: unknown, + label: string, +): WorkerLaunchProfileCandidate { + const item = record(value, label); + exact(item, ["id", "label", "description"], label); + return { + id: string(item.id, `${label}.id`), + label: string(item.label, `${label}.label`), + description: string(item.description, `${label}.description`), + }; +} + +function repositoryOption( + value: unknown, + label: string, +): WorkingDirectoryRepositoryOption { + const item = record(value, label); + exact(item, ["repository_key", "default_selector"], label); + return { + repository_key: string(item.repository_key, `${label}.repository_key`), + default_selector: optional( + item.default_selector, + `${label}.default_selector`, + string, + ), + }; +} + +export function parseWorkerLaunchOptionsResponse( + value: unknown, +): WorkerLaunchOptionsResponse { + const item = record(value, "Worker launch options response"); + exact( + item, + [ + "workspace_id", + "runtimes", + "default_profile", + "profiles", + "repositories", + "working_directories", + "diagnostics", + ], + "Worker launch options response", + ); + return { + workspace_id: string(item.workspace_id, "workspace_id"), + runtimes: array(item.runtimes, "runtimes", runtimeOption), + default_profile: nullableString(item.default_profile, "default_profile"), + profiles: array(item.profiles, "profiles", profileCandidate), + repositories: array(item.repositories, "repositories", repositoryOption), + working_directories: array( + item.working_directories, + "working_directories", + parseWorkingDirectorySummary, + ), + diagnostics: array(item.diagnostics, "diagnostics", diagnostic), + }; +} + +function workspaceSummary( + value: unknown, + label: string, +): WorkerWorkspaceSummary { + const item = record(value, label); + exact(item, ["visibility", "identity", "workspace_id"], label); + return { + visibility: string(item.visibility, `${label}.visibility`), + identity: string(item.identity, `${label}.identity`), + workspace_id: optional(item.workspace_id, `${label}.workspace_id`, string), + }; +} + +function implementationSummary( + value: unknown, + label: string, +): WorkerImplementationSummary { + const item = record(value, label); + exact(item, ["kind", "display_hint"], label); + return { + kind: string(item.kind, `${label}.kind`), + display_hint: string(item.display_hint, `${label}.display_hint`), + }; +} + +function capabilitySummary( + value: unknown, + label: string, +): WorkerCapabilitySummary { + const item = record(value, label); + exact(item, ["can_stop", "can_spawn_followup"], label); + return { + can_stop: boolean(item.can_stop, `${label}.can_stop`), + can_spawn_followup: boolean( + item.can_spawn_followup, + `${label}.can_spawn_followup`, + ), + }; +} + +function runtimeCleanupTarget( + value: unknown, + label: string, +): RuntimeWorkingDirectoryCleanupTarget { + const item = record(value, label); + exact(item, ["kind", "working_directory_id", "repository_id"], label); + return { + kind: string(item.kind, `${label}.kind`), + working_directory_id: string( + item.working_directory_id, + `${label}.working_directory_id`, + ), + repository_id: string(item.repository_id, `${label}.repository_id`), + }; +} + +function runtimeWorkingDirectory( + value: unknown, + label: string, +): RuntimeWorkingDirectorySummary { + const item = record(value, label); + exact( + item, + [ + "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", + ], + label, + ); + const materializerKind = string( + item.materializer_kind, + `${label}.materializer_kind`, + ); + if ( + materializerKind !== "runtime_git_cache" && + materializerKind !== "local_git_worktree" + ) { + throw new Error(`${label}.materializer_kind is invalid`); + } + const status = string(item.status, `${label}.status`); + if ( + !["active", "cleanup_pending", "corrupted", "not_found", "unknown"] + .includes(status) + ) { + throw new Error(`${label}.status is invalid`); + } + const occupied = optional( + item.occupied_by, + `${label}.occupied_by`, + (value, occupiedLabel) => { + const occupancy = record(value, occupiedLabel); + exact( + occupancy, + ["runtime_id", "worker_id", "display_name", "linked_at"], + occupiedLabel, + ); + return { + runtime_id: string(occupancy.runtime_id, `${occupiedLabel}.runtime_id`), + worker_id: string(occupancy.worker_id, `${occupiedLabel}.worker_id`), + display_name: string( + occupancy.display_name, + `${occupiedLabel}.display_name`, + ), + linked_at: string(occupancy.linked_at, `${occupiedLabel}.linked_at`), + }; + }, + ); + return { + working_directory_id: string( + item.working_directory_id, + `${label}.working_directory_id`, + ), + repository_id: string(item.repository_id, `${label}.repository_id`), + creation_selector: optional( + item.creation_selector, + `${label}.creation_selector`, + string, + ), + creation_ref: optional(item.creation_ref, `${label}.creation_ref`, string), + creation_tree: optional( + item.creation_tree, + `${label}.creation_tree`, + string, + ), + current_selector: optional( + item.current_selector, + `${label}.current_selector`, + string, + ), + current_ref: optional(item.current_ref, `${label}.current_ref`, string), + current_tree: optional(item.current_tree, `${label}.current_tree`, string), + observed_at_epoch_seconds: optional( + item.observed_at_epoch_seconds, + `${label}.observed_at_epoch_seconds`, + number, + ), + materializer_kind: materializerKind, + cleanup_target: optional( + item.cleanup_target, + `${label}.cleanup_target`, + runtimeCleanupTarget, + ), + status: status as RuntimeWorkingDirectorySummary["status"], + cleanliness: optional(item.cleanliness, `${label}.cleanliness`, string), + primary_worker_id: optional( + item.primary_worker_id, + `${label}.primary_worker_id`, + string, + ), + occupied_by: occupied, + }; +} + +function workerSummary( + value: unknown, + label: string, +): WorkerLaunchWorkerSummary { + const item = record(value, label); + exact( + item, + [ + "runtime_id", + "worker_id", + "host_id", + "display_name", + "label", + "profile", + "singleton_key", + "tags", + "workspace", + "state", + "last_seen_at", + "pinned", + "retention_state", + "implementation", + "capabilities", + "working_directory", + "diagnostics", + ], + label, + ); + return { + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + host_id: string(item.host_id, `${label}.host_id`), + display_name: string(item.display_name, `${label}.display_name`), + label: string(item.label, `${label}.label`), + profile: nullableString(item.profile, `${label}.profile`), + singleton_key: nullableString(item.singleton_key, `${label}.singleton_key`), + tags: array(item.tags, `${label}.tags`, string), + workspace: workspaceSummary(item.workspace, `${label}.workspace`), + state: string(item.state, `${label}.state`), + last_seen_at: nullableString(item.last_seen_at, `${label}.last_seen_at`), + pinned: boolean(item.pinned, `${label}.pinned`), + retention_state: string(item.retention_state, `${label}.retention_state`), + implementation: implementationSummary( + item.implementation, + `${label}.implementation`, + ), + capabilities: capabilitySummary(item.capabilities, `${label}.capabilities`), + working_directory: optional( + item.working_directory, + `${label}.working_directory`, + runtimeWorkingDirectory, + ), + diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic), + }; +} + +export function parseBrowserCreateWorkerResponse( + value: unknown, +): BrowserCreateWorkerResponse { + const item = record(value, "Worker create response"); + exact( + item, + [ + "workspace_id", + "runtime_id", + "worker_id", + "console_href", + "worker", + "diagnostics", + ], + "Worker create response", + ); + return { + workspace_id: string(item.workspace_id, "workspace_id"), + runtime_id: string(item.runtime_id, "runtime_id"), + worker_id: string(item.worker_id, "worker_id"), + console_href: string(item.console_href, "console_href"), + worker: workerSummary(item.worker, "worker"), + diagnostics: array(item.diagnostics, "diagnostics", diagnostic), + }; +} + +export function parseBrowserWorkspaceOrchestratorResponse( + value: unknown, +): BrowserWorkspaceOrchestratorResponse { + const item = record(value, "Workspace Orchestrator response"); + exact( + item, + ["workspace_id", "online", "disposition", "worker", "diagnostics"], + "Workspace Orchestrator response", + ); + return { + workspace_id: string(item.workspace_id, "workspace_id"), + online: boolean(item.online, "online"), + disposition: string(item.disposition, "disposition"), + worker: optional(item.worker, "worker", workerSummary), + diagnostics: array(item.diagnostics, "diagnostics", diagnostic), + }; +} + +function workingDirectorySelection( + value: unknown, + label: string, +): BrowserWorkerWorkingDirectorySelection { + const item = record(value, label); + exact(item, ["working_directory_id", "relative_cwd"], label); + return { + working_directory_id: string( + item.working_directory_id, + `${label}.working_directory_id`, + ), + relative_cwd: nullableString(item.relative_cwd, `${label}.relative_cwd`), + }; +} + +function ticketAssignment( + value: unknown, + label: string, +): CreateWorkspaceWorkerTicketAssignmentRequest { + const item = record(value, label); + exact(item, ["ticket_id", "operation_id"], label); + return { + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + operation_id: string(item.operation_id, `${label}.operation_id`), + }; +} + +function segment(value: unknown, label: string): Segment { + const item = record(value, label); + const kind = string(item.kind, `${label}.kind`); + if (kind === "text") { + exact(item, ["kind", "content"], label); + return { kind, content: string(item.content, `${label}.content`) }; + } + throw new Error(`${label}.kind is not supported by Worker creation`); +} + +export function parseCreateWorkspaceWorkerRequest( + value: unknown, +): CreateWorkspaceWorkerRequest { + const item = record(value, "Worker create request"); + exact( + item, + [ + "runtime_id", + "display_name", + "profile", + "ticket_assignment", + "initial_submit", + "working_directory", + "control_operation_id", + ], + "Worker create request", + ); + return { + runtime_id: string(item.runtime_id, "runtime_id"), + display_name: string(item.display_name, "display_name"), + profile: nullableString(item.profile, "profile"), + ticket_assignment: item.ticket_assignment === null + ? null + : ticketAssignment(item.ticket_assignment, "ticket_assignment"), + initial_submit: array(item.initial_submit, "initial_submit", segment), + working_directory: item.working_directory === null + ? null + : workingDirectorySelection(item.working_directory, "working_directory"), + control_operation_id: nullableString( + item.control_operation_id, + "control_operation_id", + ), + }; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 2076d719..ec758cd7 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -1,3 +1,12 @@ +import type { + BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse, + BrowserWorkerWorkingDirectorySelection + as SharedBrowserWorkerWorkingDirectorySelection, + WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption, + WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption, +} from "$lib/generated/worker-launch-api"; import type { WorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse, @@ -101,26 +110,10 @@ export type Worker = { export type WorkerOperationState = "accepted" | "unsupported" | "rejected"; -export type WorkerLaunchRuntimeOption = { - runtime_id: string; - display_name: string; - built_in: boolean; - worker_creation_available: boolean; - working_directory_required: boolean; - status: string; - diagnostics: Diagnostic[]; -}; - -export type WorkerLaunchProfileCandidate = { - id: string; - label: string; - description: string; -}; - -export type WorkingDirectoryRepositoryOption = { - repository_key: string; - default_selector?: string | null; -}; +export type WorkerLaunchRuntimeOption = SharedWorkerLaunchRuntimeOption; +export type WorkerLaunchProfileCandidate = SharedWorkerLaunchProfileCandidate; +export type WorkingDirectoryRepositoryOption = + SharedWorkingDirectoryRepositoryOption; export type CleanupTargetKind = | "worker_delete" @@ -185,29 +178,10 @@ export type RuntimeCleanupExecutionResponse = { diagnostics: Diagnostic[]; }; -export type BrowserWorkerWorkingDirectorySelection = { - working_directory_id: string; - relative_cwd?: string | null; -}; - -export type WorkerLaunchOptionsResponse = { - workspace_id: string; - runtimes: WorkerLaunchRuntimeOption[]; - default_profile?: string | null; - profiles: WorkerLaunchProfileCandidate[]; - repositories: WorkingDirectoryRepositoryOption[]; - working_directories: WorkingDirectorySummary[]; - diagnostics: Diagnostic[]; -}; - -export type BrowserCreateWorkerResponse = { - workspace_id: string; - runtime_id: string; - worker_id: string; - console_href: string; - worker: Worker; - diagnostics: Diagnostic[]; -}; +export type BrowserWorkerWorkingDirectorySelection = + SharedBrowserWorkerWorkingDirectorySelection; +export type WorkerLaunchOptionsResponse = SharedWorkerLaunchOptionsResponse; +export type BrowserCreateWorkerResponse = SharedBrowserCreateWorkerResponse; export type WorkerInputResult = { state: WorkerOperationState; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index a52ce503..c5dfe6f1 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -196,11 +196,13 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and rela runtime_id: "embedded", display_name: "Worker", profile: "builtin:coder", + ticket_assignment: null, initial_submit: [{ kind: "text", content: "go" }], working_directory: { working_directory_id: "wd-1-repo", relative_cwd: "crates/yoi", }, + control_operation_id: null, }); }); @@ -219,7 +221,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an em assertEquals(request.initial_submit, []); }); -Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedded no-workdir launches", () => { +Deno.test("buildCreateWorkspaceWorkerRequest emits null for embedded no-workdir launches", () => { const request = buildCreateWorkspaceWorkerRequest({ runtime_id: "embedded", display_name: "Worker", @@ -235,6 +237,9 @@ Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedde runtime_id: "embedded", display_name: "Worker", profile: "builtin:companion", + ticket_assignment: null, initial_submit: [{ kind: "text", content: "chat" }], + working_directory: null, + control_operation_id: null, }); }); diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 2926bc1e..ed9adbb3 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -1,9 +1,7 @@ -import type { Segment } from "$lib/generated/protocol"; +import type { CreateWorkspaceWorkerRequest } from "$lib/generated/worker-launch-api"; +import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers"; -import type { - BrowserWorkerWorkingDirectorySelection, - WorkerLaunchOptionsResponse, -} from "./types"; +import type { WorkerLaunchOptionsResponse } from "./types"; export type WorkerLaunchFormState = { runtime_id: string; @@ -16,14 +14,6 @@ export type WorkerLaunchFormState = { relative_cwd: string; }; -export type CreateWorkspaceWorkerRequest = { - runtime_id: string; - display_name: string; - profile: string; - initial_submit: Segment[]; - working_directory?: BrowserWorkerWorkingDirectorySelection; -}; - export function defaultWorkerLaunchForm( options: WorkerLaunchOptionsResponse | null, current: WorkerLaunchFormState, @@ -86,7 +76,8 @@ export function defaultWorkerLaunchForm( ) ? current.working_directory_id : preferredWorkingDirectory?.working_directory_id || "", - working_directory_repository_key: current.working_directory_repository_key || + working_directory_repository_key: + current.working_directory_repository_key || preferredRepository?.repository_key || "", working_directory_selector: current.working_directory_selector || preferredRepository?.default_selector || "HEAD", @@ -97,22 +88,21 @@ export function defaultWorkerLaunchForm( export function buildCreateWorkspaceWorkerRequest( form: WorkerLaunchFormState, ): CreateWorkspaceWorkerRequest { - const request: CreateWorkspaceWorkerRequest = { - runtime_id: form.runtime_id, - display_name: form.display_name, - profile: form.profile, - initial_submit: form.initial_text.trim() + const initialMessage = form.initial_text.trim(); + return parseCreateWorkspaceWorkerRequest({ + runtime_id: form.runtime_id.trim(), + display_name: form.display_name.trim(), + profile: form.profile.trim() || null, + ticket_assignment: null, + initial_submit: initialMessage ? [{ kind: "text", content: form.initial_text }] : [], - }; - if (form.working_directory_id) { - request.working_directory = { - working_directory_id: form.working_directory_id, - }; - const relativeCwd = form.relative_cwd.trim(); - if (relativeCwd) { - request.working_directory.relative_cwd = relativeCwd; - } - } - return request; + working_directory: form.working_directory_id + ? { + working_directory_id: form.working_directory_id, + relative_cwd: form.relative_cwd.trim() || null, + } + : null, + control_operation_id: null, + }); } diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 0df64ebc..428a9f1d 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,3 +1,4 @@ +import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api"; import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api"; export const TICKET_STATES = [ @@ -12,22 +13,7 @@ export const TICKET_STATES = [ export type TicketState = (typeof TICKET_STATES)[number]; export type TicketWorkerRole = "coder" | "reviewer"; -export type WorkspaceOrchestratorStatus = { - workspace_id: string; - online: boolean; - disposition: string; - worker?: { - runtime_id: string; - worker_id: string; - state: string; - display_name: string; - } | null; - diagnostics: Array<{ - code: string; - severity: string; - message: string; - }>; -}; +export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse; const LANE_DEFINITIONS = [ { diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index f20a36d1..a0d34adf 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -2,6 +2,7 @@ import { untrack } from "svelte"; import type { ApiResult } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; + import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers"; import type { QueryPage, TicketListResponse, @@ -99,6 +100,7 @@ fetch, workspaceApiPath(data.workspaceId, "/orchestrator"), { method: "POST" }, + parseBrowserWorkspaceOrchestratorResponse, ); orchestratorStarting = false; } diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index 04ab28e8..2717de97 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -1,3 +1,4 @@ +import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import type { TicketListResponse } from "$lib/generated/ticket-api"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; @@ -42,6 +43,8 @@ export const load: PageLoad = async ({ fetch, params }) => { loadJson( fetch, workspaceApiPath(workspaceId, "/orchestrator"), + undefined, + parseBrowserWorkspaceOrchestratorResponse, ), ]); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 5ced53cd..bf500233 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -6,10 +6,13 @@ parseWorkingDirectoryCreateResponse, validateWorkingDirectoryCreateRequest, } from '$lib/workspace/api/workdirs'; + import { + parseBrowserCreateWorkerResponse, + parseWorkerLaunchOptionsResponse, + } from '$lib/workspace/api/workers'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch'; import type { - BrowserCreateWorkerResponse, Diagnostic, WorkerLaunchOptionsResponse, WorkingDirectorySummary, @@ -115,7 +118,7 @@ if (!response.ok) { throw new Error(`worker launch options failed (${response.status})`); } - const payload = (await response.json()) as WorkerLaunchOptionsResponse; + const payload = parseWorkerLaunchOptionsResponse(await response.json()); options = payload; const form = defaultWorkerLaunchForm(payload, { runtime_id: runtimeId, @@ -229,7 +232,7 @@ submitError = await responseDisplayError(response, 'worker create failed'); return; } - const payload = (await response.json()) as BrowserCreateWorkerResponse; + const payload = parseBrowserCreateWorkerResponse(await response.json()); await goto(payload.console_href); } catch (err) { submitError = exceptionDisplayError(err, 'worker create failed');