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
+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());