feat: add scoped TUI workspace selection

This commit is contained in:
2026-08-20 18:34:18 +09:00
parent 4b8dc302ee
commit 17abe1c40c
8 changed files with 682 additions and 111 deletions
+112 -47
View File
@@ -14,6 +14,8 @@ pub struct BackendRuntimeTarget {
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
pub base_url: String,
/// Workspace identity used for every Worker lifecycle and protocol operation.
pub workspace_id: String,
/// Backend-owned Runtime identity used as path authority.
pub runtime_id: String,
/// Backend-owned Worker identity used as path authority.
@@ -23,11 +25,13 @@ pub struct BackendRuntimeTarget {
impl BackendRuntimeTarget {
pub fn new(
base_url: impl Into<String>,
workspace_id: impl Into<String>,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Self {
Self {
base_url: base_url.into(),
workspace_id: workspace_id.into(),
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
}
@@ -57,6 +61,36 @@ impl BackendRuntimeListTarget {
runtime_id,
}
}
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
self.workspace_id = Some(workspace_id.into());
}
pub fn clear_workspace(&mut self) {
self.workspace_id = None;
}
pub fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
pub fn runtime_target(
&self,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
BackendRuntimeClientError::InvalidTarget(
"workspace_id is required before selecting a Backend worker".to_string(),
)
})?;
Ok(BackendRuntimeTarget::new(
self.base_url.clone(),
workspace_id,
runtime_id,
worker_id,
))
}
}
#[derive(Debug, Clone, Deserialize)]
@@ -186,7 +220,13 @@ pub async fn list_backend_workers(
validate_list_target(target)?;
let http = reqwest::Client::new();
if let Some(runtime_id) = target.runtime_id.as_deref() {
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
let path = backend_runtime_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
runtime_id,
);
let url = join_base_and_path(&target.base_url, &path);
return Ok(http
.get(url)
@@ -197,7 +237,12 @@ pub async fn list_backend_workers(
.await?);
}
let runtime_path = backend_runtimes_path(target.workspace_id.as_deref());
let runtime_path = backend_runtimes_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
let runtimes = http
.get(runtime_url)
@@ -210,8 +255,13 @@ pub async fn list_backend_workers(
let mut items = Vec::new();
let mut diagnostics = runtimes.diagnostics;
for runtime in runtimes.items {
let path =
backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id);
let path = backend_runtime_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
&runtime.runtime_id,
);
let url = join_base_and_path(&target.base_url, &path);
match http
.get(url)
@@ -256,7 +306,13 @@ pub async fn list_backend_stopped_workers(
));
};
let http = reqwest::Client::new();
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
let path = backend_runtime_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
runtime_id,
);
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
Ok(http
.get(url)
@@ -272,7 +328,11 @@ pub async fn restore_backend_worker(
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
validate_target(target)?;
let http = reqwest::Client::new();
let path = backend_runtime_worker_restore_path(None, &target.runtime_id, &target.worker_id);
let path = backend_runtime_worker_restore_path(
&target.workspace_id,
&target.runtime_id,
&target.worker_id,
);
let url = join_base_and_path(&target.base_url, &path);
Ok(http
.post(url)
@@ -440,6 +500,11 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
"Backend API base URL must start with http:// or https://".to_string(),
));
}
if target.workspace_id.is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id is required".to_string(),
));
}
if target.runtime_id.is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"runtime_id is required".to_string(),
@@ -466,10 +531,18 @@ fn validate_list_target(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
if target.workspace_id.as_deref().is_some_and(str::is_empty) {
return Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty when provided".to_string(),
));
match target.workspace_id.as_deref() {
Some("") => {
return Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty".to_string(),
));
}
None => {
return Err(BackendRuntimeClientError::InvalidTarget(
"workspace selection is required before listing Backend workers".to_string(),
));
}
Some(_) => {}
}
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
return Err(BackendRuntimeClientError::InvalidTarget(
@@ -479,47 +552,35 @@ fn validate_list_target(
Ok(())
}
fn backend_runtimes_path(workspace_id: Option<&str>) -> String {
match workspace_id {
Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)),
None => "/api/runtimes".to_string(),
}
fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
}
fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String {
match workspace_id {
Some(workspace_id) => format!(
"/api/w/{}/runtimes/{}/workers",
path_segment_encode(workspace_id),
path_segment_encode(runtime_id)
),
None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)),
}
fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String {
format!(
"/api/w/{}/runtimes/{}/workers",
path_segment_encode(workspace_id),
path_segment_encode(runtime_id)
)
}
fn backend_runtime_worker_restore_path(
workspace_id: Option<&str>,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
) -> String {
match workspace_id {
Some(workspace_id) => format!(
"/api/w/{}/runtimes/{}/workers/{}/restore",
path_segment_encode(workspace_id),
path_segment_encode(runtime_id),
path_segment_encode(worker_id)
),
None => format!(
"/api/runtimes/{}/workers/{}/restore",
path_segment_encode(runtime_id),
path_segment_encode(worker_id)
),
}
format!(
"/api/w/{}/runtimes/{}/workers/{}/restore",
path_segment_encode(workspace_id),
path_segment_encode(runtime_id),
path_segment_encode(worker_id)
)
}
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
let path = format!(
"/api/runtimes/{}/workers/{}/protocol/ws",
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
path_segment_encode(&target.workspace_id),
path_segment_encode(&target.runtime_id),
path_segment_encode(&target.worker_id)
);
@@ -573,11 +634,15 @@ mod tests {
#[test]
fn protocol_url_uses_backend_runtime_worker_identity() {
let target =
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
let target = BackendRuntimeTarget::new(
"http://127.0.0.1:8787/",
"workspace alpha",
"runtime/one",
"worker one",
);
assert_eq!(
protocol_ws_url(&target),
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
"ws://127.0.0.1:8787/api/w/workspace%20alpha/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
);
}
@@ -622,8 +687,8 @@ mod tests {
}
#[test]
fn workers_path_can_be_workspace_scoped_for_status_queries() {
let path = backend_runtime_workers_path(Some("team main"), "runtime/one");
fn workers_path_requires_workspace_scope_for_status_queries() {
let path = backend_runtime_workers_path("team main", "runtime/one");
assert_eq!(
format!("{path}?status=stopped"),
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
@@ -631,10 +696,10 @@ mod tests {
}
#[test]
fn restore_worker_path_uses_backend_runtime_worker_identity() {
fn restore_worker_path_requires_workspace_scope() {
assert_eq!(
backend_runtime_worker_restore_path(None, "runtime/one", "worker one"),
"/api/runtimes/runtime%2Fone/workers/worker%20one/restore"
backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"),
"/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore"
);
}
}
+161
View File
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use std::fmt;
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkspace {
pub workspace_id: String,
pub owner_account_id: Option<String>,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceResponse {
pub workspace: BackendWorkspace,
pub repository: CreateBackendWorkspaceRepositoryRecord,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceRepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub uri: String,
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget {
pub base_url: String,
}
impl BackendWorkspaceCatalogTarget {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
}
#[derive(Debug)]
pub enum BackendWorkspaceClientError {
InvalidTarget(String),
RequestFailed { status: u16, message: String },
Http(reqwest::Error),
}
impl fmt::Display for BackendWorkspaceClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTarget(message) => f.write_str(message),
Self::RequestFailed { status, message } => {
write!(f, "Backend request failed with HTTP {status}: {message}")
}
Self::Http(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for BackendWorkspaceClientError {}
impl From<reqwest::Error> for BackendWorkspaceClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
}
}
pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
validate_target(target)?;
let url = format!(
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
target.base_url.trim_end_matches('/')
);
let response = reqwest::Client::new().get(url).send().await?;
let response = require_success(response).await?;
Ok(response.json::<Vec<BackendWorkspace>>().await?)
}
pub async fn create_backend_workspace(
target: &BackendWorkspaceCatalogTarget,
request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
validate_target(target)?;
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
let response = reqwest::Client::new()
.post(url)
.json(request)
.send()
.await?;
let response = require_success(response).await?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
}
async fn require_success(
response: reqwest::Response,
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn validate_target(
target: &BackendWorkspaceCatalogTarget,
) -> Result<(), BackendWorkspaceClientError> {
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_request_keeps_operation_key_for_exact_retry() {
let request = CreateBackendWorkspaceRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
let retry = request.clone();
assert_eq!(retry.operation_key, "workspace-create-1");
assert_eq!(retry, request);
}
}
+6
View File
@@ -10,6 +10,7 @@
pub mod backend_auth;
pub mod backend_runtime;
pub mod backend_workspace;
pub mod runtime_command;
pub mod spawn;
pub mod target;
@@ -28,6 +29,11 @@ pub use backend_runtime::{
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
list_backend_workers, restore_backend_worker,
};
pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
};
pub use runtime_command::WorkerRuntimeCommand;
pub use target::{
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
+30
View File
@@ -132,6 +132,12 @@ impl TargetError {
}
}
fn invalid(target: TargetKind, message: impl Into<String>) -> Self {
Self {
message: format!("invalid {target} target: {}", message.into()),
}
}
fn local_runtime_command(error: std::io::Error) -> Self {
Self {
message: format!("failed to resolve local Worker runtime command: {error}"),
@@ -260,9 +266,16 @@ impl Target for BackendTarget {
&self,
selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError> {
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
TargetError::invalid(
self.kind(),
"workspace selection is required before connecting to a Backend Worker",
)
})?;
Ok(WorkerConnection {
target: BackendRuntimeTarget::new(
self.base_url.clone(),
workspace_id,
selector.runtime_id,
selector.worker_id,
),
@@ -313,10 +326,27 @@ mod tests {
.unwrap();
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
assert_eq!(connection.target.workspace_id, "workspace-a");
assert_eq!(connection.target.runtime_id, "runtime-a");
assert_eq!(connection.target.worker_id, "worker-b");
}
#[test]
fn backend_target_rejects_worker_connection_before_workspace_selection() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
let error =
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
Ok(_) => panic!("unscoped connection must fail"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("workspace selection is required")
);
}
#[test]
fn backend_target_rejects_local_worker_operations() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
+36
View File
@@ -0,0 +1,36 @@
use client::{
BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target,
WorkerConnectionSelector,
};
#[test]
fn workspace_creation_request_preserves_operation_key_for_retry() {
let request = CreateBackendWorkspaceRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1");
}
#[test]
fn backend_worker_connection_requires_explicit_workspace_scope() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a"))
{
Ok(_) => panic!("unscoped Backend worker connection must fail"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("workspace selection is required")
);
}