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`.
|
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
||||||
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
||||||
pub base_url: String,
|
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.
|
/// Backend-owned Runtime identity used as path authority.
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
/// Backend-owned Worker identity used as path authority.
|
/// Backend-owned Worker identity used as path authority.
|
||||||
@@ -23,11 +25,13 @@ pub struct BackendRuntimeTarget {
|
|||||||
impl BackendRuntimeTarget {
|
impl BackendRuntimeTarget {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
runtime_id: impl Into<String>,
|
runtime_id: impl Into<String>,
|
||||||
worker_id: impl Into<String>,
|
worker_id: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
runtime_id: runtime_id.into(),
|
runtime_id: runtime_id.into(),
|
||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,36 @@ impl BackendRuntimeListTarget {
|
|||||||
runtime_id,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -186,7 +220,13 @@ pub async fn list_backend_workers(
|
|||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
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);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
return Ok(http
|
return Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -197,7 +237,12 @@ pub async fn list_backend_workers(
|
|||||||
.await?);
|
.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 runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||||
let runtimes = http
|
let runtimes = http
|
||||||
.get(runtime_url)
|
.get(runtime_url)
|
||||||
@@ -210,8 +255,13 @@ pub async fn list_backend_workers(
|
|||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut diagnostics = runtimes.diagnostics;
|
let mut diagnostics = runtimes.diagnostics;
|
||||||
for runtime in runtimes.items {
|
for runtime in runtimes.items {
|
||||||
let path =
|
let path = backend_runtime_workers_path(
|
||||||
backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id);
|
target
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated Backend Workspace scope"),
|
||||||
|
&runtime.runtime_id,
|
||||||
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
match http
|
match http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -256,7 +306,13 @@ pub async fn list_backend_stopped_workers(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
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"));
|
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||||
Ok(http
|
Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -272,7 +328,11 @@ pub async fn restore_backend_worker(
|
|||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
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);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
Ok(http
|
Ok(http
|
||||||
.post(url)
|
.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(),
|
"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() {
|
if target.runtime_id.is_empty() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
"runtime_id is required".to_string(),
|
"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(),
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if target.workspace_id.as_deref().is_some_and(str::is_empty) {
|
match target.workspace_id.as_deref() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
Some("") => {
|
||||||
"workspace_id must not be empty when provided".to_string(),
|
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) {
|
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
@@ -479,47 +552,35 @@ fn validate_list_target(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtimes_path(workspace_id: Option<&str>) -> String {
|
fn backend_runtimes_path(workspace_id: &str) -> String {
|
||||||
match workspace_id {
|
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
||||||
Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)),
|
|
||||||
None => "/api/runtimes".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String {
|
fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String {
|
||||||
match workspace_id {
|
format!(
|
||||||
Some(workspace_id) => format!(
|
"/api/w/{}/runtimes/{}/workers",
|
||||||
"/api/w/{}/runtimes/{}/workers",
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(runtime_id)
|
||||||
path_segment_encode(runtime_id)
|
)
|
||||||
),
|
|
||||||
None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_worker_restore_path(
|
fn backend_runtime_worker_restore_path(
|
||||||
workspace_id: Option<&str>,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
match workspace_id {
|
format!(
|
||||||
Some(workspace_id) => format!(
|
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(runtime_id),
|
||||||
path_segment_encode(runtime_id),
|
path_segment_encode(worker_id)
|
||||||
path_segment_encode(worker_id)
|
)
|
||||||
),
|
|
||||||
None => format!(
|
|
||||||
"/api/runtimes/{}/workers/{}/restore",
|
|
||||||
path_segment_encode(runtime_id),
|
|
||||||
path_segment_encode(worker_id)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
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.runtime_id),
|
||||||
path_segment_encode(&target.worker_id)
|
path_segment_encode(&target.worker_id)
|
||||||
);
|
);
|
||||||
@@ -573,11 +634,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||||
let target =
|
let target = BackendRuntimeTarget::new(
|
||||||
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
|
"http://127.0.0.1:8787/",
|
||||||
|
"workspace alpha",
|
||||||
|
"runtime/one",
|
||||||
|
"worker one",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
protocol_ws_url(&target),
|
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]
|
#[test]
|
||||||
fn workers_path_can_be_workspace_scoped_for_status_queries() {
|
fn workers_path_requires_workspace_scope_for_status_queries() {
|
||||||
let path = backend_runtime_workers_path(Some("team main"), "runtime/one");
|
let path = backend_runtime_workers_path("team main", "runtime/one");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{path}?status=stopped"),
|
format!("{path}?status=stopped"),
|
||||||
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
||||||
@@ -631,10 +696,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_worker_path_uses_backend_runtime_worker_identity() {
|
fn restore_worker_path_requires_workspace_scope() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
backend_runtime_worker_restore_path(None, "runtime/one", "worker one"),
|
backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"),
|
||||||
"/api/runtimes/runtime%2Fone/workers/worker%20one/restore"
|
"/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_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
|
pub mod backend_workspace;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
@@ -28,6 +29,11 @@ pub use backend_runtime::{
|
|||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
list_backend_workers, restore_backend_worker,
|
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 runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
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 {
|
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
message: format!("failed to resolve local Worker runtime command: {error}"),
|
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||||
@@ -260,9 +266,16 @@ impl Target for BackendTarget {
|
|||||||
&self,
|
&self,
|
||||||
selector: WorkerConnectionSelector,
|
selector: WorkerConnectionSelector,
|
||||||
) -> Result<WorkerConnection, TargetError> {
|
) -> 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 {
|
Ok(WorkerConnection {
|
||||||
target: BackendRuntimeTarget::new(
|
target: BackendRuntimeTarget::new(
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
|
workspace_id,
|
||||||
selector.runtime_id,
|
selector.runtime_id,
|
||||||
selector.worker_id,
|
selector.worker_id,
|
||||||
),
|
),
|
||||||
@@ -313,10 +326,27 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
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.runtime_id, "runtime-a");
|
||||||
assert_eq!(connection.target.worker_id, "worker-b");
|
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]
|
#[test]
|
||||||
fn backend_target_rejects_local_worker_operations() {
|
fn backend_target_rejects_local_worker_operations() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
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 std::time::Duration;
|
||||||
|
|
||||||
use client::{
|
use client::{
|
||||||
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
|
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
|
||||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
list_backend_workers, restore_backend_worker,
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
@@ -14,77 +14,94 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||||
|
|
||||||
|
use crate::backend_workspace_picker::select_backend_workspace;
|
||||||
use crate::console;
|
use crate::console;
|
||||||
|
|
||||||
const MAX_ROWS: usize = 10;
|
const MAX_ROWS: usize = 10;
|
||||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||||
|
|
||||||
pub(crate) async fn run(
|
pub(crate) async fn run(
|
||||||
target: BackendRuntimeListTarget,
|
mut target: BackendRuntimeListTarget,
|
||||||
include_stopped: bool,
|
include_stopped: bool,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
loop {
|
||||||
io::Error::other(format!(
|
if target.workspace_id().is_none() {
|
||||||
"failed to list Backend runtime workers from {}: {error}",
|
let workspace_id = select_backend_workspace(&target.base_url)
|
||||||
target.base_url
|
.await
|
||||||
))
|
.map_err(|error| io::Error::other(error.to_string()))?
|
||||||
})?;
|
.ok_or_else(|| io::Error::other("Backend workspace picker cancelled"))?;
|
||||||
if include_stopped {
|
target.select_workspace(workspace_id);
|
||||||
match list_backend_stopped_workers(&target).await {
|
}
|
||||||
Ok(stopped) => {
|
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
||||||
response.items.extend(stopped.items);
|
io::Error::other(format!(
|
||||||
response.diagnostics.extend(stopped.diagnostics);
|
"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(),
|
dedup_workers(&mut response.items);
|
||||||
severity: Some("error".to_string()),
|
if response.items.is_empty() {
|
||||||
message: error.to_string(),
|
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 selected = match pick_worker(target.clone(), response.items)? {
|
||||||
let worker = if selected.state == "stopped" {
|
WorkerPickerResult::SwitchWorkspace => {
|
||||||
let restore_target = BackendRuntimeTarget::new(
|
target.clear_workspace();
|
||||||
target.base_url.clone(),
|
continue;
|
||||||
selected.runtime_id.clone(),
|
}
|
||||||
selected.worker_id.clone(),
|
WorkerPickerResult::Selected(selected) => selected,
|
||||||
);
|
};
|
||||||
restore_backend_worker(&restore_target)
|
let worker = if selected.state == "stopped" {
|
||||||
.await
|
let restore_target = target
|
||||||
.map_err(|error| {
|
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||||
io::Error::other(format!(
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
"failed to restore Backend worker {}/{}: {error}",
|
restore_backend_worker(&restore_target)
|
||||||
selected.runtime_id, selected.worker_id
|
.await
|
||||||
))
|
.map_err(|error| {
|
||||||
})?
|
io::Error::other(format!(
|
||||||
.result
|
"failed to restore Backend worker {}/{}: {error}",
|
||||||
.worker
|
selected.runtime_id, selected.worker_id
|
||||||
.unwrap_or(selected)
|
))
|
||||||
} else {
|
})?
|
||||||
selected
|
.result
|
||||||
};
|
.worker
|
||||||
let attach_target =
|
.unwrap_or(selected)
|
||||||
BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
|
} else {
|
||||||
console::run_backend_runtime(attach_target).await
|
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>) {
|
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())));
|
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WorkerPickerResult {
|
||||||
|
Selected(BackendWorkerSummary),
|
||||||
|
SwitchWorkspace,
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_worker(
|
fn pick_worker(
|
||||||
target: BackendRuntimeListTarget,
|
target: BackendRuntimeListTarget,
|
||||||
mut workers: Vec<BackendWorkerSummary>,
|
mut workers: Vec<BackendWorkerSummary>,
|
||||||
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
|
) -> Result<WorkerPickerResult, Box<dyn Error>> {
|
||||||
workers.sort_by(|a, b| {
|
workers.sort_by(|a, b| {
|
||||||
a.runtime_id
|
a.runtime_id
|
||||||
.cmp(&b.runtime_id)
|
.cmp(&b.runtime_id)
|
||||||
@@ -114,7 +136,13 @@ fn pick_worker(
|
|||||||
Some(Action::Down) => state.next(),
|
Some(Action::Down) => state.next(),
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
close_viewport(&mut terminal)?;
|
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) => {
|
Some(Action::Cancel) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
@@ -181,6 +209,7 @@ enum Action {
|
|||||||
Up,
|
Up,
|
||||||
Down,
|
Down,
|
||||||
Submit,
|
Submit,
|
||||||
|
SwitchWorkspace,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +226,7 @@ fn poll_event() -> io::Result<Option<Action>> {
|
|||||||
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||||
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||||
KeyCode::Enter => Some(Action::Submit),
|
KeyCode::Enter => Some(Action::Submit),
|
||||||
|
KeyCode::Char('w') if !ctrl => Some(Action::SwitchWorkspace),
|
||||||
KeyCode::Esc => Some(Action::Cancel),
|
KeyCode::Esc => Some(Action::Cancel),
|
||||||
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -239,6 +269,8 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
|||||||
Span::raw(" select "),
|
Span::raw(" select "),
|
||||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||||
Span::raw(" attach "),
|
Span::raw(" attach "),
|
||||||
|
Span::styled("[w]", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" switch Workspace "),
|
||||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||||
Span::raw(" cancel"),
|
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 app;
|
||||||
mod backend_worker_picker;
|
mod backend_worker_picker;
|
||||||
|
mod backend_workspace_picker;
|
||||||
mod block;
|
mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
|
|||||||
@@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
|
Deno.test("root layout leaves Workspace selection explicit", async () => {
|
||||||
const layout = await Deno.readTextFile(
|
const layout = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
!layout.includes("/api/workspace") &&
|
||||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
!layout.includes("redirect(") &&
|
||||||
|
layout.includes("Workspace selection is explicit"),
|
||||||
|
"root layout must not infer or redirect to a singleton Workspace",
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
|
||||||
|
const [layout, multiplexer] = await Promise.all([
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||||
|
),
|
||||||
|
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
|
||||||
|
]);
|
||||||
assert(
|
assert(
|
||||||
layout.includes("throw redirect(307") &&
|
layout.includes("disposeWorkspaceMultiplexer(workspaceId)") &&
|
||||||
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
|
multiplexer.includes("multiplexers.delete(workspaceId)") &&
|
||||||
!layout.includes("scopedCompatibilityRoute") &&
|
multiplexer.includes("this.#subscriptions.clear()") &&
|
||||||
!layout.includes("workspaceRoute(workspaceId, pathname)"),
|
multiplexer.includes("this.#socket?.close()"),
|
||||||
"root layout should redirect only to the scoped workspace entry",
|
"changing Workspace must dispose old subscriptions and transport state",
|
||||||
);
|
|
||||||
assert(
|
|
||||||
!layout.includes("`/api${path}`") &&
|
|
||||||
!layout.includes('"/api/repositories"'),
|
|
||||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
export type WorkspaceCatalogRecord = {
|
||||||
|
workspace_id: string;
|
||||||
|
owner_account_id: string | null;
|
||||||
|
display_name: string;
|
||||||
|
state: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceRepositoryRecord = {
|
||||||
|
workspace_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
uri: string;
|
||||||
|
default_ref: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||||
|
repositories: WorkspaceRepositoryRecord[];
|
||||||
|
repository_error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceRequest = {
|
||||||
|
operation_key: string;
|
||||||
|
display_name: string;
|
||||||
|
repository: {
|
||||||
|
uri: string;
|
||||||
|
display_name: string | null;
|
||||||
|
default_ref: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceResponse = {
|
||||||
|
workspace: WorkspaceCatalogRecord;
|
||||||
|
repository: WorkspaceRepositoryRecord;
|
||||||
|
config_revision: number;
|
||||||
|
request_fingerprint: string;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WorkspaceCatalogError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number | null,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "WorkspaceCatalogError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fetch = typeof globalThis.fetch;
|
||||||
|
|
||||||
|
export async function listWorkspaces(
|
||||||
|
fetcher: Fetch,
|
||||||
|
): Promise<WorkspaceCatalogRecord[]> {
|
||||||
|
return await fetchJson<WorkspaceCatalogRecord[]>(
|
||||||
|
fetcher,
|
||||||
|
"/api/workspaces?limit=200",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceRepositories(
|
||||||
|
fetcher: Fetch,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<WorkspaceRepositoryRecord[]> {
|
||||||
|
return await fetchJson<WorkspaceRepositoryRecord[]>(
|
||||||
|
fetcher,
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkspaceCatalog(
|
||||||
|
fetcher: Fetch,
|
||||||
|
): Promise<WorkspaceCatalogItem[]> {
|
||||||
|
const workspaces = await listWorkspaces(fetcher);
|
||||||
|
return await Promise.all(
|
||||||
|
workspaces.map(async (workspace) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
repositories: await listWorkspaceRepositories(
|
||||||
|
fetcher,
|
||||||
|
workspace.workspace_id,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
repositories: [],
|
||||||
|
repository_error: errorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspace(
|
||||||
|
fetcher: Fetch,
|
||||||
|
request: CreateWorkspaceRequest,
|
||||||
|
): Promise<CreateWorkspaceResponse> {
|
||||||
|
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creationErrorMessage(error: unknown): string {
|
||||||
|
if (!(error instanceof WorkspaceCatalogError)) {
|
||||||
|
return `Network error. The same operation can be retried safely. ${
|
||||||
|
errorMessage(error)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
switch (error.status) {
|
||||||
|
case 400:
|
||||||
|
return `Validation failed. ${error.message}`;
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
return `You are not authorized to create this Workspace. ${error.message}`;
|
||||||
|
case 409:
|
||||||
|
return `Creation conflicts with current Backend state. ${error.message}`;
|
||||||
|
default:
|
||||||
|
return `Workspace creation failed. The same operation can be retried safely. ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationKey(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return `web-workspace-create-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `web-workspace-create-${Date.now()}-${
|
||||||
|
Math.random().toString(16).slice(2)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(
|
||||||
|
fetcher: Fetch,
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
throw new WorkspaceCatalogError(null, errorMessage(error));
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `${response.status} ${response.statusText}`.trim();
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (typeof body?.message === "string") detail = body.message;
|
||||||
|
else if (typeof body?.error === "string") detail = body.error;
|
||||||
|
} catch {
|
||||||
|
// Preserve the bounded status text when the Backend did not return JSON.
|
||||||
|
}
|
||||||
|
throw new WorkspaceCatalogError(response.status, detail);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer
|
|||||||
return multiplexer;
|
return multiplexer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function disposeWorkspaceMultiplexer(workspaceId: string): void {
|
||||||
|
const multiplexer = multiplexers.get(workspaceId);
|
||||||
|
if (!multiplexer) return;
|
||||||
|
multiplexers.delete(workspaceId);
|
||||||
|
multiplexer.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceMultiplexer {
|
export class WorkspaceMultiplexer {
|
||||||
readonly #workspaceId: string;
|
readonly #workspaceId: string;
|
||||||
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
||||||
@@ -219,6 +226,22 @@ export class WorkspaceMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.#closed = true;
|
||||||
|
if (this.#reconnectTimer) {
|
||||||
|
clearTimeout(this.#reconnectTimer);
|
||||||
|
this.#reconnectTimer = null;
|
||||||
|
}
|
||||||
|
for (const subscription of this.#subscriptions.values()) {
|
||||||
|
subscription.listener.onStatus?.('closed', 'Workspace selection changed');
|
||||||
|
}
|
||||||
|
this.#subscriptions.clear();
|
||||||
|
this.#requests.clear();
|
||||||
|
this.#runtimeSubscriptions.clear();
|
||||||
|
this.#socket?.close();
|
||||||
|
this.#socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
#send(frame: SubscriptionFrame): void {
|
#send(frame: SubscriptionFrame): void {
|
||||||
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
||||||
this.#socket.send(JSON.stringify(frame));
|
this.#socket.send(JSON.stringify(frame));
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
const { currentPath }: Props = $props();
|
const { currentPath }: Props = $props();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ href: '/', label: 'Workspaces' },
|
||||||
|
{ href: '/#workspace-create-title', label: 'Create Workspace' },
|
||||||
{ href: '/account', label: 'Account' },
|
{ href: '/account', label: 'Account' },
|
||||||
{ href: '/login/device', label: 'Device Login' },
|
{ href: '/login/device', label: 'Device Login' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
|
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -76,6 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
listWorkspaces,
|
||||||
|
type WorkspaceCatalogRecord,
|
||||||
|
} from "$lib/workspace/api/workspace-catalog";
|
||||||
|
import "$lib/workspace/styles/workspace-catalog.css";
|
||||||
|
|
||||||
|
let { currentWorkspaceId } = $props<{ currentWorkspaceId: string }>();
|
||||||
|
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
workspaces = await listWorkspaces(fetch);
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function switchWorkspace(event: Event) {
|
||||||
|
const workspaceId = (event.currentTarget as HTMLSelectElement).value;
|
||||||
|
if (!workspaceId || workspaceId === currentWorkspaceId) return;
|
||||||
|
await goto(`/w/${encodeURIComponent(workspaceId)}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="workspace-switcher">
|
||||||
|
<label for="workspace-switcher-select">Workspace</label>
|
||||||
|
<select
|
||||||
|
id="workspace-switcher-select"
|
||||||
|
value={currentWorkspaceId}
|
||||||
|
onchange={switchWorkspace}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Switch Workspace"
|
||||||
|
>
|
||||||
|
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
|
||||||
|
<option value={currentWorkspaceId}>
|
||||||
|
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
|
||||||
|
</option>
|
||||||
|
{/if}
|
||||||
|
{#each workspaces as workspace (workspace.workspace_id)}
|
||||||
|
<option value={workspace.workspace_id}>{workspace.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div class="workspace-switcher-actions">
|
||||||
|
<a href="/">All Workspaces</a>
|
||||||
|
<a href="/#workspace-create-title">Create</a>
|
||||||
|
</div>
|
||||||
|
{#if error}<span class="workspace-switcher-error">Selector unavailable: {error}</span>{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
@layer components {
|
||||||
|
.workspace-catalog-shell {
|
||||||
|
width: min(1120px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-heading,
|
||||||
|
.workspace-card-heading,
|
||||||
|
.workspace-create-row,
|
||||||
|
.workspace-switcher-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-heading h1,
|
||||||
|
.workspace-create-panel h2,
|
||||||
|
.workspace-catalog-shell h2 {
|
||||||
|
margin: 0.2rem 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-heading p,
|
||||||
|
.workspace-create-panel p,
|
||||||
|
.workspace-empty-state p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-eyebrow {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-card,
|
||||||
|
.workspace-create-panel,
|
||||||
|
.workspace-empty-state {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-card {
|
||||||
|
color: inherit;
|
||||||
|
padding: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-card:hover,
|
||||||
|
.workspace-catalog-card:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-card code,
|
||||||
|
.workspace-catalog-card small,
|
||||||
|
.workspace-repository-summary small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-heading > span {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-heading > .workspace-state-active {
|
||||||
|
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-repository-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-empty-state {
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel input,
|
||||||
|
.workspace-switcher select {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-row > label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-primary-action,
|
||||||
|
.workspace-secondary-action {
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-primary-action {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-secondary-action {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-primary-action:disabled,
|
||||||
|
.workspace-secondary-action:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-catalog-alert {
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0 0.75rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.workspace-create-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-row,
|
||||||
|
.workspace-catalog-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,5 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import type { LayoutLoad } from './$types';
|
||||||
import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
|
|
||||||
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
|
||||||
import type { LayoutLoad } from "./$types";
|
|
||||||
|
|
||||||
export const ssr = false;
|
// Workspace selection is explicit at `/`; the root layout must never infer a
|
||||||
export const prerender = false;
|
// singleton Workspace or redirect based on an unscoped compatibility endpoint.
|
||||||
|
export const load: LayoutLoad = () => ({});
|
||||||
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
|
||||||
if (params.workspaceId) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicRoutes = new Set(["/account", "/login/device"]);
|
|
||||||
if (publicRoutes.has(url.pathname)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
|
||||||
if (workspace.data) {
|
|
||||||
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
|
||||||
throw redirect(307, `${scopedPath}${url.search}`);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,189 @@
|
|||||||
<main class="workspace-panel-shell">
|
<script lang="ts">
|
||||||
<section class="workspace-card">
|
import { goto } from "$app/navigation";
|
||||||
<h1>Redirecting to scoped workspace…</h1>
|
import {
|
||||||
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/<workspace-id></code> route.</p>
|
createOperationKey,
|
||||||
|
createWorkspace,
|
||||||
|
creationErrorMessage,
|
||||||
|
loadWorkspaceCatalog,
|
||||||
|
type CreateWorkspaceRequest,
|
||||||
|
type WorkspaceCatalogItem,
|
||||||
|
} from "$lib/workspace/api/workspace-catalog";
|
||||||
|
import "$lib/workspace/styles/workspace-catalog.css";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let workspaces = $state<WorkspaceCatalogItem[]>([]);
|
||||||
|
let catalogError = $state<string | null>(null);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let creationError = $state<string | null>(null);
|
||||||
|
let displayName = $state("");
|
||||||
|
let repositoryUri = $state("");
|
||||||
|
let repositoryName = $state("Main");
|
||||||
|
let defaultRef = $state("");
|
||||||
|
let lastSubmission = $state<{
|
||||||
|
signature: string;
|
||||||
|
request: CreateWorkspaceRequest;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
workspaces = data.workspaces;
|
||||||
|
catalogError = data.catalogError;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshCatalog() {
|
||||||
|
refreshing = true;
|
||||||
|
catalogError = null;
|
||||||
|
try {
|
||||||
|
workspaces = await loadWorkspaceCatalog(fetch);
|
||||||
|
} catch (error) {
|
||||||
|
catalogError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreation(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (creating) return;
|
||||||
|
const normalized = {
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
repositoryUri: repositoryUri.trim(),
|
||||||
|
repositoryName: repositoryName.trim(),
|
||||||
|
defaultRef: defaultRef.trim(),
|
||||||
|
};
|
||||||
|
const signature = JSON.stringify(normalized);
|
||||||
|
const request = lastSubmission?.signature === signature
|
||||||
|
? lastSubmission.request
|
||||||
|
: {
|
||||||
|
operation_key: createOperationKey(),
|
||||||
|
display_name: normalized.displayName,
|
||||||
|
repository: {
|
||||||
|
uri: normalized.repositoryUri,
|
||||||
|
display_name: normalized.repositoryName || null,
|
||||||
|
default_ref: normalized.defaultRef || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
lastSubmission = { signature, request };
|
||||||
|
creating = true;
|
||||||
|
creationError = null;
|
||||||
|
try {
|
||||||
|
const response = await createWorkspace(fetch, request);
|
||||||
|
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
|
||||||
|
} catch (error) {
|
||||||
|
creationError = creationErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdated(value: string): string {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp)
|
||||||
|
? value
|
||||||
|
: new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(timestamp);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Workspaces · Yoi</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-catalog-shell">
|
||||||
|
<section class="workspace-catalog-heading">
|
||||||
|
<div>
|
||||||
|
<p class="workspace-catalog-eyebrow">Backend</p>
|
||||||
|
<h1>Workspaces</h1>
|
||||||
|
<p>Select an accessible team space or create one on this Backend.</p>
|
||||||
|
</div>
|
||||||
|
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
|
||||||
|
{refreshing ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
{#if catalogError}
|
||||||
|
<div class="workspace-catalog-alert" role="alert">
|
||||||
|
Refresh failed. Existing results were kept. {catalogError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section aria-labelledby="workspace-list-title">
|
||||||
|
<h2 id="workspace-list-title">Available Workspaces</h2>
|
||||||
|
{#if workspaces.length === 0}
|
||||||
|
<div class="workspace-empty-state">
|
||||||
|
<strong>No accessible Workspaces</strong>
|
||||||
|
<p>Create the first Workspace if you have Backend permission.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-catalog-grid">
|
||||||
|
{#each workspaces as workspace (workspace.workspace_id)}
|
||||||
|
<a
|
||||||
|
class="workspace-catalog-card"
|
||||||
|
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||||
|
>
|
||||||
|
<span class="workspace-card-heading">
|
||||||
|
<strong>{workspace.display_name}</strong>
|
||||||
|
<span class:workspace-state-active={workspace.state === "active"}>
|
||||||
|
{workspace.state}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<code>{workspace.workspace_id}</code>
|
||||||
|
{#if workspace.repositories[0]}
|
||||||
|
<span class="workspace-repository-summary">
|
||||||
|
{workspace.repositories[0].name}
|
||||||
|
<small>
|
||||||
|
{workspace.repositories[0].default_ref ?? "repository default"} ·
|
||||||
|
{workspace.repositories[0].kind}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
{:else if workspace.repository_error}
|
||||||
|
<small>Repository summary unavailable</small>
|
||||||
|
{:else}
|
||||||
|
<small>No repositories</small>
|
||||||
|
{/if}
|
||||||
|
<small>Updated {formatUpdated(workspace.updated_at)}</small>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
|
||||||
|
<div>
|
||||||
|
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||||
|
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||||
|
<p>
|
||||||
|
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
|
||||||
|
not authority.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onsubmit={submitCreation}>
|
||||||
|
<label>
|
||||||
|
Workspace display name
|
||||||
|
<input bind:value={displayName} required autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Initial repository absolute path or URI
|
||||||
|
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="workspace-create-row">
|
||||||
|
<label>
|
||||||
|
Repository display name
|
||||||
|
<input bind:value={repositoryName} autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Default ref
|
||||||
|
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if creationError}
|
||||||
|
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||||
|
{/if}
|
||||||
|
<button class="workspace-primary-action" type="submit" disabled={creating}>
|
||||||
|
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
import { loadWorkspaceCatalog } from "$lib/workspace/api/workspace-catalog";
|
||||||
|
|
||||||
export const load: PageLoad = async () => ({});
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
workspaces: await loadWorkspaceCatalog(fetch),
|
||||||
|
catalogError: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
workspaces: [],
|
||||||
|
catalogError: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import "$lib/workspace/styles/workspace-catalog.css";
|
||||||
|
|
||||||
|
const workspaceId = $derived(page.params.workspaceId ?? "unknown");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Workspace unavailable · Yoi</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-catalog-shell">
|
||||||
|
<section class="workspace-empty-state">
|
||||||
|
<p class="workspace-catalog-eyebrow">Workspace unavailable</p>
|
||||||
|
<h1>The selected Workspace cannot be opened</h1>
|
||||||
|
<p>
|
||||||
|
<code>{workspaceId}</code> may have been removed, become inaccessible, or no longer exist on
|
||||||
|
this Backend. No state from a previously selected Workspace was retained.
|
||||||
|
</p>
|
||||||
|
<div class="workspace-switcher-actions">
|
||||||
|
<a href="/">Choose another Workspace</a>
|
||||||
|
<a href="/#workspace-create-title">Create Workspace</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||||
|
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||||
import '$lib/workspace/styles/workspace-pages.css';
|
import '$lib/workspace/styles/workspace-pages.css';
|
||||||
import '$lib/workspace/styles/tickets.css';
|
import '$lib/workspace/styles/tickets.css';
|
||||||
@@ -10,6 +11,11 @@
|
|||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
let { data, children }: LayoutProps = $props();
|
||||||
|
$effect(() => {
|
||||||
|
const workspaceId = data.workspace?.workspace_id;
|
||||||
|
if (!workspaceId) return;
|
||||||
|
return () => disposeWorkspaceMultiplexer(workspaceId);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet workspaceHeader()}
|
{#snippet workspaceHeader()}
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { LayoutLoad } from "./$types";
|
||||||
import type {
|
import type {
|
||||||
RepositoryListResponse,
|
RepositoryListResponse,
|
||||||
WorkspaceResponse,
|
WorkspaceResponse,
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
import type { LayoutLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, params }) => {
|
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||||
const workspaceId = params.workspaceId;
|
const workspaceId = params.workspaceId;
|
||||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
|
||||||
const [workspace, repositories] = await Promise.all([
|
const [workspace, repositories] = await Promise.all([
|
||||||
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
loadJson<WorkspaceResponse>(
|
||||||
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/workspace"),
|
||||||
|
),
|
||||||
|
loadJson<RepositoryListResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/repositories"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!workspace.data) {
|
||||||
|
error(404, {
|
||||||
|
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspace: workspace.data,
|
workspace: workspace.data,
|
||||||
workspaceError: workspace.error,
|
workspaceError: null,
|
||||||
repositories: repositories.data,
|
repositories: repositories.data,
|
||||||
repositoriesError: repositories.error,
|
repositoriesError: repositories.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, received ${
|
||||||
|
JSON.stringify(actual)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRejects(
|
||||||
|
operation: () => Promise<unknown>,
|
||||||
|
errorType: typeof WorkspaceCatalogError,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof errorType) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("expected operation to reject");
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWorkspace,
|
||||||
|
loadWorkspaceCatalog,
|
||||||
|
WorkspaceCatalogError,
|
||||||
|
} from "../src/lib/workspace/api/workspace-catalog.ts";
|
||||||
|
|
||||||
|
Deno.test("workspace catalog enriches each visible workspace without dropping siblings", async () => {
|
||||||
|
const fetcher = (input: string | URL | Request) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.startsWith("/api/workspaces")) {
|
||||||
|
return Promise.resolve(Response.json([
|
||||||
|
{
|
||||||
|
workspace_id: "w-a",
|
||||||
|
owner_account_id: null,
|
||||||
|
display_name: "Alpha",
|
||||||
|
state: "active",
|
||||||
|
created_at: "1",
|
||||||
|
updated_at: "2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspace_id: "w-b",
|
||||||
|
owner_account_id: null,
|
||||||
|
display_name: "Beta",
|
||||||
|
state: "active",
|
||||||
|
created_at: "1",
|
||||||
|
updated_at: "3",
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if (url.includes("w-a")) {
|
||||||
|
return Promise.resolve(Response.json([{
|
||||||
|
workspace_id: "w-a",
|
||||||
|
repository_id: "main",
|
||||||
|
name: "Main",
|
||||||
|
kind: "local_path",
|
||||||
|
uri: "/srv/alpha",
|
||||||
|
default_ref: "develop",
|
||||||
|
}]));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response("unavailable", { status: 503 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
|
||||||
|
assertEquals(items.length, 2);
|
||||||
|
assertEquals(items[0].repositories[0].repository_id, "main");
|
||||||
|
assertEquals(items[1].repositories, []);
|
||||||
|
assertEquals(typeof items[1].repository_error, "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace creation preserves caller-owned operation key across retry", async () => {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
const request = {
|
||||||
|
operation_key: "web-create-1",
|
||||||
|
display_name: "Alpha",
|
||||||
|
repository: {
|
||||||
|
uri: "/srv/alpha",
|
||||||
|
display_name: "Main",
|
||||||
|
default_ref: "develop",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const fetcher = (_input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
bodies.push(JSON.parse(String(init?.body)));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "retry" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertRejects(
|
||||||
|
() => createWorkspace(fetcher as typeof fetch, request),
|
||||||
|
WorkspaceCatalogError,
|
||||||
|
);
|
||||||
|
await assertRejects(
|
||||||
|
() => createWorkspace(fetcher as typeof fetch, request),
|
||||||
|
WorkspaceCatalogError,
|
||||||
|
);
|
||||||
|
assertEquals(bodies, [request, request]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user