feat: add workspace companion console MVP

This commit is contained in:
2026-06-26 17:01:30 +09:00
parent f39036032b
commit f3ad9c96b3
9 changed files with 1309 additions and 1 deletions
+625
View File
@@ -0,0 +1,625 @@
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use worker_runtime::catalog::{CapabilityRequest, ProfileSelector};
use crate::hosts::{
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
WorkerSummary,
};
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
const MAX_MESSAGE_CHARS: usize = 8_000;
const PROVIDERLESS_RESPONSE: &str =
include_str!("../../../resources/prompts/worker/web_companion_providerless.md");
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompanionState {
Ready,
Busy,
Error,
Timeout,
Cancelled,
Accepted,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionStatusResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
pub transport: CompanionTransportSummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTransportSummary {
pub kind: String,
pub completion: String,
pub limitation: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageRequest {
pub content: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_item: Option<CompanionTranscriptItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_item: Option<CompanionTranscriptItem>,
pub transcript: CompanionTranscriptProjection,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptProjection {
pub state: CompanionState,
pub start: usize,
pub limit: usize,
pub total_items: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_start: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptItem {
pub sequence: u64,
pub role: String,
pub content: String,
pub created_at: String,
pub source: String,
pub status: String,
}
#[derive(Debug, Default)]
struct CompanionTranscript {
items: Vec<CompanionTranscriptItem>,
next_sequence: u64,
}
#[derive(Debug)]
struct CompanionWorkerState {
state: CompanionState,
worker: Option<WorkerSummary>,
diagnostics: Vec<RuntimeDiagnostic>,
}
pub struct CompanionConsole {
runtime: Arc<RuntimeRegistry>,
worker: Mutex<CompanionWorkerState>,
transcript: Mutex<CompanionTranscript>,
}
impl CompanionConsole {
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
let initial = spawn_companion_worker(&runtime);
Self {
runtime,
worker: Mutex::new(initial),
transcript: Mutex::new(CompanionTranscript::default()),
}
}
pub fn status(&self) -> CompanionStatusResponse {
let worker = match self.worker.lock() {
Ok(worker) => worker,
Err(_) => {
return CompanionStatusResponse {
state: CompanionState::Error,
worker: None,
transport: providerless_transport(),
diagnostics: vec![diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)],
};
}
};
CompanionStatusResponse {
state: worker.state,
worker: worker.worker.clone(),
transport: providerless_transport(),
diagnostics: worker.diagnostics.clone(),
}
}
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
let transcript = match self.transcript.lock() {
Ok(transcript) => transcript,
Err(_) => {
return CompanionTranscriptProjection {
state: CompanionState::Error,
start,
limit,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
)],
};
}
};
project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new())
}
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 mut transcript = match self.transcript.try_lock() {
Ok(transcript) => transcript,
Err(std::sync::TryLockError::WouldBlock) => {
return self.busy_message_response();
}
Err(std::sync::TryLockError::Poisoned(_)) => {
return self.error_message_response(diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
));
}
};
let (worker, mut diagnostics) = match self.current_worker() {
Ok((Some(worker), diagnostics)) => (worker, diagnostics),
Ok((None, diagnostics)) => {
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
None,
None,
None,
0,
200,
diagnostics,
);
}
Err(diagnostic) => {
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
None,
None,
None,
0,
200,
vec![diagnostic],
);
}
};
let user_item = transcript.push("user", content.clone(), "browser_request", "accepted");
match self.runtime.send_input(
&worker.runtime_id,
&worker.worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content,
},
) {
Ok(result) if result.state == WorkerOperationState::Accepted => {
diagnostics.extend(result.diagnostics);
}
Ok(result) => {
diagnostics.extend(result.diagnostics);
diagnostics.push(diagnostic(
"companion_runtime_input_rejected",
DiagnosticSeverity::Error,
"Embedded Companion Worker rejected the browser message",
));
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
Some(worker),
Some(user_item),
None,
0,
200,
diagnostics,
);
}
Err(error) => {
diagnostics.push(diagnostic(
"companion_runtime_input_failed",
DiagnosticSeverity::Error,
format!("Embedded Companion Worker input failed: {error:?}"),
));
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
Some(worker),
Some(user_item),
None,
0,
200,
diagnostics,
);
}
}
diagnostics.push(diagnostic(
"companion_providerless_boundary",
DiagnosticSeverity::Info,
"Real LLM completion is not connected in this MVP; response is the backend provider-less boundary text",
));
let assistant_item = transcript.push(
"assistant",
providerless_response_text(),
"backend_providerless_boundary",
"complete",
);
response_from_locked_transcript(
&transcript,
CompanionState::Accepted,
Some(worker),
Some(user_item),
Some(assistant_item),
0,
200,
diagnostics,
)
}
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
let diagnostics = vec![diagnostic(
"companion_cancel_no_active_run",
DiagnosticSeverity::Info,
"Provider-less Companion Console has no active generation to cancel",
)];
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Cancelled,
self.status().worker,
None,
None,
0,
200,
diagnostics,
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Error,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
)],
},
diagnostics,
},
}
}
fn current_worker(
&self,
) -> Result<(Option<WorkerSummary>, Vec<RuntimeDiagnostic>), RuntimeDiagnostic> {
let worker = self.worker.lock().map_err(|_| {
diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)
})?;
Ok((worker.worker.clone(), worker.diagnostics.clone()))
}
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Rejected,
self.status().worker,
None,
None,
0,
200,
vec![diagnostic],
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Rejected,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
},
}
}
fn busy_message_response(&self) -> CompanionMessageResponse {
let diagnostic = diagnostic(
"companion_busy",
DiagnosticSeverity::Warning,
"Companion Console is already processing a message",
);
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Busy,
self.status().worker,
None,
None,
0,
200,
vec![diagnostic],
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Busy,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Busy,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
},
}
}
fn error_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
CompanionMessageResponse {
state: CompanionState::Error,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
}
}
}
impl CompanionTranscript {
fn push(
&mut self,
role: impl Into<String>,
content: impl Into<String>,
source: impl Into<String>,
status: impl Into<String>,
) -> CompanionTranscriptItem {
self.next_sequence = self.next_sequence.saturating_add(1);
let item = CompanionTranscriptItem {
sequence: self.next_sequence,
role: role.into(),
content: content.into(),
created_at: Utc::now().to_rfc3339(),
source: source.into(),
status: status.into(),
};
self.items.push(item.clone());
item
}
}
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
let request = WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCompanion,
requested_worker_name: Some("workspace-companion".to_string()),
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: Some(ProfileSelector::RuntimeDefault),
config_bundle: None,
requested_capabilities: vec![CapabilityRequest::named("conversation")],
};
match runtime.spawn_worker(COMPANION_RUNTIME_ID, request) {
Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState {
state: CompanionState::Ready,
worker: result.worker,
diagnostics: result.diagnostics,
},
Ok(result) => CompanionWorkerState {
state: CompanionState::Error,
worker: result.worker,
diagnostics: result.diagnostics,
},
Err(error) => CompanionWorkerState {
state: CompanionState::Error,
worker: None,
diagnostics: vec![diagnostic(
"companion_worker_spawn_failed",
DiagnosticSeverity::Error,
format!("Companion Worker spawn failed: {error:?}"),
)],
},
}
}
fn response_from_locked_transcript(
transcript: &CompanionTranscript,
state: CompanionState,
worker: Option<WorkerSummary>,
user_item: Option<CompanionTranscriptItem>,
assistant_item: Option<CompanionTranscriptItem>,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionMessageResponse {
CompanionMessageResponse {
state,
worker,
user_item,
assistant_item,
transcript: project_transcript(transcript, state, start, limit, diagnostics.clone()),
diagnostics,
}
}
fn project_transcript(
transcript: &CompanionTranscript,
state: CompanionState,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionTranscriptProjection {
let limit = limit.min(200);
let total_items = transcript.items.len();
let end = start.saturating_add(limit).min(total_items);
let items = if start < total_items {
transcript.items[start..end].to_vec()
} else {
Vec::new()
};
CompanionTranscriptProjection {
state,
start,
limit,
total_items,
next_start: (end < total_items).then_some(end),
items,
diagnostics,
}
}
fn providerless_response_text() -> String {
PROVIDERLESS_RESPONSE.trim().to_string()
}
fn providerless_transport() -> CompanionTransportSummary {
CompanionTransportSummary {
kind: "providerless_backend_internal".to_string(),
completion: "synchronous_request_response".to_string(),
limitation: "No provider-backed LLM generation is wired in this MVP; browser messages are recorded by a backend-internal tools-less Companion Worker and receive a resource-defined boundary response.".to_string(),
}
}
fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.into(),
severity,
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hosts::{EmbeddedWorkerRuntime, LocalWorkerRuntime, RuntimeRegistry};
#[test]
fn companion_spawns_visible_worker_and_records_providerless_turn() {
let registry = RuntimeRegistry::for_workspace(
LocalWorkerRuntime::new("local:test", "/workspace/project", None),
EmbeddedWorkerRuntime::new_memory("local:test"),
);
let registry = Arc::new(registry);
let companion = CompanionConsole::new(registry.clone());
let status = companion.status();
assert_eq!(status.state, CompanionState::Ready);
let worker = status.worker.clone().expect("companion worker");
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
assert_eq!(worker.role.as_deref(), Some("workspace_companion"));
assert!(!worker.capabilities.can_stop);
let workers = registry.list_workers(10);
assert!(
workers
.items
.iter()
.any(|item| item.worker_id == worker.worker_id)
);
let response = companion.send_message(CompanionMessageRequest {
content: "hello".to_string(),
});
assert_eq!(response.state, CompanionState::Accepted);
assert_eq!(response.transcript.items.len(), 2);
assert_eq!(response.transcript.items[0].role, "user");
assert_eq!(response.transcript.items[1].role, "assistant");
assert!(
response.transcript.items[1]
.content
.contains("provider-less")
);
let runtime_transcript = registry
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 10)
.unwrap();
assert_eq!(runtime_transcript.items.len(), 1);
assert_eq!(runtime_transcript.items[0].role, "user");
let browser_payload = serde_json::to_string(&(status, response)).unwrap();
for forbidden in [
"/workspace/project",
"metadata.json",
".jsonl",
"/run/user/",
] {
assert!(
!browser_payload.contains(forbidden),
"companion projection leaked forbidden term {forbidden}: {browser_payload}"
);
}
}
}
+1
View File
@@ -4,6 +4,7 @@
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
//! remain the canonical project records and are read through bounded bridge APIs.
pub mod companion;
pub mod hosts;
pub mod identity;
pub mod observation;
+85 -1
View File
@@ -12,6 +12,10 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
HostSummary, LocalWorkerRuntime, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic,
@@ -80,6 +84,7 @@ pub struct WorkspaceApi {
store: Arc<dyn ControlPlaneStore>,
records: LocalProjectRecordReader,
runtime: Arc<RuntimeRegistry>,
companion: Arc<CompanionConsole>,
observation_proxy: BackendObservationProxy,
}
@@ -107,12 +112,14 @@ impl WorkspaceApi {
.register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?);
}
let runtime = Arc::new(runtime);
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
config,
store,
runtime,
companion,
observation_proxy,
})
}
@@ -154,6 +161,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
.route("/api/hosts", get(list_hosts))
.route("/api/runtimes", get(list_runtimes))
.route("/api/workers", get(list_workers))
.route("/api/companion/status", get(get_companion_status))
.route("/api/companion/transcript", get(get_companion_transcript))
.route("/api/companion/messages", post(post_companion_message))
.route("/api/companion/cancel", post(post_companion_cancel))
.route(
"/api/runtimes/{runtime_id}/workers",
post(create_runtime_worker),
@@ -221,6 +232,7 @@ pub struct ExtensionPoints {
pub store: String,
pub event_stream: ExtensionPointState,
pub host_worker_bridge: ExtensionPointState,
pub companion_console: ExtensionPointState,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -329,6 +341,10 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
status: "read_only_local".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Worker metadata; no direct Worker socket, scheduling, or lifecycle control is implemented.".to_string(),
},
companion_console: ExtensionPointState {
status: "providerless_mvp".to_string(),
note: "Backend-internal tools-less Companion Worker is available through Workspace API status/transcript/message endpoints; v0 records browser messages and returns a resource-defined provider-less response instead of direct LLM generation.".to_string(),
},
},
}))
}
@@ -491,6 +507,35 @@ async fn list_workers(
workers_response(api).map(Json)
}
async fn get_companion_status(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<CompanionStatusResponse>> {
Ok(Json(api.companion.status()))
}
async fn get_companion_transcript(
State(api): State<WorkspaceApi>,
Query(query): Query<TranscriptQuery>,
) -> ApiResult<Json<CompanionTranscriptProjection>> {
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let start = query.start.unwrap_or(0);
Ok(Json(api.companion.transcript(start, limit)))
}
async fn post_companion_message(
State(api): State<WorkspaceApi>,
Json(request): Json<CompanionMessageRequest>,
) -> ApiResult<Json<CompanionMessageResponse>> {
Ok(Json(api.companion.send_message(request)))
}
async fn post_companion_cancel(
State(api): State<WorkspaceApi>,
Json(request): Json<CompanionCancelRequest>,
) -> ApiResult<Json<CompanionMessageResponse>> {
Ok(Json(api.companion.cancel(request)))
}
async fn get_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
@@ -1095,12 +1140,51 @@ mod tests {
assert_eq!(runtimes["items"][0]["host_ids"][0], host_id);
let workers = get_json(app.clone(), "/api/workers").await;
assert!(workers["items"].as_array().unwrap().is_empty());
let worker_items = workers["items"].as_array().unwrap();
let companion_worker = worker_items
.iter()
.find(|worker| worker["role"] == "workspace_companion")
.expect("companion worker is visible through runtime worker API");
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
assert_eq!(companion_worker["capabilities"]["can_stop"], false);
assert_eq!(
workers["diagnostics"][0]["code"],
"local_pod_registry_unreadable"
);
let companion_status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(companion_status["state"], "ready");
assert_eq!(companion_status["worker"]["role"], "workspace_companion");
assert_eq!(
companion_status["transport"]["kind"],
"providerless_backend_internal"
);
assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json(
app.clone(),
"/api/companion/messages",
json!({ "content": "hello companion" }),
)
.await;
assert_eq!(companion_message["state"], "accepted");
assert_eq!(companion_message["transcript"]["items"][0]["role"], "user");
assert_eq!(
companion_message["transcript"]["items"][1]["role"],
"assistant"
);
assert!(
companion_message["transcript"]["items"][1]["content"]
.as_str()
.unwrap()
.contains("provider-less")
);
assert!(!companion_message.to_string().contains("/workspace/demo"));
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
assert_eq!(companion_transcript["total_items"], 2);
assert_eq!(companion_transcript["items"][1]["role"], "assistant");
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
assert!(host_workers["items"].as_array().unwrap().is_empty());