diff --git a/Cargo.lock b/Cargo.lock index 8b0007c5..ed22fa7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6683,6 +6683,7 @@ dependencies = [ "serde", "serde_json", "ts-rs", + "url", ] [[package]] diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index b373747f..164f5e8b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -38,5 +38,10 @@ pub use target::{ WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, }; pub use worker_client::WorkerClient; -pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; +pub use workspace_api::{ + CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, + CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, + CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole, + CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary, +}; pub use workspace_product::BackendWorkspaceProductClient; diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index a052ffe0..91ebc645 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -12,6 +12,7 @@ typescript = ["dep:ts-rs"] [dependencies] serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } +url.workspace = true [dev-dependencies] serde_json.workspace = true @@ -19,3 +20,11 @@ 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/src/lib.rs b/crates/workspace-api/src/lib.rs index 37b33cf5..3dd8aba3 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -465,6 +465,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 { @@ -477,6 +478,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, @@ -486,6 +488,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, @@ -571,6 +704,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, @@ -578,6 +712,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, @@ -587,6 +722,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, @@ -598,9 +734,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)] @@ -608,14 +746,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, @@ -625,6 +766,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, @@ -633,6 +775,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, @@ -641,17 +784,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, @@ -659,6 +807,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, @@ -669,14 +818,43 @@ 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 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; @@ -706,7 +884,7 @@ pub fn workdir_api_typescript() -> String { } #[cfg(all(test, feature = "typescript"))] -mod typescript_tests { +mod workdir_typescript_tests { #[test] fn generated_workdir_api_contract_is_current() { let expected = super::workdir_api_typescript(); @@ -756,6 +934,161 @@ 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"}); @@ -849,3 +1182,50 @@ mod tests { assert!(serde_json::from_value::(stale).is_err()); } } + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_repository_access_contract_is_current() { + let expected = super::repository_access_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/repository-access-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file", + ); + } + + #[test] + fn generated_repository_access_responses_remain_secret_free() { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + for declaration in [ + super::RepositorySshCredential::decl(&config), + super::RepositorySshHostTrust::decl(&config), + super::RepositoryAccessProjection::decl(&config), + ] { + for forbidden in ["private_key", "passphrase", "secret_ref"] { + assert!( + !declaration.contains(forbidden), + "Repository Access response declaration must not expose `{forbidden}`" + ); + } + } + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect() + } +} diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 4a605bf9..08271a74 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -1,77 +1,14 @@ -use serde::{Deserialize, Serialize}; +use workspace_api::{ + CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic, + DiagnosticSeverity, +}; -use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CompanionState { - Disabled, - Rejected, - Cancelled, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionStatusResponse { - pub state: CompanionState, - #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, - pub transport: CompanionTransportSummary, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionTransportSummary { - pub kind: String, - pub completion: String, - pub limitation: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CompanionMessageRequest { - pub content: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] -pub struct CompanionCancelRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionMessageResponse { - pub state: CompanionState, - #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_item: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub assistant_item: Option, - pub transcript: CompanionTranscriptProjection, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionTranscriptProjection { - pub state: CompanionState, - pub start: usize, - pub limit: usize, - pub total_items: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_start: Option, - pub items: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CompanionTranscriptItem { - pub sequence: u64, - pub role: String, - pub content: String, - pub created_at: String, - pub source: String, - pub status: String, -} +pub use workspace_api::{ + CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse, + CompanionStatusResponse, CompanionTranscriptProjection, +}; +#[derive(Clone, Default)] pub struct CompanionConsole; impl CompanionConsole { @@ -81,68 +18,50 @@ impl CompanionConsole { pub fn status(&self) -> CompanionStatusResponse { CompanionStatusResponse { - state: CompanionState::Disabled, + state: CompanionLifecycleState::Stopped, worker: None, - transport: disabled_transport(), + transport: CompanionTransportSummary { + mode: "disabled".to_string(), + available: false, + }, diagnostics: vec![disabled_diagnostic()], } } pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection { CompanionTranscriptProjection { - state: CompanionState::Disabled, + state: CompanionLifecycleState::Stopped, start, limit, - total_items: 0, - next_start: None, + total: 0, + next: None, items: Vec::new(), - diagnostics: vec![disabled_diagnostic()], } } pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse { - disabled_message_response(CompanionState::Rejected) + disabled_message_response() } pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse { - disabled_message_response(CompanionState::Cancelled) + disabled_message_response() } } -fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse { +fn disabled_message_response() -> CompanionMessageResponse { CompanionMessageResponse { - state, - worker: None, - user_item: None, - assistant_item: None, - transcript: CompanionTranscriptProjection { - state: CompanionState::Disabled, - start: 0, - limit: 200, - total_items: 0, - next_start: None, - items: Vec::new(), - diagnostics: vec![disabled_diagnostic()], - }, - diagnostics: vec![disabled_diagnostic()], - } -} - -fn disabled_transport() -> CompanionTransportSummary { - CompanionTransportSummary { - kind: "none".to_string(), - completion: "disabled".to_string(), - limitation: - "Workspace Companion auto-start has been removed; create an explicit Worker instead." - .to_string(), - } -} - -fn disabled_diagnostic() -> RuntimeDiagnostic { - RuntimeDiagnostic { - code: "companion_disabled".to_string(), - severity: DiagnosticSeverity::Info, - message: "Workspace Companion auto-start is disabled; create an explicit Worker instead." + state: CompanionMessageDisposition::Rejected, + message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead." .to_string(), } } + +fn disabled_diagnostic() -> Diagnostic { + Diagnostic { + code: "companion_disabled".to_string(), + severity: DiagnosticSeverity::Info, + message: + "Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead." + .to_string(), + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8adb5765..aaa1949e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -11085,33 +11085,43 @@ async fn get_workspace(State(api): State) -> 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." - ), + 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, + status: extension_status, note, - diagnostics: status.diagnostics.clone(), + diagnostics: status + .diagnostics + .iter() + .map(|diagnostic| RuntimeDiagnostic { + code: diagnostic.code.clone(), + severity: match diagnostic.severity { + workspace_api::DiagnosticSeverity::Info => DiagnosticSeverity::Info, + workspace_api::DiagnosticSeverity::Warning => DiagnosticSeverity::Warning, + workspace_api::DiagnosticSeverity::Error => DiagnosticSeverity::Error, + }, + message: diagnostic.message.clone(), + }) + .collect(), } } @@ -24209,10 +24219,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( @@ -24222,16 +24232,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!( @@ -24336,7 +24356,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" @@ -24345,12 +24365,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( @@ -24360,13 +24381,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!( diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 2d157d72..fcd86546 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 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 src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts new file mode 100644 index 00000000..ad25a30e --- /dev/null +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -0,0 +1,64 @@ +// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`. +// Do not edit manually. + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; + +export type WorkspaceWorkerSubject = { + "kind": "runtime_worker"; + runtime_id: string; + worker_id: string; +}; + +export type WorkspaceWorkerDiscoveryItem = { + subject: WorkspaceWorkerSubject; + resource_key: string; + display_name: string; + profile: string | null; + status?: string | null; +}; + +export type CompanionLifecycleState = "idle" | "running" | "stopped"; + +export type CompanionMessageDisposition = "accepted" | "rejected"; + +export type CompanionTransportSummary = { mode: string; available: boolean }; + +export type CompanionStatusResponse = { + state: CompanionLifecycleState; + worker: WorkspaceWorkerDiscoveryItem | null; + transport: CompanionTransportSummary; + diagnostics: Array; +}; + +export type CompanionMessageRequest = { content: string }; + +export type CompanionCancelRequest = { reason?: string | null }; + +export type CompanionMessageResponse = { + state: CompanionMessageDisposition; + message: string; +}; + +export type CompanionTranscriptRole = "user" | "assistant"; + +export type CompanionTranscriptItem = { + sequence: number; + role: CompanionTranscriptRole; + content: string; + created_at: string; +}; + +export type CompanionTranscriptProjection = { + state: CompanionLifecycleState; + start: number; + limit: number; + total: number; + next: number | null; + items: Array; +}; diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts new file mode 100644 index 00000000..fa2f9d44 --- /dev/null +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -0,0 +1,79 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts + +export type RepositorySshCredential = { + credential_id: string; + workspace_id: string; + name: string; + public_key_algorithm: string; + public_key_fingerprint: string; + current_revision: number; + status: string; + created_at: string; + rotated_at: string | null; + referenced_repositories: Array; +}; + +export type CreateRepositorySshCredentialRequest = { + operation_id: string; + credential_id: string; + name: string; + private_key: string; + passphrase: string | null; +}; + +export type RotateRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; + private_key: string; + passphrase: string | null; +}; + +export type DeleteRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositorySshHostTrust = { + host_trust_id: string; + workspace_id: string; + hostname: string; + port: number; + key_algorithm: string; + host_key: string; + fingerprint: string; + current_revision: number; + created_at: string; + updated_at: string; + referenced_repositories: Array; +}; + +export type PutRepositorySshHostTrustRequest = { + operation_id: string; + host_trust_id: string; + hostname: string; + port: number; + host_key: string; + expected_revision: number | null; +}; + +export type DeleteRepositorySshHostTrustRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositoryAccessMode = "read_only" | "read_write"; + +export type RepositorySshAccessBinding = { + repository_id: string; + credential_id: string; + host_trust_id: string; + access: RepositoryAccessMode; +}; + +export type RepositoryAccessProjection = { + workspace_id: string; + config_revision: number; + projection_digest: string; + bindings: Array; +}; diff --git a/web/workspace/src/lib/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/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 e69ae0be..6673d092 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -422,56 +422,15 @@ export type ObjectiveListResponse = { record_authority: string; }; -export type CompanionState = - | "ready" - | "busy" - | "error" - | "timeout" - | "cancelled" - | "accepted" - | "rejected"; - -export type CompanionTransportSummary = { - kind: string; - completion: string; - limitation: string; -}; - -export type CompanionStatusResponse = { - state: CompanionState; - worker?: Worker | null; - transport: CompanionTransportSummary; - diagnostics: Diagnostic[]; -}; - -export type CompanionTranscriptItem = { - sequence: number; - role: "user" | "assistant" | "system" | string; - content: string; - created_at: string; - source: string; - status: string; -}; - -export type CompanionTranscriptProjection = { - state: CompanionState; - start: number; - limit: number; - total_items: number; - next_start?: number | null; - items: CompanionTranscriptItem[]; - diagnostics: Diagnostic[]; -}; - -export type CompanionMessageRequest = { - content: string; -}; - -export type CompanionMessageResponse = { - state: CompanionState; - worker?: Worker | null; - user_item?: CompanionTranscriptItem | null; - assistant_item?: CompanionTranscriptItem | null; - transcript: CompanionTranscriptProjection; - diagnostics: Diagnostic[]; -}; +export type { + CompanionCancelRequest, + CompanionLifecycleState, + CompanionMessageDisposition, + CompanionMessageRequest, + CompanionMessageResponse, + CompanionStatusResponse, + CompanionTranscriptItem, + CompanionTranscriptProjection, + CompanionTranscriptRole, + CompanionTransportSummary, +} from "$lib/generated/companion-api"; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index 3852a6fc..f5603349 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -1,11 +1,24 @@