ui: move workers list to page

This commit is contained in:
Keisuke Hirata 2026-07-10 12:06:10 +09:00
parent a3cb59af43
commit 47e03bd54b
No known key found for this signature in database
10 changed files with 322 additions and 877 deletions

View File

@ -1,35 +1,13 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize}; 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::{ use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
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;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CompanionState { pub enum CompanionState {
Ready, Disabled,
Busy,
Error,
Timeout,
Cancelled,
Accepted,
Rejected, Rejected,
Cancelled,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -94,698 +72,77 @@ pub struct CompanionTranscriptItem {
pub status: String, pub status: String,
} }
#[derive(Debug)] pub struct CompanionConsole;
struct CompanionWorkerState {
state: CompanionState,
worker: Option<WorkerSummary>,
diagnostics: Vec<RuntimeDiagnostic>,
}
pub struct CompanionConsole {
runtime: Arc<RuntimeRegistry>,
worker: Mutex<CompanionWorkerState>,
}
impl CompanionConsole { impl CompanionConsole {
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self { pub fn disabled() -> Self {
let initial = spawn_companion_worker(&runtime); Self
Self {
runtime,
worker: Mutex::new(initial),
}
} }
pub fn status(&self) -> CompanionStatusResponse { pub fn status(&self) -> CompanionStatusResponse {
match self.refresh_worker_state() { CompanionStatusResponse {
Ok(worker) => CompanionStatusResponse { state: CompanionState::Disabled,
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, worker: None,
transport: companion_transport(None), transport: disabled_transport(),
diagnostics: vec![diagnostic], diagnostics: vec![disabled_diagnostic()],
},
} }
} }
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection { pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
match self.current_worker() { CompanionTranscriptProjection {
Ok(Some(worker)) => { state: CompanionState::Disabled,
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, start,
limit, limit,
total_items: 0, total_items: 0,
next_start: None, next_start: None,
items: Vec::new(), items: Vec::new(),
diagnostics: vec![diagnostic( diagnostics: vec![disabled_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],
},
} }
} }
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse { pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
let content = request.content.trim().to_string(); disabled_message_response(CompanionState::Rejected)
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 cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse { pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
let diagnostics = vec![diagnostic( disabled_message_response(CompanionState::Cancelled)
"companion_cancel_no_active_run", }
DiagnosticSeverity::Info, }
"Workspace Companion has no active generation to cancel",
)]; fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
let status = self.status();
let projection = self.transcript(0, 200);
CompanionMessageResponse { 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<Option<WorkerSummary>, RuntimeDiagnostic> {
self.refresh_worker_state()
.map(|state| state.worker.clone())
}
fn refresh_worker_state(&self) -> Result<CompanionWorkerState, RuntimeDiagnostic> {
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(),
})
}
}
fn projection_worker(status: &CompanionStatusResponse) -> Option<WorkerSummary> {
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<RuntimeDiagnostic>,
) -> CompanionTranscriptProjection {
CompanionTranscriptProjection {
state, state,
start: transcript.start, worker: None,
limit: transcript.limit, user_item: None,
total_items: transcript.total_items, assistant_item: None,
next_start: transcript.next_start, transcript: CompanionTranscriptProjection {
items: transcript state: CompanionState::Disabled,
.items start: 0,
.iter() limit: 200,
.map(project_runtime_transcript_item) total_items: 0,
.collect(), next_start: None,
diagnostics, items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
},
diagnostics: vec![disabled_diagnostic()],
} }
} }
fn project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem { fn disabled_transport() -> CompanionTransportSummary {
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 companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary {
if worker.is_some_and(|worker| worker.capabilities.can_accept_input) {
CompanionTransportSummary { CompanionTransportSummary {
kind: "embedded_worker_runtime".to_string(), kind: "none".to_string(),
completion: "connected".to_string(), completion: "disabled".to_string(),
limitation: limitation:
"Workspace Companion input is dispatched through the normal Worker runtime path." "Workspace Companion auto-start has been removed; create an explicit Worker instead."
.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(), .to_string(),
} }
} }
}
fn diagnostic( fn disabled_diagnostic() -> RuntimeDiagnostic {
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> RuntimeDiagnostic {
RuntimeDiagnostic { RuntimeDiagnostic {
code: code.into(), code: "companion_disabled".to_string(),
severity, severity: DiagnosticSeverity::Info,
message: message.into(), message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
} .to_string(),
}
#[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<HashMap<WorkerRef, WorkerExecutionContext>>,
}
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"
}));
} }
} }

