diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 74b1a0df..4a605bf9 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -1,35 +1,13 @@ -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -use chrono::Utc; use serde::{Deserialize, Serialize}; -use worker_runtime::catalog::ProfileSelector; -use worker_runtime::config_bundle::{ - ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, -}; -use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; -use crate::hosts::{ - DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest, - WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, - WorkerSummary, WorkerTranscriptItem as RuntimeTranscriptItem, WorkerTranscriptProjection, -}; - -const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime"; -const COMPANION_PROFILE_ID: &str = "builtin:companion"; -const COMPANION_CONFIG_BUNDLE_ID: &str = "workspace-companion-config"; -const MAX_MESSAGE_CHARS: usize = 8_000; +use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CompanionState { - Ready, - Busy, - Error, - Timeout, - Cancelled, - Accepted, + Disabled, Rejected, + Cancelled, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -94,698 +72,77 @@ pub struct CompanionTranscriptItem { pub status: String, } -#[derive(Debug)] -struct CompanionWorkerState { - state: CompanionState, - worker: Option, - diagnostics: Vec, -} - -pub struct CompanionConsole { - runtime: Arc, - worker: Mutex, -} +pub struct CompanionConsole; impl CompanionConsole { - pub fn new(runtime: Arc) -> Self { - let initial = spawn_companion_worker(&runtime); - Self { - runtime, - worker: Mutex::new(initial), - } + pub fn disabled() -> Self { + Self } pub fn status(&self) -> CompanionStatusResponse { - match self.refresh_worker_state() { - Ok(worker) => CompanionStatusResponse { - state: worker.state, - worker: worker.worker.clone(), - transport: companion_transport(worker.worker.as_ref()), - diagnostics: worker.diagnostics.clone(), - }, - Err(diagnostic) => CompanionStatusResponse { - state: CompanionState::Error, - worker: None, - transport: companion_transport(None), - diagnostics: vec![diagnostic], - }, + CompanionStatusResponse { + state: CompanionState::Disabled, + worker: None, + transport: disabled_transport(), + diagnostics: vec![disabled_diagnostic()], } } pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection { - match self.current_worker() { - Ok(Some(worker)) => { - match self - .runtime - .transcript(COMPANION_RUNTIME_ID, &worker.worker_id, start, limit) - { - Ok(transcript) => project_runtime_transcript( - &transcript, - companion_state_for_worker(&worker), - Vec::new(), - ), - Err(error) => CompanionTranscriptProjection { - state: CompanionState::Error, - start, - limit, - total_items: 0, - next_start: None, - items: Vec::new(), - diagnostics: vec![diagnostic( - "companion_transcript_unavailable", - DiagnosticSeverity::Error, - format!("Companion Worker transcript is unavailable: {error:?}"), - )], - }, - } - } - Ok(None) => CompanionTranscriptProjection { - state: CompanionState::Error, - start, - limit, - total_items: 0, - next_start: None, - items: Vec::new(), - diagnostics: vec![diagnostic( - "companion_worker_unavailable", - DiagnosticSeverity::Error, - "Workspace Companion Worker is unavailable", - )], - }, - Err(diagnostic) => CompanionTranscriptProjection { - state: CompanionState::Error, - start, - limit, - total_items: 0, - next_start: None, - items: Vec::new(), - diagnostics: vec![diagnostic], - }, + CompanionTranscriptProjection { + state: CompanionState::Disabled, + start, + limit, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![disabled_diagnostic()], } } - pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse { - let content = request.content.trim().to_string(); - if content.is_empty() { - return self.rejected_message_response(diagnostic( - "companion_message_empty", - DiagnosticSeverity::Warning, - "Companion message content is empty", - )); - } - if content.chars().count() > MAX_MESSAGE_CHARS { - return self.rejected_message_response(diagnostic( - "companion_message_too_large", - DiagnosticSeverity::Warning, - format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"), - )); - } - - let worker = match self.current_worker() { - Ok(Some(worker)) => worker, - Ok(None) => { - return self.rejected_message_response(diagnostic( - "companion_worker_unavailable", - DiagnosticSeverity::Error, - "Workspace Companion Worker is unavailable", - )); - } - Err(diagnostic) => return self.rejected_message_response(diagnostic), - }; - - let response = self.runtime.send_input( - COMPANION_RUNTIME_ID, - &worker.worker_id, - WorkerInputRequest { - kind: WorkerInputKind::User, - content: content.clone(), - }, - ); - - match response { - Ok(result) => { - let state = match result.state { - WorkerOperationState::Accepted => CompanionState::Accepted, - WorkerOperationState::Unsupported | WorkerOperationState::Rejected => { - CompanionState::Rejected - } - }; - let diagnostics = if result.diagnostics.is_empty() { - Vec::new() - } else { - result.diagnostics.clone() - }; - let projection = self.transcript(0, 200); - CompanionMessageResponse { - state, - worker: projection_worker(&self.status()), - user_item: projection - .items - .iter() - .rev() - .find(|item| item.role == "user" && item.content == content) - .cloned(), - assistant_item: projection - .items - .iter() - .rev() - .find(|item| item.role == "assistant") - .cloned(), - transcript: projection, - diagnostics, - } - } - Err(error) => self.rejected_message_response(diagnostic( - "companion_worker_input_failed", - DiagnosticSeverity::Error, - format!("Companion Worker input dispatch failed: {error:?}"), - )), - } + pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse { + disabled_message_response(CompanionState::Rejected) } pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse { - let diagnostics = vec![diagnostic( - "companion_cancel_no_active_run", - DiagnosticSeverity::Info, - "Workspace Companion has no active generation to cancel", - )]; - let status = self.status(); - let projection = self.transcript(0, 200); - CompanionMessageResponse { - state: CompanionState::Cancelled, - worker: status.worker, - user_item: None, - assistant_item: projection - .items - .iter() - .rev() - .find(|item| item.role == "assistant") - .cloned(), - transcript: projection, - diagnostics, - } - } - - fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse { - let status = self.status(); - let projection = self.transcript(0, 200); - CompanionMessageResponse { - state: CompanionState::Rejected, - worker: status.worker, - user_item: None, - assistant_item: projection - .items - .iter() - .rev() - .find(|item| item.role == "assistant") - .cloned(), - transcript: projection, - diagnostics: vec![diagnostic], - } - } - - fn current_worker(&self) -> Result, RuntimeDiagnostic> { - self.refresh_worker_state() - .map(|state| state.worker.clone()) - } - - fn refresh_worker_state(&self) -> Result { - let mut state = self.worker.lock().map_err(|_| { - diagnostic( - "companion_state_unavailable", - DiagnosticSeverity::Error, - "Companion state is unavailable", - ) - })?; - let Some(worker_id) = state.worker.as_ref().map(|worker| worker.worker_id.clone()) else { - return Ok(CompanionWorkerState { - state: state.state, - worker: None, - diagnostics: state.diagnostics.clone(), - }); - }; - - match self.runtime.worker(COMPANION_RUNTIME_ID, &worker_id) { - Ok(worker) => { - let mut diagnostics = if worker.capabilities.can_accept_input { - Vec::new() - } else { - state.diagnostics.clone() - }; - if !worker.capabilities.can_accept_input - && !diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable") - { - diagnostics.push(companion_not_input_capable_diagnostic(&worker)); - } - state.state = companion_state_for_worker(&worker); - state.worker = Some(worker); - state.diagnostics = diagnostics; - } - Err(error) => { - state.state = CompanionState::Error; - state.diagnostics = vec![diagnostic( - "companion_worker_lookup_failed", - DiagnosticSeverity::Error, - format!("Companion Worker lookup failed: {error:?}"), - )]; - } - } - - Ok(CompanionWorkerState { - state: state.state, - worker: state.worker.clone(), - diagnostics: state.diagnostics.clone(), - }) + disabled_message_response(CompanionState::Cancelled) } } -fn projection_worker(status: &CompanionStatusResponse) -> Option { - status.worker.clone() -} - -fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { - let selector = companion_profile_selector(); - let mut diagnostics = Vec::new(); - let config_bundle = companion_config_bundle(); - - match runtime.sync_config_bundle(COMPANION_RUNTIME_ID, config_bundle) { - Ok(result) => diagnostics.extend(result.diagnostics), - Err(error) => diagnostics.push(diagnostic( - "companion_config_bundle_sync_failed", - DiagnosticSeverity::Error, - format!("Workspace Companion config bundle sync failed: {error:?}"), - )), - } - - let response = runtime.spawn_worker( - COMPANION_RUNTIME_ID, - WorkerSpawnRequest { - intent: WorkerSpawnIntent::WorkspaceCompanion, - requested_worker_name: Some("workspace-companion".to_string()), - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 0, - }, - profile: Some(selector), - initial_input: None, - working_directory_request: None, - resolved_working_directory_request: None, - resolved_working_directory: None, - resolved_config_bundle: None, - }, - ); - - match response { - Ok(response) => { - diagnostics.extend(response.diagnostics); - if let Some(worker) = response.worker { - if !worker.capabilities.can_accept_input { - diagnostics.push(companion_not_input_capable_diagnostic(&worker)); - } - CompanionWorkerState { - state: companion_state_for_worker(&worker), - worker: Some(worker), - diagnostics, - } - } else { - diagnostics.push(diagnostic( - "companion_worker_missing", - DiagnosticSeverity::Error, - "Workspace Companion Worker spawn did not return a Worker projection", - )); - CompanionWorkerState { - state: CompanionState::Error, - worker: None, - diagnostics, - } - } - } - Err(error) => CompanionWorkerState { - state: CompanionState::Error, - worker: None, - diagnostics: vec![diagnostic( - "companion_worker_spawn_failed", - DiagnosticSeverity::Error, - format!("Workspace Companion Worker spawn failed: {error:?}"), - )], - }, - } -} - -fn companion_profile_selector() -> ProfileSelector { - ProfileSelector::Builtin(COMPANION_PROFILE_ID.to_string()) -} - -fn companion_config_bundle() -> ConfigBundle { - ConfigBundle { - metadata: ConfigBundleMetadata { - id: COMPANION_CONFIG_BUNDLE_ID.to_string(), - digest: String::new(), - revision: "1".to_string(), - workspace_id: "workspace-companion".to_string(), - created_at: Utc::now().to_rfc3339(), - provenance: ConfigBundleProvenance { - source: "workspace-server".to_string(), - detail: Some("workspace-companion".to_string()), - }, - }, - profiles: vec![ConfigProfileDescriptor { - selector: companion_profile_selector(), - label: Some("Workspace Companion".to_string()), - }], - declarations: Vec::new(), - profile_source_archive: Some(companion_profile_archive()), - profile_source_archive_handle: None, - } - .with_computed_digest() -} - -fn companion_profile_archive() -> ProfileSourceArchive { - let mut entrypoints = BTreeMap::new(); - entrypoints.insert("default".to_string(), "profiles/companion.dcdl".to_string()); - entrypoints.insert( - COMPANION_PROFILE_ID.to_string(), - "profiles/companion.dcdl".to_string(), - ); - let mut sources = BTreeMap::new(); - sources.insert( - "profiles/companion.dcdl".to_string(), - include_str!("../../../resources/profiles/companion.dcdl").to_string(), - ); - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: "workspace-companion-profile-archive-v1".to_string(), - entrypoints, - imports: BTreeMap::new(), - sources, - }) - .expect("builtin Companion Decodal profile source archive is valid") -} - -fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState { - if !worker.capabilities.can_accept_input { - return CompanionState::Error; - } - match worker.status.as_str() { - "busy" | "running" | "stopping" => CompanionState::Busy, - "errored" | "error" | "stopped" | "unavailable" => CompanionState::Error, - _ => CompanionState::Ready, - } -} - -fn companion_not_input_capable_diagnostic(worker: &WorkerSummary) -> RuntimeDiagnostic { - diagnostic( - "companion_worker_not_input_capable", - DiagnosticSeverity::Error, - format!( - "Workspace Companion Worker '{}' is not input-capable; check profile, provider, secret, and authority diagnostics", - worker.worker_id - ), - ) -} - -fn project_runtime_transcript( - transcript: &WorkerTranscriptProjection, - state: CompanionState, - diagnostics: Vec, -) -> CompanionTranscriptProjection { - CompanionTranscriptProjection { +fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse { + CompanionMessageResponse { state, - start: transcript.start, - limit: transcript.limit, - total_items: transcript.total_items, - next_start: transcript.next_start, - items: transcript - .items - .iter() - .map(project_runtime_transcript_item) - .collect(), - diagnostics, + 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 project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem { - CompanionTranscriptItem { - sequence: item.sequence, - role: item.role.clone(), - content: item.content.clone(), - created_at: format!("runtime_sequence:{}", item.sequence), - source: "worker_runtime".to_string(), - status: "committed".to_string(), +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 companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary { - if worker.is_some_and(|worker| worker.capabilities.can_accept_input) { - CompanionTransportSummary { - kind: "embedded_worker_runtime".to_string(), - completion: "connected".to_string(), - limitation: - "Workspace Companion input is dispatched through the normal Worker runtime path." - .to_string(), - } - } else { - CompanionTransportSummary { - kind: "embedded_worker_runtime".to_string(), - completion: "not_input_capable".to_string(), - limitation: - "Workspace Companion is a Worker but is not input-capable; inspect typed diagnostics for missing profile, provider, secret, or authority." - .to_string(), - } - } -} - -fn diagnostic( - code: impl Into, - severity: DiagnosticSeverity, - message: impl Into, -) -> RuntimeDiagnostic { +fn disabled_diagnostic() -> RuntimeDiagnostic { RuntimeDiagnostic { - code: code.into(), - severity, - message: message.into(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hosts::{EmbeddedWorkerRuntime, RuntimeRegistry}; - use std::collections::HashMap; - use std::sync::Mutex as StdMutex; - use std::thread; - use std::time::{Duration, Instant}; - use worker_runtime::execution::{ - WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle, - WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState, - WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, - }; - use worker_runtime::identity::WorkerRef; - use worker_runtime::interaction::WorkerInput; - - #[derive(Default)] - struct DeterministicExecutionBackend { - contexts: StdMutex>, - } - - impl WorkerExecutionBackend for DeterministicExecutionBackend { - fn backend_id(&self) -> &str { - "deterministic-companion-test" - } - - fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { - self.contexts - .lock() - .unwrap() - .insert(request.worker_ref.clone(), request.context); - WorkerExecutionSpawnResult::Connected { - handle: WorkerExecutionHandle::new( - request.worker_ref.clone(), - "deterministic-companion-test", - ), - run_state: WorkerExecutionRunState::Idle, - working_directory: request - .working_directory - .as_ref() - .map(|binding| binding.status()), - } - } - - fn dispatch_input( - &self, - handle: &WorkerExecutionHandle, - request: WorkerInput, - ) -> WorkerExecutionResult { - let worker = handle.worker_ref().clone(); - let context = self - .contexts - .lock() - .unwrap() - .get(&worker) - .cloned() - .expect("execution context"); - let content = request.content.clone(); - thread::spawn(move || { - thread::sleep(Duration::from_millis(25)); - let _ = context.publish_protocol_event(protocol::Event::TextDone { - text: format!("companion echoed: {content}"), - }); - }); - WorkerExecutionResult::accepted( - WorkerExecutionOperation::Input, - WorkerExecutionRunState::Idle, - ) - } - } - - #[test] - fn companion_spawns_worker_with_companion_profile_through_runtime_backend() { - let registry = RuntimeRegistry::for_workspace( - EmbeddedWorkerRuntime::new_memory_with_execution_backend( - "local:test", - Arc::new(DeterministicExecutionBackend::default()), - ) - .unwrap(), - ); - let registry = Arc::new(registry); - let companion = CompanionConsole::new(registry.clone()); - - let status = companion.status(); - let worker = status.worker.clone().expect("companion worker"); - assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID); - assert_eq!(worker.role.as_deref(), Some(COMPANION_PROFILE_ID)); - assert!(worker.capabilities.can_accept_input); - assert_eq!(status.transport.completion, "connected"); - assert!(status.diagnostics.is_empty()); - - let response = companion.send_message(CompanionMessageRequest { - content: "hello".to_string(), - }); - assert_eq!(response.state, CompanionState::Accepted); - assert!(response.diagnostics.is_empty()); - assert!( - response - .transcript - .items - .iter() - .any(|entry| entry.role == "user" && entry.content == "hello") - ); - - let worker_detail = registry - .worker(COMPANION_RUNTIME_ID, &worker.worker_id) - .expect("worker detail"); - assert_eq!(worker_detail.profile.as_deref(), Some(COMPANION_PROFILE_ID)); - - let browser_payload = serde_json::to_string(&(status, response, worker_detail)).unwrap(); - for forbidden in [ - "/workspace/project", - "metadata.json", - ".jsonl", - "/run/user/", - "session", - "manifest", - ] { - assert!( - !browser_payload.contains(forbidden), - "companion projection leaked forbidden term {forbidden}: {browser_payload}" - ); - } - } - - #[test] - fn companion_dispatches_input_and_projects_assistant_output_from_worker_runtime() { - let registry = RuntimeRegistry::for_workspace( - EmbeddedWorkerRuntime::new_memory_with_execution_backend( - "local:test", - Arc::new(DeterministicExecutionBackend::default()), - ) - .expect("embedded runtime"), - ); - let registry = Arc::new(registry); - let companion = CompanionConsole::new(registry.clone()); - let status = companion.status(); - let worker = status.worker.clone().expect("companion worker"); - assert_eq!(status.transport.completion, "connected"); - assert_eq!(worker.profile.as_deref(), Some(COMPANION_PROFILE_ID)); - assert!(worker.capabilities.can_accept_input); - - let source = registry - .observation_source(COMPANION_RUNTIME_ID, &worker.worker_id) - .expect("observation source"); - let crate::observation::RuntimeObservationSource::Embedded(source) = source else { - panic!("expected embedded observation source"); - }; - let cursor = source - .runtime - .worker_observation_cursor_now(&source.worker_ref) - .expect("observation cursor"); - - let response = companion.send_message(CompanionMessageRequest { - content: "hello runtime".to_string(), - }); - assert_eq!(response.state, CompanionState::Accepted); - assert!( - response - .user_item - .as_ref() - .is_some_and(|item| item.role == "user" && item.content == "hello runtime") - ); - assert!(response.diagnostics.is_empty()); - - let deadline = Instant::now() + Duration::from_secs(2); - let observed = loop { - let observed = source - .runtime - .read_worker_observation_events(&source.worker_ref, cursor) - .expect("observation events"); - if observed.iter().any(|event| { - serde_json::to_string(event) - .unwrap() - .contains("companion echoed: hello runtime") - }) { - break observed; - } - assert!( - Instant::now() < deadline, - "timed out waiting for observation event" - ); - thread::sleep(Duration::from_millis(20)); - }; - let observed_json = serde_json::to_string(&observed).unwrap(); - assert!(observed_json.contains("companion echoed: hello runtime")); - - let deadline = Instant::now() + Duration::from_secs(2); - let transcript = loop { - let transcript = companion.transcript(0, 20); - if transcript.items.iter().any(|item| { - item.role == "assistant" && item.content == "companion echoed: hello runtime" - }) { - break transcript; - } - assert!( - Instant::now() < deadline, - "timed out waiting for companion assistant output: {transcript:?}" - ); - thread::sleep(Duration::from_millis(20)); - }; - - assert!( - transcript - .items - .iter() - .any(|item| item.role == "user" && item.content == "hello runtime") - ); - assert!(transcript.items.iter().any(|item| { - item.role == "assistant" - && item.source == "worker_runtime" - && item.status == "committed" - })); - - let runtime_transcript = registry - .transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 20) - .expect("runtime transcript"); - assert!(runtime_transcript.items.iter().any(|item| { - item.role == "assistant" && item.content == "companion echoed: hello runtime" - })); + code: "companion_disabled".to_string(), + severity: DiagnosticSeverity::Info, + message: "Workspace Companion auto-start is disabled; create an explicit Worker instead." + .to_string(), } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f04cbd9a..987a3a3f 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -261,6 +261,11 @@ impl RuntimeList { } } +fn is_retired_companion_worker(worker: &WorkerSummary) -> bool { + worker.role.as_deref() == Some("builtin:companion") + || worker.profile.as_deref() == Some("builtin:companion") +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerLookupResult { #[serde(skip_serializing_if = "Option::is_none")] @@ -874,7 +879,12 @@ impl RuntimeRegistry { } let mut list = runtime.list_workers(limit.saturating_sub(items.len())); diagnostics.append(&mut list.diagnostics); - items.append(&mut list.items); + items.extend( + list.items + .into_iter() + .filter(|worker| !is_retired_companion_worker(worker)) + .take(limit.saturating_sub(items.len())), + ); } diagnostics.truncate(MAX_DIAGNOSTICS); RuntimeList::new(items, diagnostics) @@ -904,6 +914,7 @@ impl RuntimeRegistry { .items .into_iter() .filter(|worker| worker.host_id == host_id) + .filter(|worker| !is_retired_companion_worker(worker)) .take(limit.saturating_sub(items.len())), ); if items.len() >= limit { @@ -927,9 +938,16 @@ impl RuntimeRegistry { validate_backend_identifier("worker_id", worker_id)?; let runtime = self.runtime(runtime_id)?; let lookup = runtime.worker(worker_id); - lookup.worker.ok_or_else(|| { + let worker = lookup.worker.ok_or_else(|| { operation_failed_or_unknown_worker(runtime_id, worker_id, lookup.diagnostics) - }) + })?; + if is_retired_companion_worker(&worker) { + return Err(RuntimeRegistryError::UnknownWorker { + runtime_id: runtime_id.to_string(), + worker_id: worker_id.to_string(), + }); + } + Ok(worker) } pub fn spawn_worker( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c0b070ae..47c87934 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -249,7 +249,7 @@ impl WorkspaceApi { ); } let runtime = Arc::new(runtime); - let companion = Arc::new(CompanionConsole::new(runtime.clone())); + let companion = Arc::new(CompanionConsole::disabled()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); Ok(Self { records: LocalProjectRecordReader::new(config.workspace_root.clone()), @@ -1590,6 +1590,7 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens ) } } + "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." ), @@ -5064,24 +5065,18 @@ mod tests { let workers = get_json(app.clone(), "/api/workers").await; let worker_items = workers["items"].as_array().unwrap(); - let companion_worker = worker_items - .iter() - .find(|worker| worker["role"] == "builtin:companion") - .expect("companion worker is visible through runtime worker API"); - assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime"); - assert!(companion_worker["capabilities"]["can_stop"].is_boolean()); + assert!( + worker_items + .iter() + .all(|worker| worker["role"] != "builtin:companion"), + "companion auto-start should not create runtime workers: {workers}" + ); let companion_status = get_json(app.clone(), "/api/companion/status").await; - assert!(matches!( - companion_status["state"].as_str(), - Some("ready") | Some("error") - )); - assert_eq!(companion_status["worker"]["role"], "builtin:companion"); - assert_eq!( - companion_status["transport"]["kind"], - "embedded_worker_runtime" - ); - assert_ne!(companion_status["transport"]["completion"], "not_connected"); + assert_eq!(companion_status["state"], "disabled"); + assert!(companion_status["worker"].is_null()); + assert_eq!(companion_status["transport"]["kind"], "none"); + assert_eq!(companion_status["transport"]["completion"], "disabled"); assert!(!companion_status.to_string().contains("/workspace/demo")); let companion_message = post_json( @@ -5090,20 +5085,17 @@ mod tests { json!({ "content": "hello companion" }), ) .await; - assert_eq!(companion_message["state"], "accepted"); - assert_eq!(companion_message["user_item"]["role"], "user"); - assert_eq!(companion_message["user_item"]["content"], "hello companion"); - assert!( - !companion_message - .to_string() - .contains("companion_llm_not_connected"), - "legacy non-execution diagnostic leaked: {companion_message}" + assert_eq!(companion_message["state"], "rejected"); + assert_eq!( + companion_message["diagnostics"][0]["code"], + "companion_disabled" ); - assert!(!companion_message.to_string().contains("providerless")); + assert!(companion_message["user_item"].is_null()); + assert!(companion_message["assistant_item"].is_null()); assert!(!companion_message.to_string().contains("/workspace/demo")); let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await; - assert!(companion_transcript["total_items"].as_u64().unwrap() >= 1); + assert_eq!(companion_transcript["total_items"], 0); let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await; assert!( @@ -5111,7 +5103,7 @@ mod tests { .as_array() .unwrap() .iter() - .any(|worker| worker["role"] == "builtin:companion") + .all(|worker| worker["role"] != "builtin:companion") ); let runs_response = app @@ -5193,7 +5185,7 @@ mod tests { } #[tokio::test] - async fn legacy_companion_messages_route_dispatches_through_worker_runtime() { + async fn companion_routes_report_disabled_without_spawning_worker() { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path().join("workspace")); let api = WorkspaceApi::new_with_execution_backend( @@ -5207,24 +5199,22 @@ mod tests { let workspace = get_json(app.clone(), "/api/workspace").await; let workspace_companion = &workspace["extension_points"]["companion_console"]; - assert_eq!(workspace_companion["status"], "connected"); - assert!( - workspace_companion["diagnostics"] - .as_array() - .unwrap() - .is_empty() + assert_eq!(workspace_companion["status"], "disabled"); + assert_eq!( + workspace_companion["diagnostics"][0]["code"], + "companion_disabled" ); assert!( workspace_companion["note"] .as_str() .unwrap() - .contains("normal Worker runtime path") + .contains("auto-start has been removed") ); let status = get_json(app.clone(), "/api/companion/status").await; - assert_eq!(status["transport"]["completion"], "connected"); - let worker_id = status["worker"]["worker_id"].as_str().unwrap().to_string(); - assert_eq!(status["worker"]["profile"], "builtin:companion"); + assert_eq!(status["state"], "disabled"); + assert_eq!(status["transport"]["completion"], "disabled"); + assert!(status["worker"].is_null()); let response = post_json( app.clone(), @@ -5232,49 +5222,23 @@ mod tests { serde_json::json!({ "content": "from legacy route" }), ) .await; - assert_eq!(response["state"], "accepted"); - assert_eq!(response["user_item"]["content"], "from legacy route"); - assert!(!response.to_string().contains("companion_llm_not_connected")); + 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()); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - let transcript = loop { - let transcript = get_json(app.clone(), "/api/companion/transcript").await; - let has_assistant = transcript["items"].as_array().unwrap().iter().any(|item| { - item["role"] == "assistant" - && item["content"] == "server companion echoed: from legacy route" - && item["source"] == "worker_runtime" - }); - if has_assistant { - break transcript; - } - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for server companion transcript: {transcript}" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - }; + let transcript = get_json(app.clone(), "/api/companion/transcript").await; + assert_eq!(transcript["state"], "disabled"); + assert_eq!(transcript["total_items"], 0); + + let workers = get_json(app, "/api/workers").await; assert!( - transcript["items"] + workers["items"] .as_array() .unwrap() .iter() - .any(|item| { item["role"] == "user" && item["content"] == "from legacy route" }) - ); - - let worker_transcript = get_json( - app, - &format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript"), - ) - .await; - assert!( - worker_transcript["items"] - .as_array() - .unwrap() - .iter() - .any(|item| { - item["role"] == "assistant" - && item["content"] == "server companion echoed: from legacy route" - }) + .all(|worker| worker["role"] != "builtin:companion"), + "disabled companion route should not spawn workers: {workers}" ); } diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index d880ecdb..de0d7490 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -349,6 +349,17 @@ margin: 0; } + .section-heading-link { + color: inherit; + text-decoration: none; + } + + .section-heading-link:hover, + .section-heading-link:focus-visible, + .section-heading-link.active { + color: var(--accent); + } + .eyebrow { color: var(--accent-muted); margin: 0; @@ -635,6 +646,44 @@ text-transform: uppercase; } + .workers-page { + display: grid; + gap: var(--space-4); + } + + .workers-page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + } + + .workers-page-header h1, + .workers-page-header p { + margin: 0; + } + + .workers-page-header h1 { + color: var(--text-strong); + font-size: clamp(1.6rem, 4vw, 2.4rem); + line-height: 1.05; + } + + .workers-page-header p { + margin-top: var(--space-2); + color: var(--text-muted); + } + + .workers-table-wrap { + border-top: 1px solid var(--line); + } + + .workers-table td small { + display: block; + margin-top: 0.2rem; + color: var(--text-muted); + } + .diagnostics { margin-top: var(--space-4); } diff --git a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts index 70503742..23bbf053 100644 --- a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts @@ -9,10 +9,13 @@ function assert(condition: unknown, message: string): asserts condition { } } -Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => { +Deno.test("workspace Worker list lives on the dedicated Workers page", async () => { const workspacePage = await Deno.readTextFile( new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url), ); + const workersPage = await Deno.readTextFile( + new URL("./../../routes/w/[workspaceId]/workers/+page.svelte", import.meta.url), + ); const workersNav = await Deno.readTextFile( new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url), ); @@ -21,14 +24,21 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs ); assert( - workspacePage.includes("workerConsoleHref(worker, workspaceId)") && - workspacePage.includes("Open Console"), - "top Worker list should expose an attach action per Worker", + !workspacePage.includes("workerConsoleHref") && + !workspacePage.includes("Open Console"), + "top workspace page should not own the Worker list", ); assert( - workersNav.includes("workerConsoleHref(worker, workspaceId)") && + workersPage.includes("workerConsoleHref(worker, data.workspaceId)") && + workersPage.includes("") && + workersPage.includes("Open Console"), + "dedicated Workers page should expose a table and attach action per Worker", + ); + assert( + workersNav.includes('href={`/w/${workspaceId}/workers`}') && + workersNav.includes("workerConsoleHref(worker, workspaceId)") && workersNav.includes("aria-current"), - "Workers sidebar rows should link to the Worker target Console route", + "Workers sidebar should link to the Worker list page and target Console routes", ); assert( !sidebar.includes("CompanionNavSection") && diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index cc05a5f0..8fb1d9df 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -68,7 +68,14 @@ -
-
-

Hosts

- {#if data.hosts} - {#if data.hosts.items.length === 0} -

No local Hosts are visible.

- {:else} -
- {#each data.hosts.items as host} -
-
- {host.label} - {host.status} +
+

Hosts

+ {#if data.hosts} + {#if data.hosts.items.length === 0} +

No local Hosts are visible.

+ {:else} +
+ {#each data.hosts.items as host} +
+
+ {host.label} + {host.status} +
+
+
+
ID
+
{host.host_id}
-
-
-
ID
-
{host.host_id}
-
-
-
Kind
-
{host.kind}
-
-
-
Runtime
-
{host.runtime_id}
-
-
-
Scope
-
{host.capabilities.workspace_scope}
-
-
-
Platform
-
{host.capabilities.os} / {host.capabilities.arch}
-
-
-
- {/each} -
- {/if} - {:else if data.hostsError} -

{data.hostsError}

- {:else} -

Waiting for /api/hosts

+
+
Kind
+
{host.kind}
+
+
+
Runtime
+
{host.runtime_id}
+
+
+
Scope
+
{host.capabilities.workspace_scope}
+
+
+
Platform
+
{host.capabilities.os} / {host.capabilities.arch}
+
+ +
+ {/each} +
{/if} -
- -
-

Workers

- {#if data.workers} - {#if data.workers.items.length === 0} -

No local Workers are visible.

- {:else} -
-
- - - - - - - - - - - - {#each data.workers.items as worker} - - - - - - - - - {/each} - -
WorkerHostStateWorkspaceImplementationAttach
- {worker.label} - {#if worker.role || worker.profile} - {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} - {/if} - {worker.host_id}{worker.state} · {worker.status}{worker.workspace.visibility} · {worker.workspace.identity}{worker.implementation.kind}Open Console
- - {/if} - {:else if data.workersError} -

{data.workersError}

- {:else} -

Waiting for /api/workers

- {/if} - + {:else if data.hostsError} +

{data.hostsError}

+ {:else} +

Waiting for /api/hosts

+ {/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/+page.ts index c059d384..d9d09cf2 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+page.ts @@ -1,19 +1,14 @@ import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; -import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types"; +import type { Host, ListResponse } from "$lib/workspace-sidebar/types"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); - const [hosts, workers] = await Promise.all([ - loadJson>(fetch, apiPath("/hosts")), - loadJson>(fetch, apiPath("/workers")), - ]); + const hosts = await loadJson>(fetch, apiPath("/hosts")); return { workspaceId: params.workspaceId, hosts: hosts.data, hostsError: hosts.error, - workers: workers.data, - workersError: workers.error, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte new file mode 100644 index 00000000..30435d19 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -0,0 +1,78 @@ + + + + Workers · Yoi Workspace + + + +
+
+
+

Workers

+

Workers running or persisted for this workspace.

+
+
New Worker +
+ + {#if data.workersError} +

{data.workersError}

+ {:else if !data.workers} +

Loading Workers…

+ {:else if data.workers.items.length === 0} +

No Workers are visible.

+ {:else} +
+ + + + + + + + + + + + + {#each data.workers.items as worker} + + + + + + + + + {/each} + +
WorkerRuntimeProfileStatusWorking directoryAction
+ {worker.label} + {worker.worker_id} + {worker.runtime_id}{workerProfile(worker)}{workerStatus(worker)}{workerDirectory(worker)} + Open Console +
+
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.ts b/web/workspace/src/routes/w/[workspaceId]/workers/+page.ts new file mode 100644 index 00000000..5fb0945f --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.ts @@ -0,0 +1,16 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; +import type { ListResponse, Worker } from "$lib/workspace-sidebar/types"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const workers = await loadJson>( + fetch, + workspaceApiPath(params.workspaceId, "/workers"), + ); + + return { + workspaceId: params.workspaceId, + workers: workers.data, + workersError: workers.error, + }; +};