From 58143ead835fca9a1628875628681c5292c2d364 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 22 Jun 2026 01:30:48 +0900 Subject: [PATCH 1/6] feat: expose workspace hosts and workers --- Cargo.lock | 2 + crates/workspace-server/Cargo.toml | 2 + crates/workspace-server/src/hosts.rs | 552 ++++++++++++++++++++++++++ crates/workspace-server/src/lib.rs | 3 + crates/workspace-server/src/server.rs | 107 ++++- crates/workspace-server/src/store.rs | 46 --- package.nix | 2 +- web/workspace/src/routes/+page.svelte | 274 ++++++++++++- 8 files changed, 913 insertions(+), 75 deletions(-) create mode 100644 crates/workspace-server/src/hosts.rs diff --git a/Cargo.lock b/Cargo.lock index 7d492b65..da305388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6027,6 +6027,8 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "manifest", + "pod-store", "project-record", "rusqlite", "serde", diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 444f36b0..515bd2a4 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -8,6 +8,8 @@ publish = false [dependencies] async-trait.workspace = true axum.workspace = true +manifest = { workspace = true } +pod-store = { workspace = true } project-record.workspace = true rusqlite.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs new file mode 100644 index 00000000..3fc82d02 --- /dev/null +++ b/crates/workspace-server/src/hosts.rs @@ -0,0 +1,552 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use pod_store::{PodMetadata, validate_pod_name}; +use serde::{Deserialize, Serialize}; + +const MAX_DIAGNOSTICS: usize = 20; +const MAX_LABEL_LEN: usize = 120; +const MAX_PATH_LEN: usize = 512; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeDiagnostic { + pub code: String, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostSummary { + pub host_id: String, + pub label: String, + pub kind: String, + pub status: String, + pub observed_at: String, + pub last_seen_at: String, + pub capabilities: HostCapabilitySummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostCapabilitySummary { + pub local_pod_inspection: String, + pub workspace_root: String, + pub os: String, + pub arch: String, + pub max_workers: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerSummary { + pub worker_id: String, + pub host_id: String, + pub label: String, + pub pod_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + pub state: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen_at: Option, + pub implementation: WorkerImplementation, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerImplementation { + pub kind: String, + pub pod_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalRuntimeBridge { + workspace_id: String, + workspace_root: PathBuf, + data_dir: Option, +} + +impl LocalRuntimeBridge { + pub fn new( + workspace_id: impl Into, + workspace_root: impl Into, + data_dir: Option, + ) -> Self { + Self { + workspace_id: workspace_id.into(), + workspace_root: workspace_root.into(), + data_dir, + } + } + + pub fn host_id(&self) -> String { + stable_local_host_id(&self.workspace_id) + } + + pub fn list_hosts(&self, limit: usize) -> (Vec, Vec) { + if limit == 0 { + return (Vec::new(), Vec::new()); + } + + let observed_at = unix_timestamp(SystemTime::now()); + let mut diagnostics = pod_root_diagnostics(self.pod_root().as_deref()); + let local_pod_inspection = if diagnostics.is_empty() { + "available" + } else { + "unavailable" + } + .to_string(); + let status = if local_pod_inspection == "available" { + "available" + } else { + "degraded" + } + .to_string(); + truncate_diagnostics(&mut diagnostics); + + let host = HostSummary { + host_id: self.host_id(), + label: format!( + "Local host ({})", + self.workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + ), + kind: "local_host".to_string(), + status, + observed_at: observed_at.clone(), + last_seen_at: observed_at, + capabilities: HostCapabilitySummary { + local_pod_inspection, + workspace_root: bounded_path(&self.workspace_root), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + max_workers: limit.min(200), + }, + diagnostics: diagnostics.clone(), + }; + + (vec![host], diagnostics) + } + + pub fn list_workers(&self, limit: usize) -> (Vec, Vec) { + let limit = limit.min(200); + let Some(pod_root) = self.pod_root() else { + return ( + Vec::new(), + vec![RuntimeDiagnostic::new( + "local_yoi_data_dir_unavailable", + "warning", + "local Yoi data directory is not configured; local Pod workers cannot be inspected", + )], + ); + }; + + let mut diagnostics = Vec::new(); + if !pod_root.exists() { + diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_root_missing", + "info", + "local Pod metadata directory is absent; no local workers were discovered", + )); + return (Vec::new(), diagnostics); + } + + let entries = match fs::read_dir(&pod_root) { + Ok(entries) => entries, + Err(error) => { + diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_root_unreadable", + "warning", + format!("local Pod metadata directory cannot be read: {error}"), + )); + return (Vec::new(), diagnostics); + } + }; + + let mut workers = Vec::new(); + let mut candidate_names = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_metadata_entry_unreadable", + "warning", + format!("one local Pod metadata entry cannot be read: {error}"), + ), + ); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_metadata_entry_type_unreadable", + "warning", + format!("one local Pod metadata entry type cannot be read: {error}"), + ), + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_name_non_utf8", + "warning", + "one local Pod metadata directory has a non-UTF-8 name and was skipped", + ), + ); + continue; + }; + if validate_pod_name(&name).is_err() || name.len() > MAX_LABEL_LEN { + push_diagnostic( + &mut diagnostics, + RuntimeDiagnostic::new( + "local_pod_name_invalid", + "warning", + "one local Pod metadata directory has an invalid or oversized name and was skipped", + ), + ); + continue; + } + if entry.path().join("metadata.json").exists() { + candidate_names.push(name); + } + } + + candidate_names.sort(); + candidate_names.truncate(limit); + for pod_name in candidate_names { + match read_worker(&pod_root, &pod_name, &self.host_id()) { + Ok(worker) => workers.push(worker), + Err(diagnostic) => push_diagnostic(&mut diagnostics, diagnostic), + } + } + truncate_diagnostics(&mut diagnostics); + (workers, diagnostics) + } + + fn pod_root(&self) -> Option { + self.data_dir.as_ref().map(|data_dir| data_dir.join("pods")) + } +} + +impl RuntimeDiagnostic { + pub fn new( + code: impl Into, + severity: impl Into, + message: impl Into, + ) -> Self { + Self { + code: truncate_string(&code.into(), MAX_LABEL_LEN), + severity: truncate_string(&severity.into(), MAX_LABEL_LEN), + message: truncate_string(&message.into(), 240), + } + } +} + +fn read_worker( + pod_root: &Path, + pod_name: &str, + host_id: &str, +) -> Result { + let metadata_path = pod_root.join(pod_name).join("metadata.json"); + let last_seen_at = metadata_path + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(unix_timestamp); + let raw = fs::read_to_string(&metadata_path).map_err(|error| { + RuntimeDiagnostic::new( + "local_pod_metadata_unreadable", + "warning", + format!("local Pod metadata for `{pod_name}` cannot be read: {error}"), + ) + })?; + let metadata: PodMetadata = serde_json::from_str(&raw).map_err(|error| { + RuntimeDiagnostic::new( + "local_pod_metadata_invalid", + "warning", + format!("local Pod metadata for `{pod_name}` is invalid: {error}"), + ) + })?; + + let mut worker_diagnostics = Vec::new(); + if metadata.pod_name != pod_name { + worker_diagnostics.push(RuntimeDiagnostic::new( + "local_pod_metadata_name_mismatch", + "warning", + "metadata pod_name differed from its directory name; the directory name was used", + )); + } + + let state = if metadata.active.is_some() { + "active" + } else { + "inactive" + } + .to_string(); + let status = match metadata.active.as_ref() { + Some(active) if active.segment_id.is_some() => "active_segment_known", + Some(_) => "active_session_pending_segment", + None => "metadata_only", + } + .to_string(); + let (role, profile) = extract_safe_role_profile(metadata.resolved_manifest_snapshot.as_ref()); + + Ok(WorkerSummary { + worker_id: format!("local-pod-{}", sanitize_identifier(pod_name, MAX_LABEL_LEN)), + host_id: host_id.to_string(), + label: truncate_string(pod_name, MAX_LABEL_LEN), + pod_name: truncate_string(pod_name, MAX_LABEL_LEN), + role, + profile, + workspace_root: metadata + .workspace_root + .as_ref() + .map(|path| bounded_path(path)), + state, + status, + last_seen_at, + implementation: WorkerImplementation { + kind: "local_pod".to_string(), + pod_name: truncate_string(pod_name, MAX_LABEL_LEN), + }, + diagnostics: worker_diagnostics, + }) +} + +fn pod_root_diagnostics(pod_root: Option<&Path>) -> Vec { + let Some(pod_root) = pod_root else { + return vec![RuntimeDiagnostic::new( + "local_yoi_data_dir_unavailable", + "warning", + "local Yoi data directory is not configured; local Pod inspection is unavailable", + )]; + }; + if !pod_root.exists() { + return vec![RuntimeDiagnostic::new( + "local_pod_metadata_root_missing", + "info", + "local Pod metadata directory is absent; local Pod inspection found no workers", + )]; + } + match fs::read_dir(pod_root) { + Ok(_) => Vec::new(), + Err(error) => vec![RuntimeDiagnostic::new( + "local_pod_metadata_root_unreadable", + "warning", + format!("local Pod metadata directory cannot be read: {error}"), + )], + } +} + +fn extract_safe_role_profile( + snapshot: Option<&serde_json::Value>, +) -> (Option, Option) { + let Some(snapshot) = snapshot else { + return (None, None); + }; + let role = snapshot + .get("role") + .and_then(|value| value.as_str()) + .and_then(safe_metadata_label); + let profile = snapshot + .get("profile") + .and_then(|profile| { + profile + .get("name") + .or_else(|| profile.get("selector")) + .or_else(|| profile.get("id")) + }) + .and_then(|value| value.as_str()) + .and_then(safe_metadata_label); + (role, profile) +} + +fn safe_metadata_label(value: &str) -> Option { + if value.is_empty() + || value.len() > MAX_LABEL_LEN + || value.contains('/') + || value.contains('\\') + || value.contains('\0') + || value.chars().any(|ch| ch.is_control()) + { + return None; + } + Some(value.to_string()) +} + +fn stable_local_host_id(workspace_id: &str) -> String { + format!("local-{}", sanitize_identifier(workspace_id, 96)) +} + +fn sanitize_identifier(value: &str, max_len: usize) -> String { + let mut output = String::new(); + for ch in value.chars() { + if output.len() >= max_len { + break; + } + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if !output.ends_with('-') { + output.push('-'); + } + } + let output = output.trim_matches('-'); + if output.is_empty() { + "local".to_string() + } else { + output.to_string() + } +} + +fn bounded_path(path: &Path) -> String { + truncate_string(&path.to_string_lossy(), MAX_PATH_LEN) +} + +fn truncate_string(value: &str, max_len: usize) -> String { + if value.len() <= max_len { + return value.to_string(); + } + let mut end = max_len; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn push_diagnostic(diagnostics: &mut Vec, diagnostic: RuntimeDiagnostic) { + if diagnostics.len() < MAX_DIAGNOSTICS { + diagnostics.push(diagnostic); + } +} + +fn truncate_diagnostics(diagnostics: &mut Vec) { + diagnostics.truncate(MAX_DIAGNOSTICS); +} + +fn unix_timestamp(time: SystemTime) -> String { + match time.duration_since(UNIX_EPOCH) { + Ok(duration) => format!("unix:{}", duration.as_secs()), + Err(_) => "unix:0".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn lists_workers_from_local_pod_metadata_without_exposing_snapshot_contents() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let worker_dir = data_dir.join("pods/coder"); + fs::create_dir_all(&worker_dir).unwrap(); + fs::write( + worker_dir.join("metadata.json"), + serde_json::to_vec_pretty(&json!({ + "pod_name": "coder", + "active": { + "session_id": "018f4b8e-7c8a-7b41-8d66-111111111111", + "segment_id": "018f4b8e-7c8a-7b41-8d66-222222222222" + }, + "workspace_root": "/workspace/project", + "resolved_manifest_snapshot": { + "role": "coder", + "profile": { "name": "builtin-coder" }, + "secret_token": "do-not-return", + "system_prompt": "do-not-return" + } + })) + .unwrap(), + ) + .unwrap(); + + let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir)); + let (workers, diagnostics) = bridge.list_workers(20); + assert!(diagnostics.is_empty()); + assert_eq!(workers.len(), 1); + let worker = &workers[0]; + assert_eq!(worker.worker_id, "local-pod-coder"); + assert_eq!(worker.host_id, "local-local-test"); + assert_eq!(worker.pod_name, "coder"); + assert_eq!(worker.role.as_deref(), Some("coder")); + assert_eq!(worker.profile.as_deref(), Some("builtin-coder")); + assert_eq!(worker.workspace_root.as_deref(), Some("/workspace/project")); + assert_eq!(worker.state, "active"); + assert_eq!(worker.status, "active_segment_known"); + assert_eq!(worker.implementation.kind, "local_pod"); + assert_eq!(worker.implementation.pod_name, "coder"); + + let response_json = serde_json::to_string(&workers).unwrap(); + assert!(!response_json.contains("do-not-return")); + assert!(!response_json.contains("system_prompt")); + assert!(!response_json.contains("session_id")); + assert!(!response_json.contains("segment_id")); + } + + #[test] + fn missing_local_pod_data_dir_degrades_to_empty_workers_and_diagnostic() { + let temp = tempfile::tempdir().unwrap(); + let bridge = LocalRuntimeBridge::new( + "local:test", + temp.path(), + Some(temp.path().join("missing-data")), + ); + let (workers, diagnostics) = bridge.list_workers(20); + assert!(workers.is_empty()); + assert_eq!(diagnostics[0].code, "local_pod_metadata_root_missing"); + + let (hosts, host_diagnostics) = bridge.list_hosts(20); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].status, "degraded"); + assert_eq!(hosts[0].capabilities.local_pod_inspection, "unavailable"); + assert_eq!(host_diagnostics[0].code, "local_pod_metadata_root_missing"); + } + + #[test] + fn worker_list_and_diagnostics_are_bounded() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let pod_root = data_dir.join("pods"); + fs::create_dir_all(&pod_root).unwrap(); + for index in 0..250 { + let worker_dir = pod_root.join(format!("worker-{index:03}")); + fs::create_dir_all(&worker_dir).unwrap(); + fs::write( + worker_dir.join("metadata.json"), + serde_json::to_vec_pretty(&json!({ + "pod_name": format!("worker-{index:03}"), + "workspace_root": "/workspace/project" + })) + .unwrap(), + ) + .unwrap(); + } + + let bridge = LocalRuntimeBridge::new("local:test", "/workspace/project", Some(data_dir)); + let (workers, diagnostics) = bridge.list_workers(5); + assert_eq!(workers.len(), 5); + assert!(diagnostics.len() <= MAX_DIAGNOSTICS); + assert_eq!(workers[0].pod_name, "worker-000"); + assert_eq!(workers[4].pod_name, "worker-004"); + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 502ca47d..e8a5a282 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -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 hosts; pub mod records; pub mod server; pub mod store; @@ -30,6 +31,8 @@ pub enum Error { InvalidRecordId(String), #[error("record `{0}` is missing frontmatter")] MissingFrontmatter(String), + #[error("unknown local host `{0}`")] + UnknownHost(String), #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 19d5f27d..6c2ea706 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -10,8 +10,9 @@ use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; +use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail}; -use crate::store::{ControlPlaneStore, RunSummary, RunnerSummary, WorkspaceRecord}; +use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; use crate::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -28,6 +29,7 @@ pub struct ServerConfig { pub static_assets_dir: Option, pub auth: AuthConfig, pub max_records: usize, + pub local_runtime_data_dir: Option, } impl ServerConfig { @@ -45,6 +47,7 @@ impl ServerConfig { token_configured: false, }, max_records: 200, + local_runtime_data_dir: manifest::paths::data_dir(), } } } @@ -84,6 +87,14 @@ impl WorkspaceApi { pub fn workspace_id(&self) -> &str { self.config.workspace_id.as_str() } + + fn local_runtime_bridge(&self) -> LocalRuntimeBridge { + LocalRuntimeBridge::new( + self.config.workspace_id.clone(), + self.config.workspace_root.clone(), + self.config.local_runtime_data_dir.clone(), + ) + } } pub fn build_router(api: WorkspaceApi) -> Router { @@ -94,7 +105,9 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/objectives", get(list_objectives)) .route("/api/objectives/{id}", get(get_objective)) .route("/api/runs", get(list_runs)) - .route("/api/runners", get(list_runners)) + .route("/api/hosts", get(list_hosts)) + .route("/api/workers", get(list_workers)) + .route("/api/hosts/{host_id}/workers", get(list_host_workers)) .fallback(get(static_or_spa_fallback)) .with_state(api) } @@ -124,7 +137,7 @@ pub struct WorkspaceResponse { pub struct ExtensionPoints { pub store: String, pub event_stream: ExtensionPointState, - pub runner_connection: ExtensionPointState, + pub host_worker_bridge: ExtensionPointState, } #[derive(Debug, Serialize, Deserialize)] @@ -148,6 +161,7 @@ pub struct RuntimeListResponse { pub limit: usize, pub items: Vec, pub source: String, + pub diagnostics: Vec, } async fn get_workspace(State(api): State) -> ApiResult> { @@ -177,9 +191,9 @@ async fn get_workspace(State(api): State) -> ApiResult, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); - let items = api.store.list_runners(api.workspace_id(), limit).await?; + let bridge = api.local_runtime_bridge(); + let (items, diagnostics) = bridge.list_hosts(limit); Ok(Json(RuntimeListResponse { workspace_id: api.config.workspace_id, limit, items, - source: "sqlite_runtime_tables".to_string(), + source: "local_pod_metadata".to_string(), + diagnostics, })) } +async fn list_workers( + State(api): State, +) -> ApiResult>> { + workers_response(api).map(Json) +} + +async fn list_host_workers( + State(api): State, + AxumPath(host_id): AxumPath, +) -> ApiResult>> { + let bridge = api.local_runtime_bridge(); + if host_id != bridge.host_id() { + return Err(Error::UnknownHost(host_id).into()); + } + workers_response(api).map(Json) +} + +fn workers_response(api: WorkspaceApi) -> ApiResult> { + let limit = api.config.max_records.min(200); + let bridge = api.local_runtime_bridge(); + let (items, diagnostics) = bridge.list_workers(limit); + Ok(RuntimeListResponse { + workspace_id: api.config.workspace_id, + limit, + items, + source: "local_pod_metadata".to_string(), + diagnostics, + }) +} + async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -360,7 +407,9 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.0 { - Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) => StatusCode::NOT_FOUND, + Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) => { + StatusCode::NOT_FOUND + } Error::Ticket(_) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -401,6 +450,7 @@ mod tests { let mut config = ServerConfig::local_dev(dir.path()); config.workspace_id = "local:test".to_string(); config.static_assets_dir = Some(static_dir); + config.local_runtime_data_dir = Some(dir.path().join("data")); let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); let app = build_router(api); @@ -408,8 +458,8 @@ mod tests { assert_eq!(workspace["workspace_id"], "local:test"); assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!( - workspace["extension_points"]["runner_connection"]["status"], - "reserved" + workspace["extension_points"]["host_worker_bridge"]["status"], + "read_only_local" ); let tickets = get_json(app.clone(), "/api/tickets").await; @@ -419,8 +469,35 @@ mod tests { let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"][0]["id"], "00000000001J3"); - let runners = get_json(app.clone(), "/api/runners").await; - assert!(runners["items"].as_array().unwrap().is_empty()); + let hosts = get_json(app.clone(), "/api/hosts").await; + assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); + assert_eq!(hosts["items"][0]["kind"], "local_host"); + assert_eq!( + hosts["items"][0]["capabilities"]["local_pod_inspection"], + "unavailable" + ); + + let workers = get_json(app.clone(), "/api/workers").await; + assert!(workers["items"].as_array().unwrap().is_empty()); + assert_eq!( + workers["diagnostics"][0]["code"], + "local_pod_metadata_root_missing" + ); + + let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; + assert!(host_workers["items"].as_array().unwrap().is_empty()); + + let runners_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/runners") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(runners_response.status(), StatusCode::NOT_FOUND); let static_response = app .clone() diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3b4a6334..7c0c4ee6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -50,14 +50,6 @@ CREATE TABLE IF NOT EXISTS objective_projections ( PRIMARY KEY (workspace_id, objective_id) ); -CREATE TABLE IF NOT EXISTS runners ( - runner_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - label TEXT NOT NULL, - status TEXT NOT NULL, - last_seen_at TEXT -); - CREATE TABLE IF NOT EXISTS runs ( run_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, @@ -106,22 +98,12 @@ pub struct RunSummary { pub updated_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RunnerSummary { - pub runner_id: String, - pub workspace_id: String, - pub label: String, - pub status: String, - pub last_seen_at: Option, -} - #[async_trait] pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result>; - async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result>; } #[derive(Clone)] @@ -229,27 +211,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { rows.collect::>>().map_err(Error::from) }) } - - async fn list_runners(&self, workspace_id: &str, limit: usize) -> Result> { - self.with_conn(|conn| { - let limit = limit.min(200) as i64; - let mut stmt = conn.prepare( - r#"SELECT runner_id, workspace_id, label, status, last_seen_at - FROM runners WHERE workspace_id = ?1 ORDER BY runner_id ASC LIMIT ?2"#, - )?; - let rows = stmt.query_map(params![workspace_id, limit], |row| { - Ok(RunnerSummary { - runner_id: row.get(0)?, - workspace_id: row.get(1)?, - label: row.get(2)?, - status: row.get(3)?, - last_seen_at: row.get(4)?, - }) - })?; - rows.collect::>>() - .map_err(Error::from) - }) - } } fn configure_sqlite(conn: &Connection) -> Result<()> { @@ -330,12 +291,5 @@ mod tests { .unwrap() .is_empty() ); - assert!( - reopened - .list_runners("local-dev", 20) - .await - .unwrap() - .is_empty() - ); } } diff --git a/package.nix b/package.nix index a0d9e420..82da2056 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-cZxkmM42kbDp1Rv9gn4sCD5WIQLc0wCbjj4GbKjuA9Q="; + cargoHash = "sha256-dKkAFUfTAMxSRHq9iNmwRXjQVSBHQBtb0+v8VHkgAGM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index d905974c..9b1c1a4a 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -5,33 +5,98 @@ record_authority: string; extension_points: { event_stream: { status: string; note: string }; - runner_connection: { status: string; note: string }; + host_worker_bridge: { status: string; note: string }; }; }; + type Diagnostic = { + code: string; + severity: string; + message: string; + }; + + type Host = { + host_id: string; + label: string; + kind: string; + status: string; + observed_at: string; + last_seen_at: string; + capabilities: { + local_pod_inspection: string; + workspace_root: string; + os: string; + arch: string; + max_workers: number; + }; + diagnostics: Diagnostic[]; + }; + + type Worker = { + worker_id: string; + host_id: string; + label: string; + pod_name: string; + role?: string; + profile?: string; + workspace_root?: string; + state: string; + status: string; + last_seen_at?: string; + implementation: { kind: string; pod_name: string }; + diagnostics: Diagnostic[]; + }; + + type ListResponse = { + workspace_id: string; + limit: number; + items: T[]; + source: string; + diagnostics: Diagnostic[]; + }; + const endpoints = [ { label: 'Workspace', path: '/api/workspace' }, { label: 'Tickets', path: '/api/tickets' }, { label: 'Objectives', path: '/api/objectives' }, { label: 'Runs', path: '/api/runs' }, - { label: 'Runners', path: '/api/runners' } + { label: 'Hosts', path: '/api/hosts' }, + { label: 'Workers', path: '/api/workers' } ]; let workspace = $state(null); + let hosts = $state | null>(null); + let workers = $state | null>(null); let loadError = $state(null); + async function getJson(path: string): Promise { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`GET ${path} failed: ${response.status}`); + } + return response.json() as Promise; + } + async function loadWorkspace() { try { - const response = await fetch('/api/workspace'); - if (!response.ok) { - throw new Error(`GET /api/workspace failed: ${response.status}`); - } - workspace = await response.json(); + const [workspaceResponse, hostResponse, workerResponse] = await Promise.all([ + getJson('/api/workspace'), + getJson>('/api/hosts'), + getJson>('/api/workers') + ]); + workspace = workspaceResponse; + hosts = hostResponse; + workers = workerResponse; + loadError = null; } catch (error) { loadError = error instanceof Error ? error.message : String(error); } } + function diagnosticsFor(...groups: Array): Diagnostic[] { + return groups.flatMap((group) => group ?? []); + } + $effect(() => { void loadWorkspace(); }); @@ -51,8 +116,9 @@

Yoi Workspace Control Plane

Static SPA shell for reading canonical .yoi project records - through bounded backend APIs. Ticket and Objective lifecycle authority stays - in the existing local record workflow. + and the local Host / Worker execution view through bounded backend APIs. + Ticket and Objective lifecycle authority stays in the existing local record + workflow.

@@ -72,6 +138,10 @@
Record authority
{workspace.record_authority}
+
+
Host / Worker bridge
+
{workspace.extension_points.host_worker_bridge.status}
+
{:else if loadError}

{loadError}

@@ -93,12 +163,118 @@

Reserved seams

- Event streams and runner connections are represented as extension-point - state in the backend response, but no scheduler, write API, or hosted - multi-tenant behavior is implemented in this slice. + Event streams remain represented as extension-point state in the backend + response. Hosts and Workers are read-only local observations; no + scheduler, lifecycle control, or hosted multi-tenant behavior is + implemented in this slice.

+ +
+
+

Hosts

+ {#if hosts} + {#if hosts.items.length === 0} +

No local Hosts are visible.

+ {:else} +
+ {#each hosts.items as host} +
+
+ {host.label} + {host.status} +
+
+
+
ID
+
{host.host_id}
+
+
+
Kind
+
{host.kind}
+
+
+
Local Pod inspection
+
{host.capabilities.local_pod_inspection}
+
+
+
Platform
+
{host.capabilities.os} / {host.capabilities.arch}
+
+
+
+ {/each} +
+ {/if} + {:else if loadError} +

{loadError}

+ {:else} +

Waiting for /api/hosts

+ {/if} +
+ +
+

Workers

+ {#if workers} + {#if workers.items.length === 0} +

No local Workers are visible.

+ {:else} +
+ + + + + + + + + + + + {#each workers.items as worker} + + + + + + + + {/each} + +
WorkerHostStateWorkspaceImplementation
+ {worker.label} + {#if worker.role || worker.profile} + {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} + {/if} + {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}: {worker.implementation.pod_name}
+
+ {/if} + {:else if loadError} +

{loadError}

+ {:else} +

Waiting for /api/workers

+ {/if} +
+
+ + {#if hosts || workers} + {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} + {#if diagnostics.length > 0} +
+

Diagnostics

+
    + {#each diagnostics as diagnostic} +
  • + {diagnostic.severity} + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+
+ {/if} + {/if}