ui: move workers list to page
This commit is contained in:
parent
a3cb59af43
commit
47e03bd54b
|
|
@ -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(),
|
.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(
|
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"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") &&
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
16
web/workspace/src/routes/w/[workspaceId]/workers/+page.ts
Normal file
16
web/workspace/src/routes/w/[workspaceId]/workers/+page.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user