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 worker_runtime::catalog::ProfileSelector;
|
||||
use worker_runtime::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
|
||||
use crate::hosts::{
|
||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
||||
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
||||
WorkerSummary, WorkerTranscriptItem as RuntimeTranscriptItem, WorkerTranscriptProjection,
|
||||
};
|
||||
|
||||
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const COMPANION_PROFILE_ID: &str = "builtin:companion";
|
||||
const COMPANION_CONFIG_BUNDLE_ID: &str = "workspace-companion-config";
|
||||
const MAX_MESSAGE_CHARS: usize = 8_000;
|
||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompanionState {
|
||||
Ready,
|
||||
Busy,
|
||||
Error,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Accepted,
|
||||
Disabled,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -94,698 +72,77 @@ pub struct CompanionTranscriptItem {
|
|||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CompanionWorkerState {
|
||||
state: CompanionState,
|
||||
worker: Option<WorkerSummary>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
pub struct CompanionConsole {
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
worker: Mutex<CompanionWorkerState>,
|
||||
}
|
||||
pub struct CompanionConsole;
|
||||
|
||||
impl CompanionConsole {
|
||||
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
|
||||
let initial = spawn_companion_worker(&runtime);
|
||||
Self {
|
||||
runtime,
|
||||
worker: Mutex::new(initial),
|
||||
}
|
||||
pub fn disabled() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CompanionStatusResponse {
|
||||
match self.refresh_worker_state() {
|
||||
Ok(worker) => CompanionStatusResponse {
|
||||
state: worker.state,
|
||||
worker: worker.worker.clone(),
|
||||
transport: companion_transport(worker.worker.as_ref()),
|
||||
diagnostics: worker.diagnostics.clone(),
|
||||
},
|
||||
Err(diagnostic) => CompanionStatusResponse {
|
||||
state: CompanionState::Error,
|
||||
CompanionStatusResponse {
|
||||
state: CompanionState::Disabled,
|
||||
worker: None,
|
||||
transport: companion_transport(None),
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
transport: disabled_transport(),
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||
match self.current_worker() {
|
||||
Ok(Some(worker)) => {
|
||||
match self
|
||||
.runtime
|
||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, start, limit)
|
||||
{
|
||||
Ok(transcript) => project_runtime_transcript(
|
||||
&transcript,
|
||||
companion_state_for_worker(&worker),
|
||||
Vec::new(),
|
||||
),
|
||||
Err(error) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
CompanionTranscriptProjection {
|
||||
state: CompanionState::Disabled,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_transcript_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker transcript is unavailable: {error:?}"),
|
||||
)],
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_worker_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace Companion Worker is unavailable",
|
||||
)],
|
||||
},
|
||||
Err(diagnostic) => CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||
let content = request.content.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_empty",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Companion message content is empty",
|
||||
));
|
||||
}
|
||||
if content.chars().count() > MAX_MESSAGE_CHARS {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_too_large",
|
||||
DiagnosticSeverity::Warning,
|
||||
format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"),
|
||||
));
|
||||
}
|
||||
|
||||
let worker = match self.current_worker() {
|
||||
Ok(Some(worker)) => worker,
|
||||
Ok(None) => {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_worker_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace Companion Worker is unavailable",
|
||||
));
|
||||
}
|
||||
Err(diagnostic) => return self.rejected_message_response(diagnostic),
|
||||
};
|
||||
|
||||
let response = self.runtime.send_input(
|
||||
COMPANION_RUNTIME_ID,
|
||||
&worker.worker_id,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::User,
|
||||
content: content.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
let state = match result.state {
|
||||
WorkerOperationState::Accepted => CompanionState::Accepted,
|
||||
WorkerOperationState::Unsupported | WorkerOperationState::Rejected => {
|
||||
CompanionState::Rejected
|
||||
}
|
||||
};
|
||||
let diagnostics = if result.diagnostics.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
result.diagnostics.clone()
|
||||
};
|
||||
let projection = self.transcript(0, 200);
|
||||
CompanionMessageResponse {
|
||||
state,
|
||||
worker: projection_worker(&self.status()),
|
||||
user_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "user" && item.content == content)
|
||||
.cloned(),
|
||||
assistant_item: projection
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.role == "assistant")
|
||||
.cloned(),
|
||||
transcript: projection,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
Err(error) => self.rejected_message_response(diagnostic(
|
||||
"companion_worker_input_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker input dispatch failed: {error:?}"),
|
||||
)),
|
||||
}
|
||||
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||
disabled_message_response(CompanionState::Rejected)
|
||||
}
|
||||
|
||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||
let diagnostics = vec![diagnostic(
|
||||
"companion_cancel_no_active_run",
|
||||
DiagnosticSeverity::Info,
|
||||
"Workspace Companion has no active generation to cancel",
|
||||
)];
|
||||
let status = self.status();
|
||||
let projection = self.transcript(0, 200);
|
||||
disabled_message_response(CompanionState::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_message_response(state: CompanionState) -> 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,
|
||||
start: transcript.start,
|
||||
limit: transcript.limit,
|
||||
total_items: transcript.total_items,
|
||||
next_start: transcript.next_start,
|
||||
items: transcript
|
||||
.items
|
||||
.iter()
|
||||
.map(project_runtime_transcript_item)
|
||||
.collect(),
|
||||
diagnostics,
|
||||
worker: None,
|
||||
user_item: None,
|
||||
assistant_item: None,
|
||||
transcript: CompanionTranscriptProjection {
|
||||
state: CompanionState::Disabled,
|
||||
start: 0,
|
||||
limit: 200,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
},
|
||||
diagnostics: vec![disabled_diagnostic()],
|
||||
}
|
||||
}
|
||||
|
||||
fn project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem {
|
||||
CompanionTranscriptItem {
|
||||
sequence: item.sequence,
|
||||
role: item.role.clone(),
|
||||
content: item.content.clone(),
|
||||
created_at: format!("runtime_sequence:{}", item.sequence),
|
||||
source: "worker_runtime".to_string(),
|
||||
status: "committed".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary {
|
||||
if worker.is_some_and(|worker| worker.capabilities.can_accept_input) {
|
||||
fn disabled_transport() -> CompanionTransportSummary {
|
||||
CompanionTransportSummary {
|
||||
kind: "embedded_worker_runtime".to_string(),
|
||||
completion: "connected".to_string(),
|
||||
kind: "none".to_string(),
|
||||
completion: "disabled".to_string(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic(
|
||||
code: impl Into<String>,
|
||||
severity: DiagnosticSeverity,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
fn disabled_diagnostic() -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hosts::{EmbeddedWorkerRuntime, RuntimeRegistry};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use worker_runtime::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState,
|
||||
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use worker_runtime::identity::WorkerRef;
|
||||
use worker_runtime::interaction::WorkerInput;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DeterministicExecutionBackend {
|
||||
contexts: StdMutex<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"
|
||||
}));
|
||||
code: "companion_disabled".to_string(),
|
||||
severity: DiagnosticSeverity::Info,
|
||||
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
pub struct WorkerLookupResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -874,7 +879,12 @@ impl RuntimeRegistry {
|
|||
}
|
||||
let mut list = runtime.list_workers(limit.saturating_sub(items.len()));
|
||||
diagnostics.append(&mut list.diagnostics);
|
||||
items.append(&mut list.items);
|
||||
items.extend(
|
||||
list.items
|
||||
.into_iter()
|
||||
.filter(|worker| !is_retired_companion_worker(worker))
|
||||
.take(limit.saturating_sub(items.len())),
|
||||
);
|
||||
}
|
||||
diagnostics.truncate(MAX_DIAGNOSTICS);
|
||||
RuntimeList::new(items, diagnostics)
|
||||
|
|
@ -904,6 +914,7 @@ impl RuntimeRegistry {
|
|||
.items
|
||||
.into_iter()
|
||||
.filter(|worker| worker.host_id == host_id)
|
||||
.filter(|worker| !is_retired_companion_worker(worker))
|
||||
.take(limit.saturating_sub(items.len())),
|
||||
);
|
||||
if items.len() >= limit {
|
||||
|
|
@ -927,9 +938,16 @@ impl RuntimeRegistry {
|
|||
validate_backend_identifier("worker_id", worker_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
let lookup = runtime.worker(worker_id);
|
||||
lookup.worker.ok_or_else(|| {
|
||||
let worker = lookup.worker.ok_or_else(|| {
|
||||
operation_failed_or_unknown_worker(runtime_id, worker_id, lookup.diagnostics)
|
||||
})
|
||||
})?;
|
||||
if is_retired_companion_worker(&worker) {
|
||||
return Err(RuntimeRegistryError::UnknownWorker {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(worker)
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ impl WorkspaceApi {
|
|||
);
|
||||
}
|
||||
let runtime = Arc::new(runtime);
|
||||
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
|
||||
let companion = Arc::new(CompanionConsole::disabled());
|
||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
|
|
@ -1590,6 +1590,7 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
|
|||
)
|
||||
}
|
||||
}
|
||||
"disabled" => "Workspace Companion auto-start has been removed; create an explicit Worker instead.".to_string(),
|
||||
other => format!(
|
||||
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
|
||||
),
|
||||
|
|
@ -5064,24 +5065,18 @@ mod tests {
|
|||
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
let worker_items = workers["items"].as_array().unwrap();
|
||||
let companion_worker = worker_items
|
||||
assert!(
|
||||
worker_items
|
||||
.iter()
|
||||
.find(|worker| worker["role"] == "builtin:companion")
|
||||
.expect("companion worker is visible through runtime worker API");
|
||||
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
|
||||
assert!(companion_worker["capabilities"]["can_stop"].is_boolean());
|
||||
.all(|worker| worker["role"] != "builtin:companion"),
|
||||
"companion auto-start should not create runtime workers: {workers}"
|
||||
);
|
||||
|
||||
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
||||
assert!(matches!(
|
||||
companion_status["state"].as_str(),
|
||||
Some("ready") | Some("error")
|
||||
));
|
||||
assert_eq!(companion_status["worker"]["role"], "builtin:companion");
|
||||
assert_eq!(
|
||||
companion_status["transport"]["kind"],
|
||||
"embedded_worker_runtime"
|
||||
);
|
||||
assert_ne!(companion_status["transport"]["completion"], "not_connected");
|
||||
assert_eq!(companion_status["state"], "disabled");
|
||||
assert!(companion_status["worker"].is_null());
|
||||
assert_eq!(companion_status["transport"]["kind"], "none");
|
||||
assert_eq!(companion_status["transport"]["completion"], "disabled");
|
||||
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_message = post_json(
|
||||
|
|
@ -5090,20 +5085,17 @@ mod tests {
|
|||
json!({ "content": "hello companion" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(companion_message["state"], "accepted");
|
||||
assert_eq!(companion_message["user_item"]["role"], "user");
|
||||
assert_eq!(companion_message["user_item"]["content"], "hello companion");
|
||||
assert!(
|
||||
!companion_message
|
||||
.to_string()
|
||||
.contains("companion_llm_not_connected"),
|
||||
"legacy non-execution diagnostic leaked: {companion_message}"
|
||||
assert_eq!(companion_message["state"], "rejected");
|
||||
assert_eq!(
|
||||
companion_message["diagnostics"][0]["code"],
|
||||
"companion_disabled"
|
||||
);
|
||||
assert!(!companion_message.to_string().contains("providerless"));
|
||||
assert!(companion_message["user_item"].is_null());
|
||||
assert!(companion_message["assistant_item"].is_null());
|
||||
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
assert!(companion_transcript["total_items"].as_u64().unwrap() >= 1);
|
||||
assert_eq!(companion_transcript["total_items"], 0);
|
||||
|
||||
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
||||
assert!(
|
||||
|
|
@ -5111,7 +5103,7 @@ mod tests {
|
|||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|worker| worker["role"] == "builtin:companion")
|
||||
.all(|worker| worker["role"] != "builtin:companion")
|
||||
);
|
||||
|
||||
let runs_response = app
|
||||
|
|
@ -5193,7 +5185,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_companion_messages_route_dispatches_through_worker_runtime() {
|
||||
async fn companion_routes_report_disabled_without_spawning_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = test_server_config(temp.path().join("workspace"));
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
|
|
@ -5207,24 +5199,22 @@ mod tests {
|
|||
|
||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
||||
assert_eq!(workspace_companion["status"], "connected");
|
||||
assert!(
|
||||
workspace_companion["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
assert_eq!(workspace_companion["status"], "disabled");
|
||||
assert_eq!(
|
||||
workspace_companion["diagnostics"][0]["code"],
|
||||
"companion_disabled"
|
||||
);
|
||||
assert!(
|
||||
workspace_companion["note"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("normal Worker runtime path")
|
||||
.contains("auto-start has been removed")
|
||||
);
|
||||
|
||||
let status = get_json(app.clone(), "/api/companion/status").await;
|
||||
assert_eq!(status["transport"]["completion"], "connected");
|
||||
let worker_id = status["worker"]["worker_id"].as_str().unwrap().to_string();
|
||||
assert_eq!(status["worker"]["profile"], "builtin:companion");
|
||||
assert_eq!(status["state"], "disabled");
|
||||
assert_eq!(status["transport"]["completion"], "disabled");
|
||||
assert!(status["worker"].is_null());
|
||||
|
||||
let response = post_json(
|
||||
app.clone(),
|
||||
|
|
@ -5232,49 +5222,23 @@ mod tests {
|
|||
serde_json::json!({ "content": "from legacy route" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["state"], "accepted");
|
||||
assert_eq!(response["user_item"]["content"], "from legacy route");
|
||||
assert!(!response.to_string().contains("companion_llm_not_connected"));
|
||||
assert_eq!(response["state"], "rejected");
|
||||
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
|
||||
assert!(response["user_item"].is_null());
|
||||
assert!(response["assistant_item"].is_null());
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let transcript = loop {
|
||||
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
let has_assistant = transcript["items"].as_array().unwrap().iter().any(|item| {
|
||||
item["role"] == "assistant"
|
||||
&& item["content"] == "server companion echoed: from legacy route"
|
||||
&& item["source"] == "worker_runtime"
|
||||
});
|
||||
if has_assistant {
|
||||
break transcript;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for server companion transcript: {transcript}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
};
|
||||
assert!(
|
||||
transcript["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| { item["role"] == "user" && item["content"] == "from legacy route" })
|
||||
);
|
||||
assert_eq!(transcript["state"], "disabled");
|
||||
assert_eq!(transcript["total_items"], 0);
|
||||
|
||||
let worker_transcript = get_json(
|
||||
app,
|
||||
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript"),
|
||||
)
|
||||
.await;
|
||||
let workers = get_json(app, "/api/workers").await;
|
||||
assert!(
|
||||
worker_transcript["items"]
|
||||
workers["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item["role"] == "assistant"
|
||||
&& item["content"] == "server companion echoed: from legacy route"
|
||||
})
|
||||
.all(|worker| worker["role"] != "builtin:companion"),
|
||||
"disabled companion route should not spawn workers: {workers}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -349,6 +349,17 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.section-heading-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section-heading-link:hover,
|
||||
.section-heading-link:focus-visible,
|
||||
.section-heading-link.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent-muted);
|
||||
margin: 0;
|
||||
|
|
@ -635,6 +646,44 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workers-page {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.workers-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.workers-page-header h1,
|
||||
.workers-page-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workers-page-header h1 {
|
||||
color: var(--text-strong);
|
||||
font-size: clamp(1.6rem, 4vw, 2.4rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.workers-page-header p {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workers-table-wrap {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workers-table td small {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||
}
|
||||
}
|
||||
|
||||
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
||||
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
|
||||
const workspacePage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
||||
);
|
||||
const workersPage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/workers/+page.svelte", import.meta.url),
|
||||
);
|
||||
const workersNav = await Deno.readTextFile(
|
||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||
);
|
||||
|
|
@ -21,14 +24,21 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
|
|||
);
|
||||
|
||||
assert(
|
||||
workspacePage.includes("workerConsoleHref(worker, workspaceId)") &&
|
||||
workspacePage.includes("Open Console"),
|
||||
"top Worker list should expose an attach action per Worker",
|
||||
!workspacePage.includes("workerConsoleHref") &&
|
||||
!workspacePage.includes("Open Console"),
|
||||
"top workspace page should not own the Worker list",
|
||||
);
|
||||
assert(
|
||||
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("aria-current"),
|
||||
"Workers sidebar rows should link to the Worker target Console route",
|
||||
"Workers sidebar should link to the Worker list page and target Console routes",
|
||||
);
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
|
|
|
|||
|
|
@ -68,7 +68,14 @@
|
|||
|
||||
<section class="nav-section" aria-labelledby="workers-heading">
|
||||
<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
|
||||
class="section-action"
|
||||
class:active={currentPath === `/w/${workspaceId}/workers/new`}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -39,8 +37,7 @@
|
|||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<section class="card">
|
||||
<h2>Hosts</h2>
|
||||
{#if data.hosts}
|
||||
{#if data.hosts.items.length === 0}
|
||||
|
|
@ -84,50 +81,4 @@
|
|||
{:else}
|
||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||
{/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>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||
import type { Host, ListResponse } from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||
const [hosts, workers] = await Promise.all([
|
||||
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
|
||||
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
|
||||
]);
|
||||
const hosts = await loadJson<ListResponse<Host>>(fetch, apiPath("/hosts"));
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
hosts: hosts.data,
|
||||
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