feat: add worker runtime registry overview

This commit is contained in:
Keisuke Hirata 2026-06-24 19:56:08 +09:00
parent 371fd7c6e5
commit 9bd1550715
No known key found for this signature in database
6 changed files with 921 additions and 554 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,15 @@ pub enum Error {
MissingFrontmatter(String),
#[error("unknown local host `{0}`")]
UnknownHost(String),
#[error("unknown local worker `{0}`")]
UnknownWorker(String),
#[error("invalid runtime {kind} `{value}`")]
InvalidRuntimeIdentifier { kind: String, value: String },
#[error("runtime `{runtime_id}` does not support `{capability}`")]
RuntimeCapabilityUnsupported {
runtime_id: String,
capability: String,
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("workspace identity error: {0}")]

View File

@ -3,7 +3,7 @@ use std::process::{Command, Output};
use serde::{Deserialize, Serialize};
use crate::hosts::RuntimeDiagnostic;
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
@ -340,7 +340,11 @@ fn truncate_field(value: &str, limit: usize) -> String {
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.to_string(),
severity: severity.to_string(),
severity: match severity {
"error" => DiagnosticSeverity::Error,
"warning" => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
},
message,
}
}

View File

@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use crate::hosts::{
HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary, WorkspaceWorkerRuntime,
DiagnosticSeverity, HostSummary, LocalPodRuntime, RuntimeDiagnostic, RuntimeSummary,
WorkerRuntimeRegistry, WorkerSummary,
};
use crate::identity::WorkspaceIdentity;
use crate::records::{
@ -63,7 +64,7 @@ pub struct WorkspaceApi {
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
records: LocalProjectRecordReader,
runtime: Arc<dyn WorkspaceWorkerRuntime>,
runtime: Arc<WorkerRuntimeRegistry>,
}
impl WorkspaceApi {
@ -77,11 +78,11 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(),
})
.await?;
let runtime = Arc::new(LocalRuntimeBridge::new(
let runtime = Arc::new(WorkerRuntimeRegistry::for_local_pods(LocalPodRuntime::new(
config.workspace_id.clone(),
config.workspace_root.clone(),
config.local_runtime_data_dir.clone(),
));
)));
Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
config,
@ -125,6 +126,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
get(repository_tickets),
)
.route("/api/hosts", get(list_hosts))
.route("/api/runtimes", get(list_runtimes))
.route("/api/workers", get(list_workers))
.route("/api/hosts/{host_id}/workers", get(list_host_workers))
.fallback(get(static_or_spa_fallback))
@ -381,7 +383,7 @@ async fn repository_tickets(
source: "workspace_local_ticket_fallback".to_string(),
diagnostics: vec![RuntimeDiagnostic {
code: "repository_ticket_target_metadata_absent".to_string(),
severity: "info".to_string(),
severity: DiagnosticSeverity::Info,
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
}],
}))
@ -396,11 +398,25 @@ async fn list_hosts(
workspace_id: api.config.workspace_id,
limit,
items: runtime_hosts.items,
source: "worker_runtime".to_string(),
source: "worker_runtime_registry".to_string(),
diagnostics: runtime_hosts.diagnostics,
}))
}
async fn list_runtimes(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
let limit = api.config.max_records.min(200);
let runtimes = api.runtime.list_runtimes(limit);
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items: runtimes.items,
source: "worker_runtime_registry".to_string(),
diagnostics: runtimes.diagnostics,
}))
}
async fn list_workers(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
@ -411,16 +427,18 @@ async fn list_host_workers(
State(api): State<WorkspaceApi>,
AxumPath(host_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
let runtime_hosts = api.runtime.list_hosts(1);
let expected_host_id = runtime_hosts
.items
.first()
.map(|host| host.host_id.as_str())
.ok_or_else(|| Error::UnknownHost(host_id.clone()))?;
if host_id != expected_host_id {
return Err(Error::UnknownHost(host_id).into());
}
workers_response(api).map(Json)
let limit = api.config.max_records.min(200);
let runtime_workers = api
.runtime
.list_workers_for_host(&host_id, limit)
.map_err(|err| err.into_error())?;
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items: runtime_workers.items,
source: "worker_runtime_registry".to_string(),
diagnostics: runtime_workers.diagnostics,
}))
}
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
@ -430,7 +448,7 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
workspace_id: api.config.workspace_id,
limit,
items: runtime_workers.items,
source: "worker_runtime".to_string(),
source: "worker_runtime_registry".to_string(),
diagnostics: runtime_workers.diagnostics,
})
}
@ -589,11 +607,14 @@ impl From<Error> for ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self.0 {
Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST,
Error::InvalidRecordId(_)
| Error::MissingFrontmatter(_)
| Error::UnknownHost(_)
| Error::UnknownWorker(_)
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND,
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
@ -703,18 +724,30 @@ mod tests {
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
let hosts = get_json(app.clone(), "/api/hosts").await;
assert_eq!(hosts["source"], "worker_runtime_registry");
assert_eq!(hosts["items"][0]["runtime_id"], "local-pod-runtime");
assert_eq!(hosts["items"][0]["host_id"], TEST_REPOSITORY_ID);
assert_eq!(hosts["items"][0]["kind"], "local_host");
assert_eq!(hosts["items"][0]["kind"], "local-pod-host");
assert_eq!(
hosts["items"][0]["capabilities"]["local_pod_inspection"],
"unavailable"
"available"
);
assert_eq!(
hosts["items"][0]["capabilities"]["workspace_scope"],
"current_workspace"
);
assert!(!hosts.to_string().contains("metadata.json"));
let runtimes = get_json(app.clone(), "/api/runtimes").await;
assert_eq!(runtimes["source"], "worker_runtime_registry");
assert_eq!(runtimes["items"][0]["runtime_id"], "local-pod-runtime");
assert_eq!(runtimes["items"][0]["host_ids"][0], TEST_REPOSITORY_ID);
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"
"local_pod_registry_unreadable"
);
let host_workers = get_json(

View File

@ -546,6 +546,14 @@
<dt>Local inspection</dt>
<dd>{host.capabilities.local_pod_inspection}</dd>
</div>
<div>
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Scope</dt>
<dd>{host.capabilities.workspace_scope}</dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
@ -590,7 +598,7 @@
</td>
<td><code>{worker.host_id}</code></td>
<td>{worker.state} · {worker.status}</td>
<td>{worker.workspace_root ?? 'unknown'}</td>
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
<td>{worker.implementation.kind}</td>
</tr>
{/each}

View File

@ -25,35 +25,70 @@ export type Diagnostic = {
message: string;
};
export type RuntimeCapabilities = {
can_list_hosts: boolean;
can_list_workers: boolean;
can_get_worker: boolean;
can_spawn_worker: boolean;
can_stop_worker: boolean;
can_accept_input: boolean;
can_stream_events: boolean;
can_read_bounded_transcript: boolean;
has_workspace_fs: boolean;
has_shell: boolean;
has_git: boolean;
supports_worktrees: boolean;
supports_backend_internal_tools: boolean;
local_pod_inspection: string;
workspace_scope: string;
os: string;
arch: string;
max_workers: number;
};
export type Runtime = {
runtime_id: string;
label: string;
kind: string;
status: string;
host_ids: string[];
capabilities: RuntimeCapabilities;
diagnostics: Diagnostic[];
};
export type Host = {
runtime_id: string;
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;
};
last_seen_at: string | null;
capabilities: RuntimeCapabilities;
diagnostics: Diagnostic[];
};
export type WorkerCapabilities = {
can_accept_input: boolean;
can_stream_events: boolean;
can_stop: boolean;
can_spawn_followup: boolean;
can_read_bounded_transcript: boolean;
};
export type Worker = {
runtime_id: string;
worker_id: string;
host_id: string;
label: string;
pod_name: string;
role?: string;
profile?: string;
workspace_root?: string;
role?: string | null;
profile?: string | null;
workspace: { visibility: string; identity: string };
state: string;
status: string;
last_seen_at?: string;
implementation: { kind: string; pod_name: string };
last_seen_at?: string | null;
implementation: { kind: string; display_hint: string };
capabilities: WorkerCapabilities;
diagnostics: Diagnostic[];
};