View File

@ -261,6 +261,11 @@ impl<T> RuntimeList<T> {
} }
} }
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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerLookupResult { pub struct WorkerLookupResult {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -874,7 +879,12 @@ impl RuntimeRegistry {
} }
let mut list = runtime.list_workers(limit.saturating_sub(items.len())); let mut list = runtime.list_workers(limit.saturating_sub(items.len()));
diagnostics.append(&mut list.diagnostics); 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); diagnostics.truncate(MAX_DIAGNOSTICS);
RuntimeList::new(items, diagnostics) RuntimeList::new(items, diagnostics)
@ -904,6 +914,7 @@ impl RuntimeRegistry {
.items .items
.into_iter() .into_iter()
.filter(|worker| worker.host_id == host_id) .filter(|worker| worker.host_id == host_id)
.filter(|worker| !is_retired_companion_worker(worker))
.take(limit.saturating_sub(items.len())), .take(limit.saturating_sub(items.len())),
); );
if items.len() >= limit { if items.len() >= limit {
@ -927,9 +938,16 @@ impl RuntimeRegistry {
validate_backend_identifier("worker_id", worker_id)?; validate_backend_identifier("worker_id", worker_id)?;
let runtime = self.runtime(runtime_id)?; let runtime = self.runtime(runtime_id)?;
let lookup = runtime.worker(worker_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) 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( pub fn spawn_worker(

View File

@ -249,7 +249,7 @@ impl WorkspaceApi {
); );
} }
let runtime = Arc::new(runtime); 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()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self { Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()), 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!( other => format!(
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state." "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 workers = get_json(app.clone(), "/api/workers").await;
let worker_items = workers["items"].as_array().unwrap(); let worker_items = workers["items"].as_array().unwrap();
let companion_worker = worker_items assert!(
worker_items
.iter() .iter()
.find(|worker| worker["role"] == "builtin:companion") .all(|worker| worker["role"] != "builtin:companion"),
.expect("companion worker is visible through runtime worker API"); "companion auto-start should not create runtime workers: {workers}"
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime"); );
assert!(companion_worker["capabilities"]["can_stop"].is_boolean());
let companion_status = get_json(app.clone(), "/api/companion/status").await; let companion_status = get_json(app.clone(), "/api/companion/status").await;
assert!(matches!( assert_eq!(companion_status["state"], "disabled");
companion_status["state"].as_str(), assert!(companion_status["worker"].is_null());
Some("ready") | Some("error") assert_eq!(companion_status["transport"]["kind"], "none");
)); assert_eq!(companion_status["transport"]["completion"], "disabled");
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!(!companion_status.to_string().contains("/workspace/demo")); assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json( let companion_message = post_json(
@ -5090,20 +5085,17 @@ mod tests {
json!({ "content": "hello companion" }), json!({ "content": "hello companion" }),
) )
.await; .await;
assert_eq!(companion_message["state"], "accepted"); assert_eq!(companion_message["state"], "rejected");
assert_eq!(companion_message["user_item"]["role"], "user"); assert_eq!(
assert_eq!(companion_message["user_item"]["content"], "hello companion"); companion_message["diagnostics"][0]["code"],
assert!( "companion_disabled"
!companion_message
.to_string()
.contains("companion_llm_not_connected"),
"legacy non-execution diagnostic leaked: {companion_message}"
); );
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")); assert!(!companion_message.to_string().contains("/workspace/demo"));
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await; 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; let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
assert!( assert!(
@ -5111,7 +5103,7 @@ mod tests {
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|worker| worker["role"] == "builtin:companion") .all(|worker| worker["role"] != "builtin:companion")
); );
let runs_response = app let runs_response = app
@ -5193,7 +5185,7 @@ mod tests {
} }
#[tokio::test] #[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 temp = tempfile::tempdir().unwrap();
let config = test_server_config(temp.path().join("workspace")); let config = test_server_config(temp.path().join("workspace"));
let api = WorkspaceApi::new_with_execution_backend( let api = WorkspaceApi::new_with_execution_backend(
@ -5207,24 +5199,22 @@ mod tests {
let workspace = get_json(app.clone(), "/api/workspace").await; let workspace = get_json(app.clone(), "/api/workspace").await;
let workspace_companion = &workspace["extension_points"]["companion_console"]; let workspace_companion = &workspace["extension_points"]["companion_console"];
assert_eq!(workspace_companion["status"], "connected"); assert_eq!(workspace_companion["status"], "disabled");
assert!( assert_eq!(
workspace_companion["diagnostics"] workspace_companion["diagnostics"][0]["code"],
.as_array() "companion_disabled"
.unwrap()
.is_empty()
); );
assert!( assert!(
workspace_companion["note"] workspace_companion["note"]
.as_str() .as_str()
.unwrap() .unwrap()
.contains("normal Worker runtime path") .contains("auto-start has been removed")
); );
let status = get_json(app.clone(), "/api/companion/status").await; let status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(status["transport"]["completion"], "connected"); assert_eq!(status["state"], "disabled");
let worker_id = status["worker"]["worker_id"].as_str().unwrap().to_string(); assert_eq!(status["transport"]["completion"], "disabled");
assert_eq!(status["worker"]["profile"], "builtin:companion"); assert!(status["worker"].is_null());
let response = post_json( let response = post_json(
app.clone(), app.clone(),
@ -5232,49 +5222,23 @@ mod tests {
serde_json::json!({ "content": "from legacy route" }), serde_json::json!({ "content": "from legacy route" }),
) )
.await; .await;
assert_eq!(response["state"], "accepted"); assert_eq!(response["state"], "rejected");
assert_eq!(response["user_item"]["content"], "from legacy route"); assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
assert!(!response.to_string().contains("companion_llm_not_connected")); 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 transcript = get_json(app.clone(), "/api/companion/transcript").await;
let has_assistant = transcript["items"].as_array().unwrap().iter().any(|item| { assert_eq!(transcript["state"], "disabled");
item["role"] == "assistant" assert_eq!(transcript["total_items"], 0);
&& 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;
};
assert!(
transcript["items"]
.as_array()
.unwrap()
.iter()
.any(|item| { item["role"] == "user" && item["content"] == "from legacy route" })
);
let worker_transcript = get_json( let workers = get_json(app, "/api/workers").await;
app,
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript"),
)
.await;
assert!( assert!(
worker_transcript["items"] workers["items"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|item| { .all(|worker| worker["role"] != "builtin:companion"),
item["role"] == "assistant" "disabled companion route should not spawn workers: {workers}"
&& item["content"] == "server companion echoed: from legacy route"
})
); );
} }

View File

@ -349,6 +349,17 @@
margin: 0; 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 { .eyebrow {
color: var(--accent-muted); color: var(--accent-muted);
margin: 0; margin: 0;
@ -635,6 +646,44 @@
text-transform: uppercase; 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 { .diagnostics {
margin-top: var(--space-4); margin-top: var(--space-4);
} }

View File

@ -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( const workspacePage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url), 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( const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url), 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( assert(
workspacePage.includes("workerConsoleHref(worker, workspaceId)") && !workspacePage.includes("workerConsoleHref") &&
workspacePage.includes("Open Console"), !workspacePage.includes("Open Console"),
"top Worker list should expose an attach action per Worker", "top workspace page should not own the Worker list",
); );
assert( assert(
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes("<table class=\"workers-table\">") &&
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("workerConsoleHref(worker, workspaceId)") &&
workersNav.includes("aria-current"), 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( assert(
!sidebar.includes("CompanionNavSection") && !sidebar.includes("CompanionNavSection") &&

View File

@ -68,7 +68,14 @@
<section class="nav-section" aria-labelledby="workers-heading"> <section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="workers-heading">workers</h2> <h2 id="workers-heading">
<a
class="section-heading-link"
class:active={currentPath === `/w/${workspaceId}/workers`}
href={`/w/${workspaceId}/workers`}
aria-current={currentPath === `/w/${workspaceId}/workers` ? 'page' : undefined}
>workers</a>
</h2>
<a <a
class="section-action" class="section-action"
class:active={currentPath === `/w/${workspaceId}/workers/new`} class:active={currentPath === `/w/${workspaceId}/workers/new`}

View File

@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
</script> </script>
<svelte:head> <svelte:head>
@ -39,8 +37,7 @@
{/if} {/if}
</section> </section>
<section class="grid runtime"> <section class="card">
<div class="card">
<h2>Hosts</h2> <h2>Hosts</h2>
{#if data.hosts} {#if data.hosts}
{#if data.hosts.items.length === 0} {#if data.hosts.items.length === 0}
@ -84,50 +81,4 @@
{:else} {:else}
<p>Waiting for <code>/api/hosts</code></p> <p>Waiting for <code>/api/hosts</code></p>
{/if} {/if}
</div>
<div class="card">
<h2>Workers</h2>
{#if data.workers}
{#if data.workers.items.length === 0}
<p>No local Workers are visible.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Host</th>
<th>State</th>
<th>Workspace</th>
<th>Implementation</th>
<th>Attach</th>
</tr>
</thead>
<tbody>
{#each data.workers.items as worker}
<tr>
<td>
<strong>{worker.label}</strong>
{#if worker.role || worker.profile}
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
{/if}
</td>
<td><code>{worker.host_id}</code></td>
<td>{worker.state} · {worker.status}</td>
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
<td>{worker.implementation.kind}</td>
<td><a class="inline-link" href={workerConsoleHref(worker, workspaceId)}>Open Console</a></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if data.workersError}
<p class="error">{data.workersError}</p>
{:else}
<p>Waiting for <code>/api/workers</code></p>
{/if}
</div>
</section> </section>

View File

@ -1,19 +1,14 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; 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"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const [hosts, workers] = await Promise.all([ const hosts = await loadJson<ListResponse<Host>>(fetch, apiPath("/hosts"));
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
]);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
hosts: hosts.data, hosts: hosts.data,
hostsError: hosts.error, hostsError: hosts.error,
workers: workers.data,
workersError: workers.error,
}; };
}; };

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { Worker } from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function workerStatus(worker: Worker): string {
return `${worker.state} · ${worker.status}`;
}
function workerProfile(worker: Worker): string {
return worker.profile ?? worker.role ?? 'unknown';
}
function workerDirectory(worker: Worker): string {
const directory = worker.working_directory;
if (!directory) return '—';
const selector = directory.requested_selector ?? 'HEAD';
const commit = directory.resolved_commit ? directory.resolved_commit.slice(0, 12) : null;
return commit ? `${directory.repository_id} · ${selector} · ${commit}` : `${directory.repository_id} · ${selector}`;
}
</script>
<svelte:head>
<title>Workers · Yoi Workspace</title>
<meta name="description" content="Workspace Workers" />
</svelte:head>
<section class="workers-page" aria-labelledby="workers-heading">
<header class="workers-page-header">
<div>
<h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace.</p>
</div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if data.workers.items.length === 0}
<p class="section-state">No Workers are visible.</p>
{:else}
<div class="table-wrap workers-table-wrap">
<table class="workers-table">
<thead>
<tr>
<th>Worker</th>
<th>Runtime</th>
<th>Profile</th>
<th>Status</th>
<th>Working directory</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each data.workers.items as worker}
<tr>
<td>
<strong>{worker.label}</strong>
<small><code>{worker.worker_id}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
<td>{workerStatus(worker)}</td>
<td>{workerDirectory(worker)}</td>
<td>
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>

View File

@ -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<ListResponse<Worker>>(
fetch,
workspaceApiPath(params.workspaceId, "/workers"),
);
return {
workspaceId: params.workspaceId,
workers: workers.data,
workersError: workers.error,
};
};