feat: integrate workspace switching
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{
|
||||
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
|
||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
||||
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
|
||||
list_backend_workers, restore_backend_worker,
|
||||
};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
@@ -14,77 +14,94 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
|
||||
pub(crate) async fn run(
|
||||
target: BackendRuntimeListTarget,
|
||||
mut target: BackendRuntimeListTarget,
|
||||
include_stopped: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to list Backend runtime workers from {}: {error}",
|
||||
target.base_url
|
||||
))
|
||||
})?;
|
||||
if include_stopped {
|
||||
match list_backend_stopped_workers(&target).await {
|
||||
Ok(stopped) => {
|
||||
response.items.extend(stopped.items);
|
||||
response.diagnostics.extend(stopped.diagnostics);
|
||||
loop {
|
||||
if target.workspace_id().is_none() {
|
||||
let workspace_id = select_backend_workspace(&target.base_url)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(error.to_string()))?
|
||||
.ok_or_else(|| io::Error::other("Backend workspace picker cancelled"))?;
|
||||
target.select_workspace(workspace_id);
|
||||
}
|
||||
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to list Backend runtime workers from {}: {error}",
|
||||
target.base_url
|
||||
))
|
||||
})?;
|
||||
if include_stopped {
|
||||
match list_backend_stopped_workers(&target).await {
|
||||
Ok(stopped) => {
|
||||
response.items.extend(stopped.items);
|
||||
response.diagnostics.extend(stopped.diagnostics);
|
||||
}
|
||||
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
||||
code: "backend_stopped_workers_list_failed".to_string(),
|
||||
severity: Some("error".to_string()),
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
||||
code: "backend_stopped_workers_list_failed".to_string(),
|
||||
severity: Some("error".to_string()),
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
dedup_workers(&mut response.items);
|
||||
if response.items.is_empty() {
|
||||
let diagnostics = response
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let detail = if diagnostics.is_empty() {
|
||||
"no backend diagnostics".to_string()
|
||||
} else {
|
||||
diagnostics
|
||||
};
|
||||
eprintln!(
|
||||
"Backend returned no runtime workers for workspace {} ({detail}); choose another Workspace",
|
||||
response.workspace_id
|
||||
);
|
||||
target.clear_workspace();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
dedup_workers(&mut response.items);
|
||||
if response.items.is_empty() {
|
||||
let diagnostics = response
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let detail = if diagnostics.is_empty() {
|
||||
"no backend diagnostics".to_string()
|
||||
} else {
|
||||
diagnostics
|
||||
};
|
||||
return Err(Box::new(io::Error::other(format!(
|
||||
"Backend returned no runtime workers for workspace {} ({detail})",
|
||||
response.workspace_id
|
||||
))));
|
||||
}
|
||||
|
||||
let selected = pick_worker(target.clone(), response.items)?;
|
||||
let worker = if selected.state == "stopped" {
|
||||
let restore_target = BackendRuntimeTarget::new(
|
||||
target.base_url.clone(),
|
||||
selected.runtime_id.clone(),
|
||||
selected.worker_id.clone(),
|
||||
);
|
||||
restore_backend_worker(&restore_target)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?
|
||||
.result
|
||||
.worker
|
||||
.unwrap_or(selected)
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
let attach_target =
|
||||
BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
|
||||
console::run_backend_runtime(attach_target).await
|
||||
let selected = match pick_worker(target.clone(), response.items)? {
|
||||
WorkerPickerResult::SwitchWorkspace => {
|
||||
target.clear_workspace();
|
||||
continue;
|
||||
}
|
||||
WorkerPickerResult::Selected(selected) => selected,
|
||||
};
|
||||
let worker = if selected.state == "stopped" {
|
||||
let restore_target = target
|
||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
restore_backend_worker(&restore_target)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?
|
||||
.result
|
||||
.worker
|
||||
.unwrap_or(selected)
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
let attach_target = target
|
||||
.runtime_target(worker.runtime_id, worker.worker_id)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
return console::run_backend_runtime(attach_target).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
||||
@@ -92,10 +109,15 @@ fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
||||
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
||||
}
|
||||
|
||||
enum WorkerPickerResult {
|
||||
Selected(BackendWorkerSummary),
|
||||
SwitchWorkspace,
|
||||
}
|
||||
|
||||
fn pick_worker(
|
||||
target: BackendRuntimeListTarget,
|
||||
mut workers: Vec<BackendWorkerSummary>,
|
||||
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
|
||||
) -> Result<WorkerPickerResult, Box<dyn Error>> {
|
||||
workers.sort_by(|a, b| {
|
||||
a.runtime_id
|
||||
.cmp(&b.runtime_id)
|
||||
@@ -114,7 +136,13 @@ fn pick_worker(
|
||||
Some(Action::Down) => state.next(),
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(state.selected_worker().clone());
|
||||
return Ok(WorkerPickerResult::Selected(
|
||||
state.selected_worker().clone(),
|
||||
));
|
||||
}
|
||||
Some(Action::SwitchWorkspace) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
@@ -181,6 +209,7 @@ enum Action {
|
||||
Up,
|
||||
Down,
|
||||
Submit,
|
||||
SwitchWorkspace,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
@@ -197,6 +226,7 @@ fn poll_event() -> io::Result<Option<Action>> {
|
||||
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||
KeyCode::Enter => Some(Action::Submit),
|
||||
KeyCode::Char('w') if !ctrl => Some(Action::SwitchWorkspace),
|
||||
KeyCode::Esc => Some(Action::Cancel),
|
||||
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||
_ => None,
|
||||
@@ -239,6 +269,8 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
||||
Span::raw(" select "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" attach "),
|
||||
Span::styled("[w]", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" switch Workspace "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])),
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use client::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository,
|
||||
CreateBackendWorkspaceRequest, create_backend_workspace, list_backend_workspaces,
|
||||
};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use std::error::Error;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
||||
|
||||
pub(crate) async fn select_backend_workspace(base_url: &str) -> PickerResult<Option<String>> {
|
||||
let target = BackendWorkspaceCatalogTarget::new(base_url);
|
||||
let mut workspaces = Vec::new();
|
||||
|
||||
'catalog: loop {
|
||||
let error = match list_backend_workspaces(&target).await {
|
||||
Ok(items) => {
|
||||
workspaces = items;
|
||||
None
|
||||
}
|
||||
Err(fetch_error) => Some(format!("failed to refresh workspaces: {fetch_error}")),
|
||||
};
|
||||
|
||||
match pick_workspace(&workspaces, error.as_deref())? {
|
||||
WorkspacePickerAction::Select(index) => {
|
||||
return Ok(workspaces.get(index).map(|item| item.workspace_id.clone()));
|
||||
}
|
||||
WorkspacePickerAction::Refresh => continue,
|
||||
WorkspacePickerAction::Create => {
|
||||
let Some(request) = prompt_create_request()? else {
|
||||
continue;
|
||||
};
|
||||
loop {
|
||||
match create_backend_workspace(&target, &request).await {
|
||||
Ok(response) => return Ok(Some(response.workspace.workspace_id)),
|
||||
Err(create_error) => {
|
||||
let creation_error =
|
||||
format!("workspace creation failed: {create_error}");
|
||||
match pick_workspace(&workspaces, Some(&creation_error))? {
|
||||
WorkspacePickerAction::Select(index) => {
|
||||
return Ok(workspaces
|
||||
.get(index)
|
||||
.map(|item| item.workspace_id.clone()));
|
||||
}
|
||||
// Retry the exact request and operation key.
|
||||
WorkspacePickerAction::Create => continue,
|
||||
WorkspacePickerAction::Refresh => continue 'catalog,
|
||||
WorkspacePickerAction::Cancel => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkspacePickerAction::Cancel => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorkspacePickerAction {
|
||||
Select(usize),
|
||||
Create,
|
||||
Refresh,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn pick_workspace(
|
||||
workspaces: &[BackendWorkspace],
|
||||
error: Option<&str>,
|
||||
) -> PickerResult<WorkspacePickerAction> {
|
||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||
return Err(
|
||||
"Backend target has no configured workspace; an interactive terminal is required to choose one"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let mut selected = 0usize;
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(if error.is_some() { 3 } else { 1 }),
|
||||
])
|
||||
.split(frame.area());
|
||||
frame.render_widget(
|
||||
Paragraph::new("Choose the Workspace for this Backend session")
|
||||
.block(Block::default().title("Workspace").borders(Borders::ALL)),
|
||||
chunks[0],
|
||||
);
|
||||
let rows = workspaces
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
workspace.display_name.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {} {}", workspace.workspace_id, workspace.state)),
|
||||
]))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let rows = if rows.is_empty() {
|
||||
vec![ListItem::new("No accessible Workspaces")]
|
||||
} else {
|
||||
rows
|
||||
};
|
||||
let mut state = ListState::default();
|
||||
if !workspaces.is_empty() {
|
||||
state.select(Some(selected));
|
||||
}
|
||||
frame.render_stateful_widget(
|
||||
List::new(rows)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.highlight_symbol("▶ "),
|
||||
chunks[1],
|
||||
&mut state,
|
||||
);
|
||||
let footer = error
|
||||
.map(|message| {
|
||||
format!(
|
||||
"{message} [n] create/retry [r] refresh [Enter] select [Esc] cancel"
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"[Enter] select [n] new [r] refresh [Esc] cancel".to_string()
|
||||
});
|
||||
frame.render_widget(Paragraph::new(footer), chunks[2]);
|
||||
})?;
|
||||
|
||||
if let Event::Key(key) = event::read()?
|
||||
&& key.kind == KeyEventKind::Press
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Up if !workspaces.is_empty() => {
|
||||
selected = selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down if !workspaces.is_empty() => {
|
||||
selected = (selected + 1).min(workspaces.len() - 1);
|
||||
}
|
||||
KeyCode::Enter if !workspaces.is_empty() => {
|
||||
terminal.clear()?;
|
||||
return Ok(WorkspacePickerAction::Select(selected));
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
terminal.clear()?;
|
||||
return Ok(WorkspacePickerAction::Create);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
terminal.clear()?;
|
||||
return Ok(WorkspacePickerAction::Refresh);
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
terminal.clear()?;
|
||||
return Ok(WorkspacePickerAction::Cancel);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||
disable_raw_mode()?;
|
||||
let result = prompt_create_request_inner();
|
||||
enable_raw_mode()?;
|
||||
result
|
||||
}
|
||||
|
||||
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||
println!("Create Workspace (leave display name empty to cancel)");
|
||||
let display_name = prompt_line("Workspace display name: ")?;
|
||||
if display_name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let uri = prompt_line("Initial repository absolute path/URI: ")?;
|
||||
if uri.is_empty() {
|
||||
println!("Repository path/URI is required.");
|
||||
return Ok(None);
|
||||
}
|
||||
let repository_name = prompt_line("Repository display name [Main]: ")?;
|
||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||
let operation_key = format!(
|
||||
"tui-workspace-create-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
Ok(Some(CreateBackendWorkspaceRequest {
|
||||
operation_key,
|
||||
display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
display_name: Some(if repository_name.is_empty() {
|
||||
"Main".to_string()
|
||||
} else {
|
||||
repository_name
|
||||
}),
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn prompt_line(prompt: &str) -> PickerResult<String> {
|
||||
print!("{prompt}");
|
||||
io::stdout().flush()?;
|
||||
let mut value = String::new();
|
||||
io::stdin().read_line(&mut value)?;
|
||||
Ok(value.trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
||||
assert_ne!(
|
||||
WorkspacePickerAction::Create,
|
||||
WorkspacePickerAction::Refresh
|
||||
);
|
||||
assert_ne!(
|
||||
WorkspacePickerAction::Select(0),
|
||||
WorkspacePickerAction::Cancel
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app;
|
||||
mod backend_worker_picker;
|
||||
mod backend_workspace_picker;
|
||||
mod block;
|
||||
mod cache;
|
||||
mod command;
|
||||
|
||||
Reference in New Issue
Block a user