chore: merge current develop into workspace catalog DTO source
# Conflicts: # crates/workspace-api/src/lib.rs # crates/workspace-server/src/server.rs # web/workspace/deno.json # web/workspace/src/lib/workspace/sidebar/types.ts
This commit is contained in:
Generated
+1
-2
@@ -649,7 +649,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"workdir",
|
|
||||||
"workspace-api",
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6591,6 +6590,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6684,7 +6684,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"workdir",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync"
|
|||||||
tokio-tungstenite = { workspace = true }
|
tokio-tungstenite = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
workspace-api.workspace = true
|
workspace-api.workspace = true
|
||||||
workdir = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
|||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
|
||||||
pub use workspace_api::{
|
pub use workspace_api::{
|
||||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||||
@@ -20,6 +19,11 @@ pub use workspace_api::{
|
|||||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||||
|
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
|
||||||
|
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
||||||
|
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
|
||||||
|
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
||||||
|
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -675,8 +679,8 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.occupied_by
|
.occupied_by
|
||||||
.expect("occupied Workdir");
|
.expect("occupied Workdir");
|
||||||
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
|
assert_eq!(occupied_by.runtime_id, "arcadia");
|
||||||
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
|
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
||||||
|
|
||||||
let mut stale = payload;
|
let mut stale = payload;
|
||||||
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
||||||
|
|||||||
@@ -38,5 +38,10 @@ pub use target::{
|
|||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||||
};
|
};
|
||||||
pub use worker_client::WorkerClient;
|
pub use worker_client::WorkerClient;
|
||||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
pub use workspace_api::{
|
||||||
|
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
||||||
|
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
|
||||||
|
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
|
||||||
|
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
|
||||||
|
};
|
||||||
pub use workspace_product::BackendWorkspaceProductClient;
|
pub use workspace_product::BackendWorkspaceProductClient;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ sha2.workspace = true
|
|||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||||
|
workspace-api = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
+11
-251
@@ -6,7 +6,11 @@
|
|||||||
//! [`crate::http`].
|
//! [`crate::http`].
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
|
||||||
|
pub use workspace_api::{
|
||||||
|
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
|
||||||
|
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
|
};
|
||||||
|
|
||||||
/// Stable Workspace identity for a Worker hosted by a Runtime.
|
/// Stable Workspace identity for a Worker hosted by a Runtime.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
@@ -26,83 +30,6 @@ impl RuntimeWorkerRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum MaterializerKind {
|
|
||||||
#[default]
|
|
||||||
RuntimeGitCache,
|
|
||||||
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
|
|
||||||
LocalGitWorktree,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WorkingDirectoryStatusKind {
|
|
||||||
Active,
|
|
||||||
CleanupPending,
|
|
||||||
Corrupted,
|
|
||||||
NotFound,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkingDirectoryStatusKind {
|
|
||||||
pub const fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Active => "active",
|
|
||||||
Self::CleanupPending => "cleanup_pending",
|
|
||||||
Self::Corrupted => "corrupted",
|
|
||||||
Self::NotFound => "not_found",
|
|
||||||
Self::Unknown => "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for WorkingDirectoryStatusKind {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkingDirectoryCleanupTarget {
|
|
||||||
pub kind: String,
|
|
||||||
pub working_directory_id: String,
|
|
||||||
pub repository_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Durable Workspace occupancy projection for one Workdir.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct WorkingDirectoryOccupancy {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub worker: RuntimeWorkerRef,
|
|
||||||
pub display_name: String,
|
|
||||||
pub linked_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for WorkingDirectoryOccupancy {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct Wire {
|
|
||||||
runtime_id: String,
|
|
||||||
worker_id: String,
|
|
||||||
display_name: String,
|
|
||||||
linked_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let wire = Wire::deserialize(deserializer)?;
|
|
||||||
Ok(Self {
|
|
||||||
worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id),
|
|
||||||
display_name: wire.display_name,
|
|
||||||
linked_at: wire.linked_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable materialization provenance retained by Workspace inventory.
|
/// Immutable materialization provenance retained by Workspace inventory.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation {
|
|||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkingDirectorySummary {
|
|
||||||
pub working_directory_id: String,
|
|
||||||
pub repository_id: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub creation_selector: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub creation_ref: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub creation_tree: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub current_selector: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub current_ref: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub current_tree: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub observed_at_epoch_seconds: Option<u64>,
|
|
||||||
pub materializer_kind: MaterializerKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
|
||||||
pub status: WorkingDirectoryStatusKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cleanliness: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub primary_worker_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkingDirectorySummary {
|
|
||||||
/// Workspace-managed inventory rows carry explicit cleanup authority.
|
|
||||||
pub fn is_workspace_managed(&self) -> bool {
|
|
||||||
self.cleanup_target.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn provenance(&self) -> WorkingDirectoryProvenance {
|
|
||||||
WorkingDirectoryProvenance {
|
|
||||||
creation_selector: self.creation_selector.clone(),
|
|
||||||
creation_ref: self.creation_ref.clone(),
|
|
||||||
creation_tree: self.creation_tree.clone(),
|
|
||||||
materializer_kind: self.materializer_kind.clone(),
|
|
||||||
cleanup_target: self.cleanup_target.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_observation(&self) -> WorkingDirectoryCurrentObservation {
|
|
||||||
WorkingDirectoryCurrentObservation {
|
|
||||||
current_selector: self.current_selector.clone(),
|
|
||||||
current_ref: self.current_ref.clone(),
|
|
||||||
current_tree: self.current_tree.clone(),
|
|
||||||
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
|
|
||||||
status: self.status.clone(),
|
|
||||||
cleanliness: self.cleanliness.clone(),
|
|
||||||
primary_worker_id: self.primary_worker_id.clone(),
|
|
||||||
occupied_by: self.occupied_by.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WorkingDirectoryDiagnosticSeverity {
|
|
||||||
Info,
|
|
||||||
Warning,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkingDirectoryDiagnostic {
|
|
||||||
pub code: String,
|
|
||||||
pub severity: WorkingDirectoryDiagnosticSeverity,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkingDirectoryListResponse {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub items: Vec<WorkingDirectorySummary>,
|
|
||||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkingDirectoryDetailResponse {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub runtime_id: String,
|
|
||||||
pub item: WorkingDirectorySummary,
|
|
||||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -255,88 +88,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn occupied_and_free_list_response_round_trips() {
|
fn workspace_workdir_projection_reexports_workspace_api_authority() {
|
||||||
let response = WorkingDirectoryListResponse {
|
|
||||||
workspace_id: "workspace".to_string(),
|
|
||||||
items: vec![
|
|
||||||
WorkingDirectorySummary {
|
|
||||||
working_directory_id: "occupied".to_string(),
|
|
||||||
repository_id: "repo".to_string(),
|
|
||||||
creation_selector: Some("develop".to_string()),
|
|
||||||
creation_ref: Some("abc123".to_string()),
|
|
||||||
creation_tree: Some("tree123".to_string()),
|
|
||||||
current_selector: Some("work/ticket".to_string()),
|
|
||||||
current_ref: Some("def456".to_string()),
|
|
||||||
current_tree: Some("tree456".to_string()),
|
|
||||||
observed_at_epoch_seconds: Some(1_777_777_777),
|
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
|
||||||
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
|
||||||
kind: "git_worktree".to_string(),
|
|
||||||
working_directory_id: "occupied".to_string(),
|
|
||||||
repository_id: "repo".to_string(),
|
|
||||||
}),
|
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
|
||||||
cleanliness: Some("clean".to_string()),
|
|
||||||
primary_worker_id: None,
|
|
||||||
occupied_by: Some(WorkingDirectoryOccupancy {
|
|
||||||
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
|
|
||||||
display_name: "Coder".to_string(),
|
|
||||||
linked_at: "2026-08-12T00:00:00Z".to_string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
WorkingDirectorySummary {
|
|
||||||
working_directory_id: "free".to_string(),
|
|
||||||
repository_id: "repo".to_string(),
|
|
||||||
creation_selector: None,
|
|
||||||
creation_ref: None,
|
|
||||||
creation_tree: None,
|
|
||||||
current_selector: None,
|
|
||||||
current_ref: Some("987fed".to_string()),
|
|
||||||
current_tree: None,
|
|
||||||
observed_at_epoch_seconds: None,
|
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
|
||||||
cleanup_target: None,
|
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
|
||||||
cleanliness: Some("unknown".to_string()),
|
|
||||||
primary_worker_id: None,
|
|
||||||
occupied_by: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
diagnostics: vec![WorkingDirectoryDiagnostic {
|
|
||||||
code: "observed".to_string(),
|
|
||||||
severity: WorkingDirectoryDiagnosticSeverity::Info,
|
|
||||||
message: "inventory observed".to_string(),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let encoded = serde_json::to_value(&response).unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encoded["items"][0]["occupied_by"]["worker_id"],
|
std::any::TypeId::of::<WorkingDirectorySummary>(),
|
||||||
"worker-opaque-64"
|
std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
|
||||||
);
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
encoded["items"][0]["occupied_by"]
|
std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
|
||||||
.get("runtime_worker_id")
|
std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
|
||||||
.is_none()
|
|
||||||
);
|
);
|
||||||
assert!(encoded["items"][1].get("occupied_by").is_none());
|
|
||||||
|
|
||||||
let mut stale = encoded.clone();
|
|
||||||
stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
|
||||||
assert!(serde_json::from_value::<WorkingDirectoryListResponse>(stale).is_err());
|
|
||||||
|
|
||||||
let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap();
|
|
||||||
assert_eq!(decoded, response);
|
|
||||||
|
|
||||||
let detail = WorkingDirectoryDetailResponse {
|
|
||||||
workspace_id: decoded.workspace_id.clone(),
|
|
||||||
runtime_id: "arcadia".to_string(),
|
|
||||||
item: decoded.items[0].clone(),
|
|
||||||
diagnostics: decoded.diagnostics.clone(),
|
|
||||||
};
|
|
||||||
let encoded = serde_json::to_value(&detail).unwrap();
|
|
||||||
let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap();
|
|
||||||
assert_eq!(decoded, detail);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
||||||
use workdir::workspace::{
|
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
|
||||||
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
|
|
||||||
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
|
|
||||||
WorkspaceWorkdirSessionOperationRequest,
|
|
||||||
};
|
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
@@ -24,6 +20,13 @@ use workdir::{
|
|||||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use workspace_api::{
|
||||||
|
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
|
||||||
|
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
|
||||||
|
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
|
||||||
|
WorkingDirectoryListResponse as WorkdirListResponse,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||||
ToolDeclaration,
|
ToolDeclaration,
|
||||||
@@ -420,9 +423,9 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
runtime_id: runtime_id.map(str::to_string),
|
runtime_id: runtime_id.map(str::to_string),
|
||||||
repository_id: repository_id.to_string(),
|
repository_id: repository_id.to_string(),
|
||||||
selector,
|
selector,
|
||||||
operation_id,
|
operation_id: Some(operation_id),
|
||||||
};
|
};
|
||||||
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
|
let response = self.execute_json::<WorkdirCreateResponse>(WorkspaceRequest::json(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("/api/w/{workspace_id}/working-directories"),
|
format!("/api/w/{workspace_id}/working-directories"),
|
||||||
serde_json::to_string(&request).map_err(decode_error)?,
|
serde_json::to_string(&request).map_err(decode_error)?,
|
||||||
@@ -701,16 +704,6 @@ struct WorkdirCreateInput {
|
|||||||
selector: Option<String>,
|
selector: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct WorkdirCreateRequest {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
runtime_id: Option<String>,
|
|
||||||
repository_id: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
selector: Option<String>,
|
|
||||||
operation_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct WorkdirAttachInput {
|
struct WorkdirAttachInput {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ typescript = ["dep:ts-rs"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
workdir.workspace = true
|
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_typescript"
|
name = "generate_typescript"
|
||||||
@@ -20,3 +19,15 @@ required-features = ["typescript"]
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_workdir_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_companion_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_repository_access_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::companion_api_typescript());
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::repository_access_api_typescript());
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::workdir_api_typescript());
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
//! callers must explicitly construct these Workspace-authoritative resources.
|
//! callers must explicitly construct these Workspace-authoritative resources.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use workdir::workspace::WorkingDirectorySummary;
|
|
||||||
|
|
||||||
/// Provider-neutral classification of an authoritative Repository source.
|
/// Provider-neutral classification of an authoritative Repository source.
|
||||||
///
|
///
|
||||||
@@ -319,12 +318,165 @@ pub enum DiagnosticSeverity {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Diagnostic {
|
pub struct Diagnostic {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub severity: DiagnosticSeverity,
|
pub severity: DiagnosticSeverity,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public Workdir materializer classification.
|
||||||
|
///
|
||||||
|
/// The value identifies stable materialization provenance without exposing a
|
||||||
|
/// provider path, Runtime handle, or session identity.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkingDirectoryMaterializerKind {
|
||||||
|
#[default]
|
||||||
|
RuntimeGitCache,
|
||||||
|
LocalGitWorktree,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkingDirectoryStatusKind {
|
||||||
|
Active,
|
||||||
|
CleanupPending,
|
||||||
|
Corrupted,
|
||||||
|
NotFound,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkingDirectoryStatusKind {
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::CleanupPending => "cleanup_pending",
|
||||||
|
Self::Corrupted => "corrupted",
|
||||||
|
Self::NotFound => "not_found",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for WorkingDirectoryStatusKind {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryCleanupTarget {
|
||||||
|
pub kind: String,
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable Workspace occupancy projection for one Workdir.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryOccupancy {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub linked_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public, provider-neutral Workdir inventory projection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectorySummary {
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creation_selector: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creation_ref: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creation_tree: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_selector: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_ref: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_tree: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
|
||||||
|
pub observed_at_epoch_seconds: Option<u64>,
|
||||||
|
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||||
|
pub status: WorkingDirectoryStatusKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cleanliness: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub primary_worker_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkingDirectorySummary {
|
||||||
|
/// Workspace-managed inventory rows carry explicit cleanup authority.
|
||||||
|
pub fn is_workspace_managed(&self) -> bool {
|
||||||
|
self.cleanup_target.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser/Rust-client Workdir materialization request.
|
||||||
|
///
|
||||||
|
/// `runtime_id = None` requests Workspace default Runtime resolution and
|
||||||
|
/// `operation_id = Some(_)` fences exact replay. All four fields deliberately
|
||||||
|
/// preserve the Server's existing optionality.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryCreateRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime_id: Option<String>,
|
||||||
|
pub repository_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub selector: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub operation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryListResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub items: Vec<WorkingDirectorySummary>,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryDetailResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub item: WorkingDirectorySummary,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkingDirectoryCreateResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub item: WorkingDirectorySummary,
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ListResponse<T> {
|
pub struct ListResponse<T> {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -543,6 +695,7 @@ pub struct WorkerCapabilitySummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum WorkspaceWorkerSubject {
|
pub enum WorkspaceWorkerSubject {
|
||||||
RuntimeWorker {
|
RuntimeWorker {
|
||||||
@@ -555,6 +708,7 @@ pub enum WorkspaceWorkerSubject {
|
|||||||
/// Runtime placement appears only in the typed subject required by Worker
|
/// Runtime placement appears only in the typed subject required by Worker
|
||||||
/// control operations; provider and launch internals are intentionally omitted.
|
/// control operations; provider and launch internals are intentionally omitted.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkspaceWorkerDiscoveryItem {
|
pub struct WorkspaceWorkerDiscoveryItem {
|
||||||
pub subject: WorkspaceWorkerSubject,
|
pub subject: WorkspaceWorkerSubject,
|
||||||
pub resource_key: String,
|
pub resource_key: String,
|
||||||
@@ -564,6 +718,137 @@ pub struct WorkspaceWorkerDiscoveryItem {
|
|||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public lifecycle projection for the Workspace Companion endpoint.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionLifecycleState {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public outcome of a Companion message submission.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionMessageDisposition {
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public, bounded transport metadata for Companion status.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTransportSummary {
|
||||||
|
pub mode: String,
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion status response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionStatusResponse {
|
||||||
|
pub state: CompanionLifecycleState,
|
||||||
|
pub worker: Option<WorkspaceWorkerDiscoveryItem>,
|
||||||
|
pub transport: CompanionTransportSummary,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion message request.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionMessageRequest {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion cancellation request.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionCancelRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public Workspace Companion message response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionMessageResponse {
|
||||||
|
pub state: CompanionMessageDisposition,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-visible role accepted in the public Companion transcript.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanionTranscriptRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One allowlisted, user-visible Companion transcript item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTranscriptItem {
|
||||||
|
pub sequence: usize,
|
||||||
|
pub role: CompanionTranscriptRole,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded public Companion transcript projection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CompanionTranscriptProjection {
|
||||||
|
pub state: CompanionLifecycleState,
|
||||||
|
pub start: usize,
|
||||||
|
pub limit: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub next: Option<usize>,
|
||||||
|
pub items: Vec<CompanionTranscriptItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn companion_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
DiagnosticSeverity::decl(&config),
|
||||||
|
Diagnostic::decl(&config),
|
||||||
|
WorkspaceWorkerSubject::decl(&config),
|
||||||
|
WorkspaceWorkerDiscoveryItem::decl(&config),
|
||||||
|
CompanionLifecycleState::decl(&config),
|
||||||
|
CompanionMessageDisposition::decl(&config),
|
||||||
|
CompanionTransportSummary::decl(&config),
|
||||||
|
CompanionStatusResponse::decl(&config),
|
||||||
|
CompanionMessageRequest::decl(&config),
|
||||||
|
CompanionCancelRequest::decl(&config),
|
||||||
|
CompanionMessageResponse::decl(&config),
|
||||||
|
CompanionTranscriptRole::decl(&config),
|
||||||
|
CompanionTranscriptItem::decl(&config),
|
||||||
|
CompanionTranscriptProjection::decl(&config),
|
||||||
|
];
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkspaceWorkerDiscoveryPage {
|
pub struct WorkspaceWorkerDiscoveryPage {
|
||||||
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
|
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
|
||||||
@@ -649,6 +934,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
|||||||
///
|
///
|
||||||
/// Secret references and secret material are deliberately not part of this DTO.
|
/// Secret references and secret material are deliberately not part of this DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshCredential {
|
pub struct RepositorySshCredential {
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
@@ -656,6 +942,7 @@ pub struct RepositorySshCredential {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub public_key_algorithm: String,
|
pub public_key_algorithm: String,
|
||||||
pub public_key_fingerprint: String,
|
pub public_key_fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub current_revision: u64,
|
pub current_revision: u64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -665,6 +952,7 @@ pub struct RepositorySshCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRepositorySshCredentialRequest {
|
pub struct CreateRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
@@ -676,9 +964,11 @@ pub struct CreateRepositorySshCredentialRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RotateRepositorySshCredentialRequest {
|
pub struct RotateRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
pub private_key: String,
|
pub private_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -686,14 +976,17 @@ pub struct RotateRepositorySshCredentialRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DeleteRepositorySshCredentialRequest {
|
pub struct DeleteRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public metadata for an explicitly pinned SSH host key.
|
/// Public metadata for an explicitly pinned SSH host key.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshHostTrust {
|
pub struct RepositorySshHostTrust {
|
||||||
pub host_trust_id: String,
|
pub host_trust_id: String,
|
||||||
@@ -703,6 +996,7 @@ pub struct RepositorySshHostTrust {
|
|||||||
pub key_algorithm: String,
|
pub key_algorithm: String,
|
||||||
pub host_key: String,
|
pub host_key: String,
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub current_revision: u64,
|
pub current_revision: u64,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -711,6 +1005,7 @@ pub struct RepositorySshHostTrust {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PutRepositorySshHostTrustRequest {
|
pub struct PutRepositorySshHostTrustRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
@@ -719,17 +1014,22 @@ pub struct PutRepositorySshHostTrustRequest {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub host_key: String,
|
pub host_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
pub expected_revision: Option<u64>,
|
pub expected_revision: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DeleteRepositorySshHostTrustRequest {
|
pub struct DeleteRepositorySshHostTrustRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RepositoryAccessMode {
|
pub enum RepositoryAccessMode {
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
@@ -737,6 +1037,7 @@ pub enum RepositoryAccessMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshAccessBinding {
|
pub struct RepositorySshAccessBinding {
|
||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
@@ -747,9 +1048,11 @@ pub struct RepositorySshAccessBinding {
|
|||||||
|
|
||||||
/// Secret-free active Repository access projection consumed by later Runtime work.
|
/// Secret-free active Repository access projection consumed by later Runtime work.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositoryAccessProjection {
|
pub struct RepositoryAccessProjection {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
pub projection_digest: String,
|
pub projection_digest: String,
|
||||||
pub bindings: Vec<RepositorySshAccessBinding>,
|
pub bindings: Vec<RepositorySshAccessBinding>,
|
||||||
@@ -792,6 +1095,88 @@ pub fn catalog_typescript() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn repository_access_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
RepositorySshCredential::decl(&config),
|
||||||
|
CreateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
RotateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
DeleteRepositorySshCredentialRequest::decl(&config),
|
||||||
|
RepositorySshHostTrust::decl(&config),
|
||||||
|
PutRepositorySshHostTrustRequest::decl(&config),
|
||||||
|
DeleteRepositorySshHostTrustRequest::decl(&config),
|
||||||
|
RepositoryAccessMode::decl(&config),
|
||||||
|
RepositorySshAccessBinding::decl(&config),
|
||||||
|
RepositoryAccessProjection::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn workdir_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
DiagnosticSeverity::decl(&config),
|
||||||
|
Diagnostic::decl(&config),
|
||||||
|
WorkingDirectoryMaterializerKind::decl(&config),
|
||||||
|
WorkingDirectoryStatusKind::decl(&config),
|
||||||
|
WorkingDirectoryCleanupTarget::decl(&config),
|
||||||
|
WorkingDirectoryOccupancy::decl(&config),
|
||||||
|
WorkingDirectorySummary::decl(&config),
|
||||||
|
WorkingDirectoryCreateRequest::decl(&config),
|
||||||
|
WorkingDirectoryListResponse::decl(&config),
|
||||||
|
WorkingDirectoryDetailResponse::decl(&config),
|
||||||
|
WorkingDirectoryCreateResponse::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod workdir_typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_workdir_api_contract_is_current() {
|
||||||
|
let expected = super::workdir_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/workdir-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Workdir API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
'\r' | '\n' | ' ' | '\t' => None,
|
||||||
|
_ => Some(character),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -910,4 +1295,299 @@ mod tests {
|
|||||||
|
|
||||||
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn round_trip<T>(value: T)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let encoded = serde_json::to_vec(&value).expect("fixture should serialize");
|
||||||
|
let decoded: T = serde_json::from_slice(&encoded).expect("fixture should deserialize");
|
||||||
|
assert_eq!(decoded, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||||
|
WorkspaceWorkerDiscoveryItem {
|
||||||
|
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||||
|
runtime_id: "arcadia".to_string(),
|
||||||
|
worker_id: "worker-7".to_string(),
|
||||||
|
},
|
||||||
|
resource_key: "W-7".to_string(),
|
||||||
|
display_name: "Companion".to_string(),
|
||||||
|
profile: Some("builtin:companion".to_string()),
|
||||||
|
status: Some("idle".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_status_fixtures_round_trip() {
|
||||||
|
for state in [
|
||||||
|
CompanionLifecycleState::Idle,
|
||||||
|
CompanionLifecycleState::Running,
|
||||||
|
CompanionLifecycleState::Stopped,
|
||||||
|
] {
|
||||||
|
round_trip(CompanionStatusResponse {
|
||||||
|
state,
|
||||||
|
worker: Some(companion_worker()),
|
||||||
|
transport: CompanionTransportSummary {
|
||||||
|
mode: "worker_runtime".to_string(),
|
||||||
|
available: state != CompanionLifecycleState::Stopped,
|
||||||
|
},
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_message_fixtures_round_trip() {
|
||||||
|
for state in [
|
||||||
|
CompanionMessageDisposition::Accepted,
|
||||||
|
CompanionMessageDisposition::Rejected,
|
||||||
|
] {
|
||||||
|
round_trip(CompanionMessageResponse {
|
||||||
|
state,
|
||||||
|
message: if state == CompanionMessageDisposition::Accepted {
|
||||||
|
"accepted"
|
||||||
|
} else {
|
||||||
|
"rejected"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_transcript_fixture_round_trips() {
|
||||||
|
round_trip(CompanionTranscriptProjection {
|
||||||
|
state: CompanionLifecycleState::Idle,
|
||||||
|
start: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 2,
|
||||||
|
next: None,
|
||||||
|
items: vec![
|
||||||
|
CompanionTranscriptItem {
|
||||||
|
sequence: 1,
|
||||||
|
role: CompanionTranscriptRole::User,
|
||||||
|
content: "hello".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
CompanionTranscriptItem {
|
||||||
|
sequence: 2,
|
||||||
|
role: CompanionTranscriptRole::Assistant,
|
||||||
|
content: "hi".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:01Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_transcript_rejects_system_and_private_fields() {
|
||||||
|
let public_item = CompanionTranscriptItem {
|
||||||
|
sequence: 1,
|
||||||
|
role: CompanionTranscriptRole::Assistant,
|
||||||
|
content: "visible".to_string(),
|
||||||
|
created_at: "2026-08-31T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
let public_fields = serde_json::to_value(public_item)
|
||||||
|
.expect("public transcript item should serialize")
|
||||||
|
.as_object()
|
||||||
|
.expect("public transcript item should be an object")
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
public_fields,
|
||||||
|
["content", "created_at", "role", "sequence"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
);
|
||||||
|
|
||||||
|
let system_item = serde_json::json!({
|
||||||
|
"sequence": 1,
|
||||||
|
"role": "system",
|
||||||
|
"content": "raw system prompt",
|
||||||
|
"created_at": "2026-08-31T00:00:00Z"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<CompanionTranscriptItem>(system_item).is_err());
|
||||||
|
|
||||||
|
let private_item = serde_json::json!({
|
||||||
|
"sequence": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "visible",
|
||||||
|
"created_at": "2026-08-31T00:00:00Z",
|
||||||
|
"reasoning": "hidden",
|
||||||
|
"credential": "secret",
|
||||||
|
"provider_session_id": "session-private"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
#[test]
|
||||||
|
fn generated_companion_api_contract_is_current() {
|
||||||
|
let expected = companion_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/companion-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize_typescript(&actual),
|
||||||
|
normalize_typescript(&expected),
|
||||||
|
"regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
fn normalize_typescript(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace(";}", "}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_create_request_preserves_optional_operation_fields() {
|
||||||
|
let payload = serde_json::json!({"repository_id": "main"});
|
||||||
|
let request = serde_json::from_value::<WorkingDirectoryCreateRequest>(payload)
|
||||||
|
.expect("optional create fields may be absent");
|
||||||
|
|
||||||
|
assert_eq!(request.runtime_id, None);
|
||||||
|
assert_eq!(request.selector, None);
|
||||||
|
assert_eq!(request.operation_id, None);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_value(request).expect("serialize create request");
|
||||||
|
assert_eq!(serialized, serde_json::json!({"repository_id": "main"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_create_request_rejects_stale_or_incomplete_json() {
|
||||||
|
let stale = serde_json::json!({
|
||||||
|
"repository_id": "main",
|
||||||
|
"selector": "develop",
|
||||||
|
"path": "/tmp/workdir"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<WorkingDirectoryCreateRequest>(stale).is_err());
|
||||||
|
|
||||||
|
let incomplete = serde_json::json!({
|
||||||
|
"runtime_id": "arcadia",
|
||||||
|
"operation_id": "operation-1"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<WorkingDirectoryCreateRequest>(incomplete).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_summary_omits_absent_optional_fields_on_the_wire() {
|
||||||
|
let value = serde_json::to_value(WorkingDirectorySummary {
|
||||||
|
working_directory_id: "workdir-1".into(),
|
||||||
|
repository_id: "main".into(),
|
||||||
|
creation_selector: None,
|
||||||
|
creation_ref: None,
|
||||||
|
creation_tree: None,
|
||||||
|
current_selector: None,
|
||||||
|
current_ref: None,
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: None,
|
||||||
|
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitCache,
|
||||||
|
cleanup_target: None,
|
||||||
|
status: WorkingDirectoryStatusKind::Active,
|
||||||
|
cleanliness: None,
|
||||||
|
primary_worker_id: None,
|
||||||
|
occupied_by: None,
|
||||||
|
})
|
||||||
|
.expect("serialize Workdir summary");
|
||||||
|
let object = value.as_object().expect("Workdir summary object");
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"creation_selector",
|
||||||
|
"creation_ref",
|
||||||
|
"creation_tree",
|
||||||
|
"current_selector",
|
||||||
|
"current_ref",
|
||||||
|
"current_tree",
|
||||||
|
"observed_at_epoch_seconds",
|
||||||
|
"cleanup_target",
|
||||||
|
"cleanliness",
|
||||||
|
"primary_worker_id",
|
||||||
|
"occupied_by",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!object.contains_key(key),
|
||||||
|
"absent field {key} must be omitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_response_rejects_stale_occupancy_shape() {
|
||||||
|
let stale = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-test",
|
||||||
|
"items": [{
|
||||||
|
"working_directory_id": "workdir-1",
|
||||||
|
"repository_id": "main",
|
||||||
|
"materializer_kind": "runtime_git_cache",
|
||||||
|
"status": "active",
|
||||||
|
"occupied_by": {
|
||||||
|
"runtime_worker_id": "worker-1",
|
||||||
|
"display_name": "Coder",
|
||||||
|
"linked_at": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"diagnostics": []
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(serde_json::from_value::<WorkingDirectoryListResponse>(stale).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_repository_access_contract_is_current() {
|
||||||
|
let expected = super::repository_access_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/repository-access-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_repository_access_responses_remain_secret_free() {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
for declaration in [
|
||||||
|
super::RepositorySshCredential::decl(&config),
|
||||||
|
super::RepositorySshHostTrust::decl(&config),
|
||||||
|
super::RepositoryAccessProjection::decl(&config),
|
||||||
|
] {
|
||||||
|
for forbidden in ["private_key", "passphrase", "secret_ref"] {
|
||||||
|
assert!(
|
||||||
|
!declaration.contains(forbidden),
|
||||||
|
"Repository Access response declaration must not expose `{forbidden}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,14 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use workspace_api::{
|
||||||
|
CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
|
pub use workspace_api::{
|
||||||
|
CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
#[serde(rename_all = "snake_case")]
|
};
|
||||||
pub enum CompanionState {
|
|
||||||
Disabled,
|
|
||||||
Rejected,
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionStatusResponse {
|
|
||||||
pub state: CompanionState,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worker: Option<WorkerSummary>,
|
|
||||||
pub transport: CompanionTransportSummary,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTransportSummary {
|
|
||||||
pub kind: String,
|
|
||||||
pub completion: String,
|
|
||||||
pub limitation: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionMessageRequest {
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
|
|
||||||
pub struct CompanionCancelRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionMessageResponse {
|
|
||||||
pub state: CompanionState,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worker: Option<WorkerSummary>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub user_item: Option<CompanionTranscriptItem>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub assistant_item: Option<CompanionTranscriptItem>,
|
|
||||||
pub transcript: CompanionTranscriptProjection,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTranscriptProjection {
|
|
||||||
pub state: CompanionState,
|
|
||||||
pub start: usize,
|
|
||||||
pub limit: usize,
|
|
||||||
pub total_items: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub next_start: Option<usize>,
|
|
||||||
pub items: Vec<CompanionTranscriptItem>,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CompanionTranscriptItem {
|
|
||||||
pub sequence: u64,
|
|
||||||
pub role: String,
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub source: String,
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
pub struct CompanionConsole;
|
pub struct CompanionConsole;
|
||||||
|
|
||||||
impl CompanionConsole {
|
impl CompanionConsole {
|
||||||
@@ -81,68 +18,50 @@ impl CompanionConsole {
|
|||||||
|
|
||||||
pub fn status(&self) -> CompanionStatusResponse {
|
pub fn status(&self) -> CompanionStatusResponse {
|
||||||
CompanionStatusResponse {
|
CompanionStatusResponse {
|
||||||
state: CompanionState::Disabled,
|
state: CompanionLifecycleState::Stopped,
|
||||||
worker: None,
|
worker: None,
|
||||||
transport: disabled_transport(),
|
transport: CompanionTransportSummary {
|
||||||
|
mode: "disabled".to_string(),
|
||||||
|
available: false,
|
||||||
|
},
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||||
CompanionTranscriptProjection {
|
CompanionTranscriptProjection {
|
||||||
state: CompanionState::Disabled,
|
state: CompanionLifecycleState::Stopped,
|
||||||
start,
|
start,
|
||||||
limit,
|
limit,
|
||||||
total_items: 0,
|
total: 0,
|
||||||
next_start: None,
|
next: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||||
disabled_message_response(CompanionState::Rejected)
|
disabled_message_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||||
disabled_message_response(CompanionState::Cancelled)
|
disabled_message_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
|
fn disabled_message_response() -> CompanionMessageResponse {
|
||||||
CompanionMessageResponse {
|
CompanionMessageResponse {
|
||||||
state,
|
state: CompanionMessageDisposition::Rejected,
|
||||||
worker: None,
|
message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead."
|
||||||
user_item: None,
|
|
||||||
assistant_item: None,
|
|
||||||
transcript: CompanionTranscriptProjection {
|
|
||||||
state: CompanionState::Disabled,
|
|
||||||
start: 0,
|
|
||||||
limit: 200,
|
|
||||||
total_items: 0,
|
|
||||||
next_start: None,
|
|
||||||
items: Vec::new(),
|
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
},
|
|
||||||
diagnostics: vec![disabled_diagnostic()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disabled_transport() -> CompanionTransportSummary {
|
|
||||||
CompanionTransportSummary {
|
|
||||||
kind: "none".to_string(),
|
|
||||||
completion: "disabled".to_string(),
|
|
||||||
limitation:
|
|
||||||
"Workspace Companion auto-start has been removed; create an explicit Worker instead."
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn disabled_diagnostic() -> RuntimeDiagnostic {
|
fn disabled_diagnostic() -> Diagnostic {
|
||||||
RuntimeDiagnostic {
|
Diagnostic {
|
||||||
code: "companion_disabled".to_string(),
|
code: "companion_disabled".to_string(),
|
||||||
severity: DiagnosticSeverity::Info,
|
severity: DiagnosticSeverity::Info,
|
||||||
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
|
message:
|
||||||
|
"Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ use workdir::http::{
|
|||||||
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
|
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
|
||||||
};
|
};
|
||||||
use workdir::workspace::{
|
use workdir::workspace::{
|
||||||
MaterializerKind, WorkingDirectoryCleanupTarget,
|
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
|
||||||
WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity,
|
|
||||||
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy,
|
|
||||||
WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence,
|
WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence,
|
||||||
WorkspaceWorkdirSessionOperationRequest,
|
WorkspaceWorkdirSessionOperationRequest,
|
||||||
};
|
};
|
||||||
@@ -67,10 +64,15 @@ use workspace_api::{
|
|||||||
RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse,
|
RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse,
|
||||||
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
||||||
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
TICKET_RELATIONS_QUERY_PATH, WorkspaceCatalogListResponse, WorkspaceCreateResponse,
|
TICKET_RELATIONS_QUERY_PATH,
|
||||||
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspacePermissionSummary,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
|
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse,
|
||||||
|
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
|
||||||
|
WorkspaceExtensionPoints, WorkspacePermissionSummary, WorkspaceRepositoryRecord,
|
||||||
|
WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
||||||
|
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -3008,18 +3010,6 @@ pub struct WorkingDirectoryRepositoryOption {
|
|||||||
pub default_selector: Option<String>,
|
pub default_selector: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct BrowserWorkingDirectoryCreateRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
pub runtime_id: Option<String>,
|
|
||||||
pub repository_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub selector: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub operation_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrowserWorkerWorkingDirectorySelection {
|
pub struct BrowserWorkerWorkingDirectorySelection {
|
||||||
@@ -8816,15 +8806,15 @@ async fn scoped_get_worker_launch_options(
|
|||||||
|
|
||||||
fn working_directory_diagnostics(
|
fn working_directory_diagnostics(
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
) -> Vec<WorkingDirectoryDiagnostic> {
|
) -> Vec<workspace_api::Diagnostic> {
|
||||||
diagnostics
|
diagnostics
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|diagnostic| WorkingDirectoryDiagnostic {
|
.map(|diagnostic| workspace_api::Diagnostic {
|
||||||
code: diagnostic.code,
|
code: diagnostic.code,
|
||||||
severity: match diagnostic.severity {
|
severity: match diagnostic.severity {
|
||||||
DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info,
|
DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
|
||||||
DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning,
|
DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
|
||||||
DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error,
|
DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
|
||||||
},
|
},
|
||||||
message: diagnostic.message,
|
message: diagnostic.message,
|
||||||
})
|
})
|
||||||
@@ -8848,7 +8838,7 @@ async fn scoped_create_runtime_working_directory(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
|
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
|
||||||
create_workspace_working_directory(
|
create_workspace_working_directory(
|
||||||
&api,
|
&api,
|
||||||
&path.workspace_id,
|
&path.workspace_id,
|
||||||
@@ -8891,7 +8881,7 @@ async fn scoped_create_working_directory(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
|
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
|
||||||
create_workspace_working_directory(&api, &path.workspace_id, None, request).await
|
create_workspace_working_directory(&api, &path.workspace_id, None, request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8984,7 +8974,7 @@ async fn create_workspace_working_directory(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
route_runtime_id: Option<&str>,
|
route_runtime_id: Option<&str>,
|
||||||
request: BrowserWorkingDirectoryCreateRequest,
|
request: BrowserWorkingDirectoryCreateRequest,
|
||||||
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
|
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
|
||||||
validate_workspace_scope(api, workspace_id)?;
|
validate_workspace_scope(api, workspace_id)?;
|
||||||
if let (Some(route_runtime_id), Some(request_runtime_id)) =
|
if let (Some(route_runtime_id), Some(request_runtime_id)) =
|
||||||
(route_runtime_id, request.runtime_id.as_deref())
|
(route_runtime_id, request.runtime_id.as_deref())
|
||||||
@@ -9147,7 +9137,17 @@ async fn create_workspace_working_directory(
|
|||||||
&reserved.resolved_runtime_id,
|
&reserved.resolved_runtime_id,
|
||||||
&reserved.working_directory_id,
|
&reserved.working_directory_id,
|
||||||
)
|
)
|
||||||
.map(|response| (StatusCode::OK, response));
|
.map(|Json(response)| {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(BrowserWorkingDirectoryCreateResponse {
|
||||||
|
workspace_id: response.workspace_id,
|
||||||
|
runtime_id: response.runtime_id,
|
||||||
|
item: response.item,
|
||||||
|
diagnostics: response.diagnostics,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtime = match api
|
let runtime = match api
|
||||||
@@ -9326,7 +9326,7 @@ async fn create_workspace_working_directory(
|
|||||||
apply_workdir_occupancy_projection(api, &mut summary)?;
|
apply_workdir_occupancy_projection(api, &mut summary)?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(BrowserWorkingDirectoryDetailResponse {
|
Json(BrowserWorkingDirectoryCreateResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
runtime_id: reserved.resolved_runtime_id,
|
runtime_id: reserved.resolved_runtime_id,
|
||||||
item: summary,
|
item: summary,
|
||||||
@@ -11090,33 +11090,31 @@ async fn get_workspace(
|
|||||||
fn companion_console_extension_point(
|
fn companion_console_extension_point(
|
||||||
status: &CompanionStatusResponse,
|
status: &CompanionStatusResponse,
|
||||||
) -> WorkspaceExtensionPointState {
|
) -> WorkspaceExtensionPointState {
|
||||||
let completion = status.transport.completion.clone();
|
let extension_status = match status.state {
|
||||||
let note = match completion.as_str() {
|
workspace_api::CompanionLifecycleState::Idle => "idle",
|
||||||
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(),
|
workspace_api::CompanionLifecycleState::Running => "running",
|
||||||
"not_input_capable" => {
|
workspace_api::CompanionLifecycleState::Stopped => "stopped",
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
let diagnostic_codes = status
|
let diagnostic_codes = status
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.map(|diagnostic| diagnostic.code.as_str())
|
.map(|diagnostic| diagnostic.code.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
if diagnostic_codes.is_empty() {
|
let note = if status.transport.available {
|
||||||
"Workspace Companion is not input-capable; check provider, config, profile, secret, and authority diagnostics.".to_string()
|
"Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path."
|
||||||
|
.to_string()
|
||||||
|
} else if diagnostic_codes.is_empty() {
|
||||||
|
"Workspace Companion is unavailable; create or select an explicit Worker instead."
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("Workspace Companion is unavailable; check typed diagnostics: {diagnostic_codes}.")
|
||||||
"Workspace Companion is not input-capable; check typed diagnostics: {diagnostic_codes}."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"disabled" => "Workspace Companion auto-start has been removed; create an explicit Worker instead.".to_string(),
|
|
||||||
other => format!(
|
|
||||||
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
WorkspaceExtensionPointState {
|
WorkspaceExtensionPointState {
|
||||||
status: completion,
|
status: extension_status,
|
||||||
note,
|
note,
|
||||||
diagnostics: status.diagnostics.iter().cloned().map(Into::into).collect(),
|
diagnostics: status.diagnostics.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14147,7 +14145,8 @@ fn merge_worker_registry_projection(
|
|||||||
.map(|workdir| {
|
.map(|workdir| {
|
||||||
let mut workdir_summary = workdir_summary_from_record(workdir);
|
let mut workdir_summary = workdir_summary_from_record(workdir);
|
||||||
workdir_summary.occupied_by = Some(WorkingDirectoryOccupancy {
|
workdir_summary.occupied_by = Some(WorkingDirectoryOccupancy {
|
||||||
worker: record.worker.clone(),
|
runtime_id: record.worker.runtime_id.clone(),
|
||||||
|
worker_id: record.worker.worker_id.clone(),
|
||||||
display_name: record.display_name.clone(),
|
display_name: record.display_name.clone(),
|
||||||
linked_at: link.linked_at.clone(),
|
linked_at: link.linked_at.clone(),
|
||||||
});
|
});
|
||||||
@@ -14496,7 +14495,8 @@ fn apply_workdir_occupancy_projection(
|
|||||||
})?;
|
})?;
|
||||||
summary.primary_worker_id = None;
|
summary.primary_worker_id = None;
|
||||||
summary.occupied_by = Some(WorkingDirectoryOccupancy {
|
summary.occupied_by = Some(WorkingDirectoryOccupancy {
|
||||||
worker: link.worker.clone(),
|
runtime_id: link.worker.runtime_id.clone(),
|
||||||
|
worker_id: link.worker.worker_id.clone(),
|
||||||
display_name: worker.display_name,
|
display_name: worker.display_name,
|
||||||
linked_at: link.linked_at.clone(),
|
linked_at: link.linked_at.clone(),
|
||||||
});
|
});
|
||||||
@@ -15933,7 +15933,8 @@ mod tests {
|
|||||||
assert_eq!(working_directory.current_selector, None);
|
assert_eq!(working_directory.current_selector, None);
|
||||||
assert_eq!(working_directory.current_ref.as_deref(), Some("fedcba"));
|
assert_eq!(working_directory.current_ref.as_deref(), Some("fedcba"));
|
||||||
let occupied_by = working_directory.occupied_by.as_ref().unwrap();
|
let occupied_by = working_directory.occupied_by.as_ref().unwrap();
|
||||||
assert_eq!(occupied_by.worker, RuntimeWorkerRef::new("embedded", "1"));
|
assert_eq!(occupied_by.runtime_id, "embedded");
|
||||||
|
assert_eq!(occupied_by.worker_id, "1");
|
||||||
assert!(working_directory.primary_worker_id.is_none());
|
assert!(working_directory.primary_worker_id.is_none());
|
||||||
let occupancy = serde_json::to_value(occupied_by).unwrap();
|
let occupancy = serde_json::to_value(occupied_by).unwrap();
|
||||||
assert_eq!(occupancy["runtime_id"], "embedded");
|
assert_eq!(occupancy["runtime_id"], "embedded");
|
||||||
@@ -17138,10 +17139,8 @@ mod tests {
|
|||||||
.find(|summary| summary.working_directory_id == "managed")
|
.find(|summary| summary.working_directory_id == "managed")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let occupied_by = managed.occupied_by.as_ref().unwrap();
|
let occupied_by = managed.occupied_by.as_ref().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(occupied_by.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
|
||||||
occupied_by.worker,
|
assert_eq!(occupied_by.worker_id, "7");
|
||||||
RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7")
|
|
||||||
);
|
|
||||||
assert_eq!(occupied_by.display_name, "Worker Seven");
|
assert_eq!(occupied_by.display_name, "Worker Seven");
|
||||||
assert_eq!(occupied_by.linked_at, "3");
|
assert_eq!(occupied_by.linked_at, "3");
|
||||||
|
|
||||||
@@ -22991,6 +22990,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_workspace_workdir_create_rejects_stale_json_before_side_effects() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let token = seed_test_api_token(api.store.as_ref(), "stale-workdir-create-json");
|
||||||
|
|
||||||
|
let response = request_json_authenticated(
|
||||||
|
build_router(api.clone()),
|
||||||
|
"POST",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"runtime_id": "missing-runtime",
|
||||||
|
"repository_id": TEST_REPOSITORY_ID,
|
||||||
|
"selector": "HEAD",
|
||||||
|
"operation_id": "stale-workdir-create",
|
||||||
|
"path": "/tmp/legacy-workdir",
|
||||||
|
})),
|
||||||
|
&token,
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("unknown field")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.config_store
|
||||||
|
.load_workdir_create_operation(TEST_WORKSPACE_ID, "stale-workdir-create")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_workspace_workdir_create_rejects_unconfigured_repository() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let token = seed_test_api_token(api.store.as_ref(), "missing-workdir-repository");
|
||||||
|
|
||||||
|
let response = request_json_authenticated(
|
||||||
|
build_router(api.clone()),
|
||||||
|
"POST",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"runtime_id": EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
|
"repository_id": "foreign-or-missing-repository",
|
||||||
|
"selector": "HEAD",
|
||||||
|
"operation_id": "missing-workdir-repository",
|
||||||
|
})),
|
||||||
|
&token,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("unknown local repository")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.config_store
|
||||||
|
.load_workdir_create_operation(TEST_WORKSPACE_ID, "missing-workdir-repository")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
|
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -24151,10 +24221,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
||||||
assert_eq!(companion_status["state"], "disabled");
|
assert_eq!(companion_status["state"], "stopped");
|
||||||
assert!(companion_status["worker"].is_null());
|
assert!(companion_status["worker"].is_null());
|
||||||
assert_eq!(companion_status["transport"]["kind"], "none");
|
assert_eq!(companion_status["transport"]["mode"], "disabled");
|
||||||
assert_eq!(companion_status["transport"]["completion"], "disabled");
|
assert_eq!(companion_status["transport"]["available"], false);
|
||||||
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
||||||
|
|
||||||
let companion_message = post_json(
|
let companion_message = post_json(
|
||||||
@@ -24164,16 +24234,26 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(companion_message["state"], "rejected");
|
assert_eq!(companion_message["state"], "rejected");
|
||||||
assert_eq!(
|
assert!(companion_message.get("accepted").is_none());
|
||||||
companion_message["diagnostics"][0]["code"],
|
assert!(companion_message.get("diagnostics").is_none());
|
||||||
"companion_disabled"
|
assert!(companion_message.get("user_item").is_none());
|
||||||
);
|
assert!(companion_message.get("assistant_item").is_none());
|
||||||
assert!(companion_message["user_item"].is_null());
|
|
||||||
assert!(companion_message["assistant_item"].is_null());
|
|
||||||
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
||||||
|
|
||||||
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||||
assert_eq!(companion_transcript["total_items"], 0);
|
assert_eq!(companion_transcript["total"], 0);
|
||||||
|
let empty_window = get_json(app.clone(), "/api/companion/transcript?start=0&limit=0").await;
|
||||||
|
assert_eq!(
|
||||||
|
empty_window,
|
||||||
|
json!({
|
||||||
|
"state": "stopped",
|
||||||
|
"start": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"total": 0,
|
||||||
|
"next": null,
|
||||||
|
"items": [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -24278,7 +24358,7 @@ mod tests {
|
|||||||
|
|
||||||
let workspace = get_json(app.clone(), "/api/workspace").await;
|
let workspace = get_json(app.clone(), "/api/workspace").await;
|
||||||
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
let workspace_companion = &workspace["extension_points"]["companion_console"];
|
||||||
assert_eq!(workspace_companion["status"], "disabled");
|
assert_eq!(workspace_companion["status"], "stopped");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace_companion["diagnostics"][0]["code"],
|
workspace_companion["diagnostics"][0]["code"],
|
||||||
"companion_disabled"
|
"companion_disabled"
|
||||||
@@ -24287,12 +24367,13 @@ mod tests {
|
|||||||
workspace_companion["note"]
|
workspace_companion["note"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("auto-start has been removed")
|
.contains("typed diagnostics")
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = get_json(app.clone(), "/api/companion/status").await;
|
let status = get_json(app.clone(), "/api/companion/status").await;
|
||||||
assert_eq!(status["state"], "disabled");
|
assert_eq!(status["state"], "stopped");
|
||||||
assert_eq!(status["transport"]["completion"], "disabled");
|
assert_eq!(status["transport"]["mode"], "disabled");
|
||||||
|
assert_eq!(status["transport"]["available"], false);
|
||||||
assert!(status["worker"].is_null());
|
assert!(status["worker"].is_null());
|
||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
@@ -24302,13 +24383,14 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response["state"], "rejected");
|
assert_eq!(response["state"], "rejected");
|
||||||
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
|
assert!(response.get("accepted").is_none());
|
||||||
assert!(response["user_item"].is_null());
|
assert!(response.get("diagnostics").is_none());
|
||||||
assert!(response["assistant_item"].is_null());
|
assert!(response.get("user_item").is_none());
|
||||||
|
assert!(response.get("assistant_item").is_none());
|
||||||
|
|
||||||
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||||
assert_eq!(transcript["state"], "disabled");
|
assert_eq!(transcript["state"], "stopped");
|
||||||
assert_eq!(transcript["total_items"], 0);
|
assert_eq!(transcript["total"], 0);
|
||||||
|
|
||||||
let workers = get_json(app, "/api/workers").await;
|
let workers = get_json(app, "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -25435,7 +25517,8 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
cleanliness: Some("clean".to_string()),
|
cleanliness: Some("clean".to_string()),
|
||||||
primary_worker_id: None,
|
primary_worker_id: None,
|
||||||
occupied_by: Some(WorkingDirectoryOccupancy {
|
occupied_by: Some(WorkingDirectoryOccupancy {
|
||||||
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
|
runtime_id: "arcadia".to_string(),
|
||||||
|
worker_id: "worker-opaque-64".to_string(),
|
||||||
display_name: "Coder".to_string(),
|
display_name: "Coder".to_string(),
|
||||||
linked_at: "2026-08-12T00:00:00Z".to_string(),
|
linked_at: "2026-08-12T00:00:00Z".to_string(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.
|
||||||
|
// Do not edit manually.
|
||||||
|
|
||||||
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export type Diagnostic = {
|
||||||
|
code: string;
|
||||||
|
severity: DiagnosticSeverity;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceWorkerSubject = {
|
||||||
|
"kind": "runtime_worker";
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceWorkerDiscoveryItem = {
|
||||||
|
subject: WorkspaceWorkerSubject;
|
||||||
|
resource_key: string;
|
||||||
|
display_name: string;
|
||||||
|
profile: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionLifecycleState = "idle" | "running" | "stopped";
|
||||||
|
|
||||||
|
export type CompanionMessageDisposition = "accepted" | "rejected";
|
||||||
|
|
||||||
|
export type CompanionTransportSummary = { mode: string; available: boolean };
|
||||||
|
|
||||||
|
export type CompanionStatusResponse = {
|
||||||
|
state: CompanionLifecycleState;
|
||||||
|
worker: WorkspaceWorkerDiscoveryItem | null;
|
||||||
|
transport: CompanionTransportSummary;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionMessageRequest = { content: string };
|
||||||
|
|
||||||
|
export type CompanionCancelRequest = { reason?: string | null };
|
||||||
|
|
||||||
|
export type CompanionMessageResponse = {
|
||||||
|
state: CompanionMessageDisposition;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionTranscriptRole = "user" | "assistant";
|
||||||
|
|
||||||
|
export type CompanionTranscriptItem = {
|
||||||
|
sequence: number;
|
||||||
|
role: CompanionTranscriptRole;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanionTranscriptProjection = {
|
||||||
|
state: CompanionLifecycleState;
|
||||||
|
start: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
next: number | null;
|
||||||
|
items: Array<CompanionTranscriptItem>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Generated from workspace-api. Do not edit by hand.
|
||||||
|
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts
|
||||||
|
|
||||||
|
export type RepositorySshCredential = {
|
||||||
|
credential_id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
name: string;
|
||||||
|
public_key_algorithm: string;
|
||||||
|
public_key_fingerprint: string;
|
||||||
|
current_revision: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
rotated_at: string | null;
|
||||||
|
referenced_repositories: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateRepositorySshCredentialRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
credential_id: string;
|
||||||
|
name: string;
|
||||||
|
private_key: string;
|
||||||
|
passphrase: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RotateRepositorySshCredentialRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
expected_revision: number;
|
||||||
|
private_key: string;
|
||||||
|
passphrase: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteRepositorySshCredentialRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
expected_revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositorySshHostTrust = {
|
||||||
|
host_trust_id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
key_algorithm: string;
|
||||||
|
host_key: string;
|
||||||
|
fingerprint: string;
|
||||||
|
current_revision: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
referenced_repositories: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PutRepositorySshHostTrustRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
host_trust_id: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
host_key: string;
|
||||||
|
expected_revision: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteRepositorySshHostTrustRequest = {
|
||||||
|
operation_id: string;
|
||||||
|
expected_revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositoryAccessMode = "read_only" | "read_write";
|
||||||
|
|
||||||
|
export type RepositorySshAccessBinding = {
|
||||||
|
repository_id: string;
|
||||||
|
credential_id: string;
|
||||||
|
host_trust_id: string;
|
||||||
|
access: RepositoryAccessMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositoryAccessProjection = {
|
||||||
|
workspace_id: string;
|
||||||
|
config_revision: number;
|
||||||
|
projection_digest: string;
|
||||||
|
bindings: Array<RepositorySshAccessBinding>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Generated from workspace-api. Do not edit by hand.
|
||||||
|
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts
|
||||||
|
|
||||||
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree";
|
||||||
|
|
||||||
|
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||||
|
|
||||||
|
export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
||||||
|
|
||||||
|
export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||||
|
|
||||||
|
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||||
@@ -113,6 +113,7 @@ export async function loadJson<T>(
|
|||||||
fetchFn: typeof fetch,
|
fetchFn: typeof fetch,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
|
parse: (value: unknown) => T = (value) => value as T,
|
||||||
): Promise<ApiResult<T>> {
|
): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchFn(path, init);
|
const response = await fetchFn(path, init);
|
||||||
@@ -123,7 +124,8 @@ export async function loadJson<T>(
|
|||||||
error: text || `${path} request failed (${response.status})`,
|
error: text || `${path} request failed (${response.status})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { data: (await response.json()) as T, error: null };
|
const payload: unknown = await response.json();
|
||||||
|
return { data: parse(payload), error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { RepositoryAccessSchemaError } from "./repository-access.ts";
|
||||||
|
|
||||||
|
export async function loadRepositoryAccessJson<T>(
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
path: string,
|
||||||
|
parse: (value: unknown) => T,
|
||||||
|
): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(path, { headers: { accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
error(503, { message: "Repository Access is temporarily unavailable." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
error(403, {
|
||||||
|
message: "Repository Access is unavailable for this account.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
error(502, {
|
||||||
|
message:
|
||||||
|
`Repository Access request failed with status ${response.status}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
error(502, {
|
||||||
|
message: "Repository Access returned an invalid JSON response.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parse(payload);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof RepositoryAccessSchemaError) {
|
||||||
|
error(502, { message: cause.message });
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type {
|
||||||
|
RepositoryAccessProjection,
|
||||||
|
RepositorySshCredential,
|
||||||
|
RepositorySshHostTrust,
|
||||||
|
} from "../../generated/repository-access-api.ts";
|
||||||
|
|
||||||
|
export class RepositoryAccessSchemaError extends Error {
|
||||||
|
constructor(path: string, expected: string) {
|
||||||
|
super(
|
||||||
|
`Repository Access response schema mismatch at ${path}: expected ${expected}`,
|
||||||
|
);
|
||||||
|
this.name = "RepositoryAccessSchemaError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshCredentials(
|
||||||
|
value: unknown,
|
||||||
|
): RepositorySshCredential[] {
|
||||||
|
return readArray(value, "credentials").map((entry, index) =>
|
||||||
|
parseRepositorySshCredential(entry, `credentials[${index}]`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshCredential(
|
||||||
|
value: unknown,
|
||||||
|
path = "credential",
|
||||||
|
): RepositorySshCredential {
|
||||||
|
const record = readRecord(value, path, [
|
||||||
|
"credential_id",
|
||||||
|
"workspace_id",
|
||||||
|
"name",
|
||||||
|
"public_key_algorithm",
|
||||||
|
"public_key_fingerprint",
|
||||||
|
"current_revision",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"rotated_at",
|
||||||
|
"referenced_repositories",
|
||||||
|
]);
|
||||||
|
readString(record, "credential_id", path);
|
||||||
|
readString(record, "workspace_id", path);
|
||||||
|
readString(record, "name", path);
|
||||||
|
readString(record, "public_key_algorithm", path);
|
||||||
|
readString(record, "public_key_fingerprint", path);
|
||||||
|
readRevision(record, "current_revision", path);
|
||||||
|
readString(record, "status", path);
|
||||||
|
readString(record, "created_at", path);
|
||||||
|
readNullableString(record, "rotated_at", path);
|
||||||
|
readStringArray(record, "referenced_repositories", path);
|
||||||
|
return record as RepositorySshCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshHostTrusts(
|
||||||
|
value: unknown,
|
||||||
|
): RepositorySshHostTrust[] {
|
||||||
|
return readArray(value, "host_trusts").map((entry, index) =>
|
||||||
|
parseRepositorySshHostTrust(entry, `host_trusts[${index}]`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositorySshHostTrust(
|
||||||
|
value: unknown,
|
||||||
|
path = "host_trust",
|
||||||
|
): RepositorySshHostTrust {
|
||||||
|
const record = readRecord(value, path, [
|
||||||
|
"host_trust_id",
|
||||||
|
"workspace_id",
|
||||||
|
"hostname",
|
||||||
|
"port",
|
||||||
|
"key_algorithm",
|
||||||
|
"host_key",
|
||||||
|
"fingerprint",
|
||||||
|
"current_revision",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"referenced_repositories",
|
||||||
|
]);
|
||||||
|
readString(record, "host_trust_id", path);
|
||||||
|
readString(record, "workspace_id", path);
|
||||||
|
readString(record, "hostname", path);
|
||||||
|
const port = readInteger(record, "port", path);
|
||||||
|
if (port < 1 || port > 65_535) {
|
||||||
|
throw new RepositoryAccessSchemaError(
|
||||||
|
`${path}.port`,
|
||||||
|
"an integer from 1 to 65535",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
readString(record, "key_algorithm", path);
|
||||||
|
readString(record, "host_key", path);
|
||||||
|
readString(record, "fingerprint", path);
|
||||||
|
readRevision(record, "current_revision", path);
|
||||||
|
readString(record, "created_at", path);
|
||||||
|
readString(record, "updated_at", path);
|
||||||
|
readStringArray(record, "referenced_repositories", path);
|
||||||
|
return record as RepositorySshHostTrust;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositoryAccessProjection(
|
||||||
|
value: unknown,
|
||||||
|
): RepositoryAccessProjection {
|
||||||
|
const path = "access_projection";
|
||||||
|
const record = readRecord(value, path, [
|
||||||
|
"workspace_id",
|
||||||
|
"config_revision",
|
||||||
|
"projection_digest",
|
||||||
|
"bindings",
|
||||||
|
]);
|
||||||
|
readString(record, "workspace_id", path);
|
||||||
|
readRevision(record, "config_revision", path);
|
||||||
|
readString(record, "projection_digest", path);
|
||||||
|
const bindings = readArray(record.bindings, `${path}.bindings`);
|
||||||
|
bindings.forEach((binding, index) => {
|
||||||
|
const bindingPath = `${path}.bindings[${index}]`;
|
||||||
|
const bindingRecord = readRecord(binding, bindingPath, [
|
||||||
|
"repository_id",
|
||||||
|
"credential_id",
|
||||||
|
"host_trust_id",
|
||||||
|
"access",
|
||||||
|
]);
|
||||||
|
readString(bindingRecord, "repository_id", bindingPath);
|
||||||
|
readString(bindingRecord, "credential_id", bindingPath);
|
||||||
|
readString(bindingRecord, "host_trust_id", bindingPath);
|
||||||
|
const access = readString(bindingRecord, "access", bindingPath);
|
||||||
|
if (access !== "read_only" && access !== "read_write") {
|
||||||
|
throw new RepositoryAccessSchemaError(
|
||||||
|
`${bindingPath}.access`,
|
||||||
|
'"read_only" or "read_write"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return record as RepositoryAccessProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecord(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
allowedKeys: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new RepositoryAccessSchemaError(path, "an object");
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const unknownKey = Object.keys(record).find((key) =>
|
||||||
|
!allowedKeys.includes(key)
|
||||||
|
);
|
||||||
|
if (unknownKey !== undefined) {
|
||||||
|
throw new RepositoryAccessSchemaError(
|
||||||
|
`${path}.${unknownKey}`,
|
||||||
|
"no unknown field",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArray(value: unknown, path: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new RepositoryAccessSchemaError(path, "an array");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
): string {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNullableString(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
const value = record[key];
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
): string[] {
|
||||||
|
const values = readArray(record[key], `${path}.${key}`);
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new RepositoryAccessSchemaError(
|
||||||
|
`${path}.${key}[${index}]`,
|
||||||
|
"a string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInteger(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
): number {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
||||||
|
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a safe integer");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRevision(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
): number {
|
||||||
|
const revision = readInteger(record, key, path);
|
||||||
|
if (revision < 0) {
|
||||||
|
throw new RepositoryAccessSchemaError(
|
||||||
|
`${path}.${key}`,
|
||||||
|
"a non-negative safe integer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type {
|
||||||
|
Diagnostic,
|
||||||
|
WorkingDirectoryCleanupTarget,
|
||||||
|
WorkingDirectoryCreateRequest,
|
||||||
|
WorkingDirectoryCreateResponse,
|
||||||
|
WorkingDirectoryDetailResponse,
|
||||||
|
WorkingDirectoryListResponse,
|
||||||
|
WorkingDirectoryOccupancy,
|
||||||
|
WorkingDirectorySummary,
|
||||||
|
} from "../../generated/workdir-api";
|
||||||
|
|
||||||
|
const SUMMARY_KEYS = new Set([
|
||||||
|
"working_directory_id",
|
||||||
|
"repository_id",
|
||||||
|
"creation_selector",
|
||||||
|
"creation_ref",
|
||||||
|
"creation_tree",
|
||||||
|
"current_selector",
|
||||||
|
"current_ref",
|
||||||
|
"current_tree",
|
||||||
|
"observed_at_epoch_seconds",
|
||||||
|
"materializer_kind",
|
||||||
|
"cleanup_target",
|
||||||
|
"status",
|
||||||
|
"cleanliness",
|
||||||
|
"primary_worker_id",
|
||||||
|
"occupied_by",
|
||||||
|
]);
|
||||||
|
const CREATE_REQUEST_KEYS = new Set([
|
||||||
|
"runtime_id",
|
||||||
|
"repository_id",
|
||||||
|
"selector",
|
||||||
|
"operation_id",
|
||||||
|
]);
|
||||||
|
const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]);
|
||||||
|
const CLEANUP_TARGET_KEYS = new Set([
|
||||||
|
"kind",
|
||||||
|
"working_directory_id",
|
||||||
|
"repository_id",
|
||||||
|
]);
|
||||||
|
const OCCUPANCY_KEYS = new Set([
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"display_name",
|
||||||
|
"linked_at",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseWorkingDirectoryListResponse(
|
||||||
|
value: unknown,
|
||||||
|
): WorkingDirectoryListResponse {
|
||||||
|
const record = exactRecord(
|
||||||
|
value,
|
||||||
|
new Set(["workspace_id", "items", "diagnostics"]),
|
||||||
|
"Workdir list response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
|
items: arrayField(record, "items").map(parseSummary),
|
||||||
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkingDirectoryDetailResponse(
|
||||||
|
value: unknown,
|
||||||
|
): WorkingDirectoryDetailResponse {
|
||||||
|
return parseDetailLike(value, "Workdir detail response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkingDirectoryCreateResponse(
|
||||||
|
value: unknown,
|
||||||
|
): WorkingDirectoryCreateResponse {
|
||||||
|
return parseDetailLike(value, "Workdir create response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWorkingDirectoryCreateRequest(
|
||||||
|
value: unknown,
|
||||||
|
): WorkingDirectoryCreateRequest {
|
||||||
|
const record = exactRecord(
|
||||||
|
value,
|
||||||
|
CREATE_REQUEST_KEYS,
|
||||||
|
"Workdir create request",
|
||||||
|
);
|
||||||
|
const request: WorkingDirectoryCreateRequest = {
|
||||||
|
repository_id: stringField(record, "repository_id"),
|
||||||
|
};
|
||||||
|
assignOptionalString(request, record, "runtime_id");
|
||||||
|
assignOptionalString(request, record, "selector");
|
||||||
|
assignOptionalString(request, record, "operation_id");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetailLike(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkingDirectoryDetailResponse {
|
||||||
|
const record = exactRecord(
|
||||||
|
value,
|
||||||
|
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
|
runtime_id: stringField(record, "runtime_id"),
|
||||||
|
item: parseSummary(record.item),
|
||||||
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummary(value: unknown): WorkingDirectorySummary {
|
||||||
|
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
||||||
|
const summary: WorkingDirectorySummary = {
|
||||||
|
working_directory_id: stringField(record, "working_directory_id"),
|
||||||
|
repository_id: stringField(record, "repository_id"),
|
||||||
|
materializer_kind: enumField(record, "materializer_kind", [
|
||||||
|
"runtime_git_cache",
|
||||||
|
"local_git_worktree",
|
||||||
|
]),
|
||||||
|
status: enumField(record, "status", [
|
||||||
|
"active",
|
||||||
|
"cleanup_pending",
|
||||||
|
"corrupted",
|
||||||
|
"not_found",
|
||||||
|
"unknown",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
assignOptionalString(summary, record, "creation_selector");
|
||||||
|
assignOptionalString(summary, record, "creation_ref");
|
||||||
|
assignOptionalString(summary, record, "creation_tree");
|
||||||
|
assignOptionalString(summary, record, "current_selector");
|
||||||
|
assignOptionalString(summary, record, "current_ref");
|
||||||
|
assignOptionalString(summary, record, "current_tree");
|
||||||
|
assignOptionalString(summary, record, "cleanliness");
|
||||||
|
assignOptionalString(summary, record, "primary_worker_id");
|
||||||
|
if (record.observed_at_epoch_seconds !== undefined) {
|
||||||
|
const observedAt = record.observed_at_epoch_seconds;
|
||||||
|
if (observedAt === null) {
|
||||||
|
summary.observed_at_epoch_seconds = null;
|
||||||
|
} else {
|
||||||
|
if (!Number.isSafeInteger(observedAt) || Number(observedAt) < 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Workdir summary.observed_at_epoch_seconds must be a non-negative safe integer or null",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
summary.observed_at_epoch_seconds = Number(observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (record.cleanup_target !== undefined) {
|
||||||
|
summary.cleanup_target = record.cleanup_target === null
|
||||||
|
? null
|
||||||
|
: parseCleanupTarget(record.cleanup_target);
|
||||||
|
}
|
||||||
|
if (record.occupied_by !== undefined) {
|
||||||
|
summary.occupied_by = record.occupied_by === null
|
||||||
|
? null
|
||||||
|
: parseOccupancy(record.occupied_by);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
||||||
|
const record = exactRecord(
|
||||||
|
value,
|
||||||
|
CLEANUP_TARGET_KEYS,
|
||||||
|
"Workdir cleanup target",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: stringField(record, "kind"),
|
||||||
|
working_directory_id: stringField(record, "working_directory_id"),
|
||||||
|
repository_id: stringField(record, "repository_id"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOccupancy(value: unknown): WorkingDirectoryOccupancy {
|
||||||
|
const record = exactRecord(value, OCCUPANCY_KEYS, "Workdir occupancy");
|
||||||
|
return {
|
||||||
|
runtime_id: stringField(record, "runtime_id"),
|
||||||
|
worker_id: stringField(record, "worker_id"),
|
||||||
|
display_name: stringField(record, "display_name"),
|
||||||
|
linked_at: stringField(record, "linked_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiagnostic(value: unknown): Diagnostic {
|
||||||
|
const record = exactRecord(value, DIAGNOSTIC_KEYS, "Workdir diagnostic");
|
||||||
|
return {
|
||||||
|
code: stringField(record, "code"),
|
||||||
|
severity: enumField(record, "severity", ["info", "warning", "error"]),
|
||||||
|
message: stringField(record, "message"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: ReadonlySet<string>,
|
||||||
|
label: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!keys.has(key)) {
|
||||||
|
throw new Error(`${label} contains unknown field ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string): string {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`${key} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayField(record: Record<string, unknown>, key: string): unknown[] {
|
||||||
|
const value = record[key];
|
||||||
|
if (!Array.isArray(value)) throw new Error(`${key} must be an array`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumField<T extends string>(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
values: readonly T[],
|
||||||
|
): T {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||||
|
throw new Error(`${key} has an unsupported value`);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignOptionalString<T extends object>(
|
||||||
|
target: T,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): void {
|
||||||
|
const value = source[key];
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (value === null) {
|
||||||
|
(target as Record<string, unknown>)[key] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`${key} must be a non-empty string or null`);
|
||||||
|
}
|
||||||
|
(target as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
parseCompanionMessageResponse,
|
||||||
|
parseCompanionStatusResponse,
|
||||||
|
parseCompanionTranscriptProjection,
|
||||||
|
} from "./api.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(fn: () => unknown, message: string): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
subject: {
|
||||||
|
kind: "runtime_worker",
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
worker_id: "worker-7",
|
||||||
|
},
|
||||||
|
resource_key: "W-7",
|
||||||
|
display_name: "Companion",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
status: "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Companion status boundary accepts every public lifecycle state", () => {
|
||||||
|
for (const state of ["idle", "running", "stopped"] as const) {
|
||||||
|
const parsed = parseCompanionStatusResponse({
|
||||||
|
state,
|
||||||
|
worker,
|
||||||
|
transport: {
|
||||||
|
mode: "worker_runtime",
|
||||||
|
available: state !== "stopped",
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
assertEquals(parsed.state, state);
|
||||||
|
assertEquals(parsed.worker?.subject, worker.subject);
|
||||||
|
assertEquals(parsed.worker?.resource_key, "W-7");
|
||||||
|
assertEquals(parsed.worker?.display_name, "Companion");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion message boundary accepts accepted and rejected fixtures", () => {
|
||||||
|
assertEquals(
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "accepted",
|
||||||
|
message: "accepted",
|
||||||
|
}),
|
||||||
|
{ state: "accepted", message: "accepted" },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "rejected",
|
||||||
|
message: "rejected",
|
||||||
|
}),
|
||||||
|
{ state: "rejected", message: "rejected" },
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionMessageResponse({
|
||||||
|
state: "accepted",
|
||||||
|
message: "accepted",
|
||||||
|
provider_request_id: "private-request",
|
||||||
|
}),
|
||||||
|
"private message response fields should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion transcript boundary accepts only bounded user-visible items", () => {
|
||||||
|
const fixture = {
|
||||||
|
state: "idle" as const,
|
||||||
|
start: 0,
|
||||||
|
limit: 2,
|
||||||
|
total: 2,
|
||||||
|
next: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
sequence: 1,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "hello",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequence: 2,
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "hi",
|
||||||
|
created_at: "2026-08-31T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assertEquals(parseCompanionTranscriptProjection(fixture), fixture);
|
||||||
|
assertEquals(
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
state: "stopped",
|
||||||
|
start: 0,
|
||||||
|
limit: 0,
|
||||||
|
total: 0,
|
||||||
|
next: null,
|
||||||
|
items: [],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
state: "stopped",
|
||||||
|
start: 0,
|
||||||
|
limit: 0,
|
||||||
|
total: 0,
|
||||||
|
next: null,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...fixture,
|
||||||
|
items: [...fixture.items, fixture.items[0]],
|
||||||
|
}),
|
||||||
|
"items beyond the declared limit should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion transcript boundary rejects system and private fields", () => {
|
||||||
|
const base = {
|
||||||
|
state: "idle",
|
||||||
|
start: 0,
|
||||||
|
limit: 1,
|
||||||
|
total: 1,
|
||||||
|
next: null,
|
||||||
|
};
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...base,
|
||||||
|
items: [{
|
||||||
|
sequence: 1,
|
||||||
|
role: "system",
|
||||||
|
content: "raw system prompt",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"system transcript content should be rejected",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionTranscriptProjection({
|
||||||
|
...base,
|
||||||
|
items: [{
|
||||||
|
sequence: 1,
|
||||||
|
role: "assistant",
|
||||||
|
content: "visible",
|
||||||
|
created_at: "2026-08-31T00:00:00Z",
|
||||||
|
reasoning: "hidden",
|
||||||
|
credential: "secret",
|
||||||
|
provider_session_id: "private-session",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"private transcript fields should be rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Companion status boundary does not use display_name as Worker identity", () => {
|
||||||
|
const fixture = {
|
||||||
|
state: "idle",
|
||||||
|
worker: { ...worker, display_name: "W-999" },
|
||||||
|
transport: { mode: "worker_runtime", available: true },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
const parsed = parseCompanionStatusResponse(fixture);
|
||||||
|
assertEquals(parsed.worker?.subject, worker.subject);
|
||||||
|
assertEquals(parsed.worker?.resource_key, "W-7");
|
||||||
|
assertEquals(parsed.worker?.display_name, "W-999");
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCompanionStatusResponse({
|
||||||
|
...fixture,
|
||||||
|
worker: { ...worker, resource_key: "Companion" },
|
||||||
|
}),
|
||||||
|
"display names must not substitute for canonical Worker resource keys",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type {
|
||||||
|
CompanionLifecycleState,
|
||||||
|
CompanionMessageDisposition,
|
||||||
|
CompanionMessageResponse,
|
||||||
|
CompanionStatusResponse,
|
||||||
|
CompanionTranscriptItem,
|
||||||
|
CompanionTranscriptProjection,
|
||||||
|
Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
WorkspaceWorkerDiscoveryItem,
|
||||||
|
WorkspaceWorkerSubject,
|
||||||
|
} from "$lib/generated/companion-api";
|
||||||
|
|
||||||
|
const MAX_TRANSCRIPT_ITEMS = 200;
|
||||||
|
const MAX_DIAGNOSTICS = 100;
|
||||||
|
const MAX_CONTENT_LENGTH = 64 * 1024;
|
||||||
|
|
||||||
|
export function parseCompanionStatusResponse(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionStatusResponse {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"state",
|
||||||
|
"worker",
|
||||||
|
"transport",
|
||||||
|
"diagnostics",
|
||||||
|
]);
|
||||||
|
const transport = strictRecord(record.transport, ["mode", "available"]);
|
||||||
|
const diagnostics = boundedArray(record.diagnostics, MAX_DIAGNOSTICS).map(
|
||||||
|
parseDiagnostic,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: lifecycleState(record.state),
|
||||||
|
worker: record.worker === null ? null : parseWorker(record.worker),
|
||||||
|
transport: {
|
||||||
|
mode: boundedString(transport.mode, 100),
|
||||||
|
available: booleanValue(transport.available),
|
||||||
|
},
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCompanionMessageResponse(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionMessageResponse {
|
||||||
|
const record = strictRecord(value, ["state", "message"]);
|
||||||
|
return {
|
||||||
|
state: messageDisposition(record.state),
|
||||||
|
message: boundedString(record.message, 8 * 1024),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCompanionTranscriptProjection(
|
||||||
|
value: unknown,
|
||||||
|
): CompanionTranscriptProjection {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"state",
|
||||||
|
"start",
|
||||||
|
"limit",
|
||||||
|
"total",
|
||||||
|
"next",
|
||||||
|
"items",
|
||||||
|
]);
|
||||||
|
const start = boundedInteger(record.start);
|
||||||
|
const limit = boundedInteger(record.limit);
|
||||||
|
if (limit > MAX_TRANSCRIPT_ITEMS) {
|
||||||
|
throw new TypeError("Companion transcript limit is out of range");
|
||||||
|
}
|
||||||
|
const items = boundedArray(record.items, limit).map(parseTranscriptItem);
|
||||||
|
const total = boundedInteger(record.total);
|
||||||
|
if (total < items.length) {
|
||||||
|
throw new TypeError("Companion transcript total is smaller than its items");
|
||||||
|
}
|
||||||
|
const next = record.next === null ? null : boundedInteger(record.next);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: lifecycleState(record.state),
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
next,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTranscriptItem(value: unknown): CompanionTranscriptItem {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"sequence",
|
||||||
|
"role",
|
||||||
|
"content",
|
||||||
|
"created_at",
|
||||||
|
]);
|
||||||
|
const role = record.role;
|
||||||
|
if (role !== "user" && role !== "assistant") {
|
||||||
|
throw new TypeError("Companion transcript role is not user-visible");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sequence: boundedInteger(record.sequence),
|
||||||
|
role,
|
||||||
|
content: boundedString(record.content, MAX_CONTENT_LENGTH),
|
||||||
|
created_at: boundedString(record.created_at, 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorker(value: unknown): WorkspaceWorkerDiscoveryItem {
|
||||||
|
const record = strictRecord(value, [
|
||||||
|
"subject",
|
||||||
|
"resource_key",
|
||||||
|
"display_name",
|
||||||
|
"profile",
|
||||||
|
"status",
|
||||||
|
], ["status"]);
|
||||||
|
const subject = parseWorkerSubject(record.subject);
|
||||||
|
const resourceKey = boundedString(record.resource_key, 100);
|
||||||
|
if (!/^W-[1-9][0-9]*$/.test(resourceKey)) {
|
||||||
|
throw new TypeError("Companion worker resource_key is not canonical");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
resource_key: resourceKey,
|
||||||
|
display_name: boundedString(record.display_name, 256),
|
||||||
|
profile: nullableString(record.profile, 256),
|
||||||
|
...(record.status === undefined
|
||||||
|
? {}
|
||||||
|
: { status: nullableString(record.status, 100) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkerSubject(value: unknown): WorkspaceWorkerSubject {
|
||||||
|
const record = strictRecord(value, ["kind", "runtime_id", "worker_id"]);
|
||||||
|
if (record.kind !== "runtime_worker") {
|
||||||
|
throw new TypeError("Companion worker subject kind is invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "runtime_worker",
|
||||||
|
runtime_id: boundedString(record.runtime_id, 256),
|
||||||
|
worker_id: boundedString(record.worker_id, 256),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiagnostic(value: unknown): Diagnostic {
|
||||||
|
const record = strictRecord(value, ["code", "severity", "message"]);
|
||||||
|
return {
|
||||||
|
code: boundedString(record.code, 256),
|
||||||
|
severity: diagnosticSeverity(record.severity),
|
||||||
|
message: boundedString(record.message, 4 * 1024),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleState(value: unknown): CompanionLifecycleState {
|
||||||
|
if (value !== "idle" && value !== "running" && value !== "stopped") {
|
||||||
|
throw new TypeError("Companion lifecycle state is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageDisposition(value: unknown): CompanionMessageDisposition {
|
||||||
|
if (value !== "accepted" && value !== "rejected") {
|
||||||
|
throw new TypeError("Companion message disposition is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticSeverity(value: unknown): DiagnosticSeverity {
|
||||||
|
if (value !== "info" && value !== "warning" && value !== "error") {
|
||||||
|
throw new TypeError("Companion diagnostic severity is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
optionalKeys: readonly string[] = [],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new TypeError("Companion API value is not an object");
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const allowed = new Set(keys);
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!allowed.has(key)) {
|
||||||
|
throw new TypeError(`Companion API field is not public: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const optional = new Set(optionalKeys);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!optional.has(key) && !(key in record)) {
|
||||||
|
throw new TypeError(`Companion API field is missing: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedArray(value: unknown, limit: number): unknown[] {
|
||||||
|
if (!Array.isArray(value) || value.length > limit) {
|
||||||
|
throw new TypeError("Companion API array is invalid or exceeds its limit");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value: unknown, limit: number): string {
|
||||||
|
if (typeof value !== "string" || value.length > limit) {
|
||||||
|
throw new TypeError("Companion API string is invalid or exceeds its limit");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown, limit: number): string | null {
|
||||||
|
return value === null ? null : boundedString(value, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new TypeError("Companion API value is not a boolean");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedInteger(value: unknown): number {
|
||||||
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||||
|
throw new TypeError("Companion API value is not a non-negative integer");
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import type {
|
||||||
|
WorkingDirectoryCreateRequest,
|
||||||
|
WorkingDirectoryCreateResponse,
|
||||||
|
WorkingDirectoryDetailResponse,
|
||||||
|
WorkingDirectoryListResponse,
|
||||||
|
WorkingDirectoryOccupancy,
|
||||||
|
WorkingDirectorySummary,
|
||||||
|
} from "$lib/generated/workdir-api";
|
||||||
import type {
|
import type {
|
||||||
Event as PodProtocolEvent,
|
Event as PodProtocolEvent,
|
||||||
Method as PodProtocolMethod,
|
Method as PodProtocolMethod,
|
||||||
@@ -14,7 +22,17 @@ import type {
|
|||||||
WorkspaceResponse as SharedWorkspaceResponse,
|
WorkspaceResponse as SharedWorkspaceResponse,
|
||||||
} from "$lib/workspace/api/workspace-model";
|
} from "$lib/workspace/api/workspace-model";
|
||||||
|
|
||||||
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
|
export type {
|
||||||
|
PodProtocolEvent,
|
||||||
|
PodProtocolMethod,
|
||||||
|
PodProtocolSegment,
|
||||||
|
WorkingDirectoryCreateRequest,
|
||||||
|
WorkingDirectoryCreateResponse,
|
||||||
|
WorkingDirectoryDetailResponse,
|
||||||
|
WorkingDirectoryListResponse,
|
||||||
|
WorkingDirectoryOccupancy,
|
||||||
|
WorkingDirectorySummary,
|
||||||
|
};
|
||||||
export type WorkspaceResponse = SharedWorkspaceResponse;
|
export type WorkspaceResponse = SharedWorkspaceResponse;
|
||||||
|
|
||||||
export type Diagnostic = {
|
export type Diagnostic = {
|
||||||
@@ -105,44 +123,6 @@ export type WorkingDirectoryRepositoryOption = {
|
|||||||
default_selector?: string | null;
|
default_selector?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkingDirectoryOccupancy = {
|
|
||||||
runtime_id: string;
|
|
||||||
worker_id: string;
|
|
||||||
display_name: string;
|
|
||||||
linked_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkingDirectorySummary = {
|
|
||||||
working_directory_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
creation_selector?: string | null;
|
|
||||||
creation_ref?: string | null;
|
|
||||||
current_selector?: string | null;
|
|
||||||
current_ref?: string | null;
|
|
||||||
materializer_kind: string;
|
|
||||||
status: string;
|
|
||||||
cleanliness?: string | null;
|
|
||||||
primary_worker_id?: string | null;
|
|
||||||
occupied_by?: WorkingDirectoryOccupancy | null;
|
|
||||||
cleanup_target: {
|
|
||||||
kind: string;
|
|
||||||
working_directory_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserWorkingDirectoryCreateResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
item: WorkingDirectorySummary;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserWorkingDirectoryListResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
items: WorkingDirectorySummary[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CleanupTargetKind =
|
export type CleanupTargetKind =
|
||||||
| "worker_delete"
|
| "worker_delete"
|
||||||
| "workdir_clean_cleanup"
|
| "workdir_clean_cleanup"
|
||||||
@@ -211,12 +191,6 @@ export type BrowserWorkerWorkingDirectorySelection = {
|
|||||||
relative_cwd?: string | null;
|
relative_cwd?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserWorkingDirectoryCreateRequest = {
|
|
||||||
runtime_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
selector?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkerLaunchOptionsResponse = {
|
export type WorkerLaunchOptionsResponse = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
runtimes: WorkerLaunchRuntimeOption[];
|
runtimes: WorkerLaunchRuntimeOption[];
|
||||||
@@ -385,56 +359,15 @@ export type ObjectiveListResponse = {
|
|||||||
record_authority: string;
|
record_authority: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanionState =
|
export type {
|
||||||
| "ready"
|
CompanionCancelRequest,
|
||||||
| "busy"
|
CompanionLifecycleState,
|
||||||
| "error"
|
CompanionMessageDisposition,
|
||||||
| "timeout"
|
CompanionMessageRequest,
|
||||||
| "cancelled"
|
CompanionMessageResponse,
|
||||||
| "accepted"
|
CompanionStatusResponse,
|
||||||
| "rejected";
|
CompanionTranscriptItem,
|
||||||
|
CompanionTranscriptProjection,
|
||||||
export type CompanionTransportSummary = {
|
CompanionTranscriptRole,
|
||||||
kind: string;
|
CompanionTransportSummary,
|
||||||
completion: string;
|
} from "$lib/generated/companion-api";
|
||||||
limitation: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionStatusResponse = {
|
|
||||||
state: CompanionState;
|
|
||||||
worker?: Worker | null;
|
|
||||||
transport: CompanionTransportSummary;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionTranscriptItem = {
|
|
||||||
sequence: number;
|
|
||||||
role: "user" | "assistant" | "system" | string;
|
|
||||||
content: string;
|
|
||||||
created_at: string;
|
|
||||||
source: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionTranscriptProjection = {
|
|
||||||
state: CompanionState;
|
|
||||||
start: number;
|
|
||||||
limit: number;
|
|
||||||
total_items: number;
|
|
||||||
next_start?: number | null;
|
|
||||||
items: CompanionTranscriptItem[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionMessageRequest = {
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CompanionMessageResponse = {
|
|
||||||
state: CompanionState;
|
|
||||||
worker?: Worker | null;
|
|
||||||
user_item?: CompanionTranscriptItem | null;
|
|
||||||
assistant_item?: CompanionTranscriptItem | null;
|
|
||||||
transcript: CompanionTranscriptProjection;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import type {
|
||||||
|
CreateRepositorySshCredentialRequest,
|
||||||
|
DeleteRepositorySshCredentialRequest,
|
||||||
|
DeleteRepositorySshHostTrustRequest,
|
||||||
|
PutRepositorySshHostTrustRequest,
|
||||||
|
RepositorySshCredential,
|
||||||
|
RepositorySshHostTrust,
|
||||||
|
RotateRepositorySshCredentialRequest,
|
||||||
|
} from '$lib/generated/repository-access-api';
|
||||||
|
import {
|
||||||
|
parseRepositorySshCredential,
|
||||||
|
parseRepositorySshHostTrust,
|
||||||
|
} from '$lib/workspace/api/repository-access';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||||
|
const accessProjection = untrack(() => data.accessProjection);
|
||||||
let message = $state<string | null>(null);
|
let message = $state<string | null>(null);
|
||||||
let pending = $state(false);
|
let pending = $state(false);
|
||||||
|
|
||||||
@@ -29,37 +42,52 @@
|
|||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
|
function request<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body: unknown,
|
||||||
|
parse: (value: unknown) => T
|
||||||
|
): Promise<T>;
|
||||||
|
function request(path: string, method: string, body: unknown, parse: null): Promise<void>;
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body: unknown,
|
||||||
|
parse: ((value: unknown) => T) | null
|
||||||
|
): Promise<T | undefined> {
|
||||||
const response = await fetch(`${base}${path}`, {
|
const response = await fetch(`${base}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = `request failed (${response.status})`;
|
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||||
try {
|
|
||||||
const payload = (await response.json()) as { error?: string; message?: string };
|
|
||||||
detail = payload.message ?? payload.error ?? detail;
|
|
||||||
} catch {
|
|
||||||
// Do not surface submitted secret values from response bodies.
|
|
||||||
}
|
}
|
||||||
throw new Error(detail);
|
if (response.status === 204) return undefined;
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (parse === null) {
|
||||||
|
throw new Error('Repository Access returned an unexpected response body.');
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
return parse(payload);
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCredential() {
|
async function createCredential() {
|
||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
const body: CreateRepositorySshCredentialRequest = {
|
||||||
operation_id: operationId('credential-create'),
|
operation_id: operationId('credential-create'),
|
||||||
credential_id: credentialId,
|
credential_id: credentialId,
|
||||||
name: credentialName,
|
name: credentialName,
|
||||||
private_key: privateKey,
|
private_key: privateKey,
|
||||||
passphrase: passphrase || null
|
passphrase: passphrase || null
|
||||||
});
|
};
|
||||||
|
const created = await request<RepositorySshCredential>(
|
||||||
|
'/credentials',
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshCredential
|
||||||
|
);
|
||||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||||
credentialId = '';
|
credentialId = '';
|
||||||
credentialName = '';
|
credentialName = '';
|
||||||
@@ -77,15 +105,17 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const rotated = await request<RepositorySshCredential>(
|
const body: RotateRepositorySshCredentialRequest = {
|
||||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
operation_id: operationId('credential-rotate'),
|
operation_id: operationId('credential-rotate'),
|
||||||
expected_revision: credential.current_revision,
|
expected_revision: credential.current_revision,
|
||||||
private_key: rotatePrivateKey,
|
private_key: rotatePrivateKey,
|
||||||
passphrase: rotatePassphrase || null
|
passphrase: rotatePassphrase || null
|
||||||
}
|
};
|
||||||
|
const rotated = await request<RepositorySshCredential>(
|
||||||
|
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshCredential
|
||||||
);
|
);
|
||||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||||
rotateCredentialId = null;
|
rotateCredentialId = null;
|
||||||
@@ -104,10 +134,16 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
const body: DeleteRepositorySshCredentialRequest = {
|
||||||
operation_id: operationId('credential-delete'),
|
operation_id: operationId('credential-delete'),
|
||||||
expected_revision: credential.current_revision
|
expected_revision: credential.current_revision
|
||||||
});
|
};
|
||||||
|
await request(
|
||||||
|
`/credentials/${encodeURIComponent(credential.credential_id)}`,
|
||||||
|
'DELETE',
|
||||||
|
body,
|
||||||
|
null
|
||||||
|
);
|
||||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||||
message = `Credential ${credential.credential_id} deleted.`;
|
message = `Credential ${credential.credential_id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,14 +157,20 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
const body: PutRepositorySshHostTrustRequest = {
|
||||||
operation_id: operationId('host-trust-create'),
|
operation_id: operationId('host-trust-create'),
|
||||||
host_trust_id: hostTrustId,
|
host_trust_id: hostTrustId,
|
||||||
hostname,
|
hostname,
|
||||||
port,
|
port,
|
||||||
host_key: hostKey,
|
host_key: hostKey,
|
||||||
expected_revision: hostExpectedRevision
|
expected_revision: hostExpectedRevision
|
||||||
});
|
};
|
||||||
|
const created = await request<RepositorySshHostTrust>(
|
||||||
|
'/host-trusts',
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshHostTrust
|
||||||
|
);
|
||||||
hostTrusts = hostExpectedRevision === null
|
hostTrusts = hostExpectedRevision === null
|
||||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
||||||
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||||
@@ -158,10 +200,16 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
const body: DeleteRepositorySshHostTrustRequest = {
|
||||||
operation_id: operationId('host-trust-delete'),
|
operation_id: operationId('host-trust-delete'),
|
||||||
expected_revision: hostTrust.current_revision
|
expected_revision: hostTrust.current_revision
|
||||||
});
|
};
|
||||||
|
await request(
|
||||||
|
`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`,
|
||||||
|
'DELETE',
|
||||||
|
body,
|
||||||
|
null
|
||||||
|
);
|
||||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,6 +230,18 @@
|
|||||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||||
{#if message}<p class="status-message">{message}</p>{/if}
|
{#if message}<p class="status-message">{message}</p>{/if}
|
||||||
|
|
||||||
|
<div class="settings-runtime-list">
|
||||||
|
<h3>Active access projection</h3>
|
||||||
|
<p>Config revision {accessProjection.config_revision} · <code>{accessProjection.projection_digest}</code></p>
|
||||||
|
{#if accessProjection.bindings.length === 0}<p>No repository access bindings are active.</p>{/if}
|
||||||
|
{#each accessProjection.bindings as binding (binding.repository_id)}
|
||||||
|
<div class="card">
|
||||||
|
<strong>{binding.repository_id}</strong>
|
||||||
|
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-runtime-list">
|
<div class="settings-runtime-list">
|
||||||
<h3>SSH credentials</h3>
|
<h3>SSH credentials</h3>
|
||||||
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
||||||
|
|||||||
@@ -1,50 +1,36 @@
|
|||||||
|
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
parseRepositoryAccessProjection,
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
} from "$lib/workspace/api/repository-access";
|
||||||
|
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import { loadJson } from "$lib/workspace/api/http";
|
|
||||||
|
|
||||||
export interface RepositorySshCredential {
|
|
||||||
credential_id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
name: string;
|
|
||||||
public_key_algorithm: string;
|
|
||||||
public_key_fingerprint: string;
|
|
||||||
current_revision: number;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
rotated_at: string | null;
|
|
||||||
referenced_repositories: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepositorySshHostTrust {
|
|
||||||
host_trust_id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
key_algorithm: string;
|
|
||||||
host_key: string;
|
|
||||||
fingerprint: string;
|
|
||||||
current_revision: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
referenced_repositories: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const base = `/api/w/${
|
const workspaceId = params.workspaceId;
|
||||||
encodeURIComponent(params.workspaceId)
|
const accessProjection = await loadRepositoryAccessJson(
|
||||||
}/settings/repository-access`;
|
fetch,
|
||||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
parseRepositoryAccessProjection,
|
||||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
|
||||||
]);
|
|
||||||
if (!credentialResult.data || !hostTrustResult.data) {
|
|
||||||
throw new Error(
|
|
||||||
credentialResult.error ?? hostTrustResult.error ??
|
|
||||||
"Repository access settings unavailable",
|
|
||||||
);
|
);
|
||||||
}
|
const [credentials, hostTrusts] = await Promise.all([
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/settings/repository-access/credentials"),
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
),
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"),
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId,
|
||||||
credentials: credentialResult.data,
|
credentials,
|
||||||
hostTrusts: hostTrustResult.data,
|
hostTrusts,
|
||||||
|
accessProjection,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
|
||||||
import type {
|
import type {
|
||||||
BrowserWorkingDirectoryListResponse,
|
|
||||||
ListResponse,
|
ListResponse,
|
||||||
Runtime,
|
Runtime,
|
||||||
RuntimeCleanupPlanResponse,
|
RuntimeCleanupPlanResponse,
|
||||||
@@ -14,12 +14,14 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
workspaceApiPath(params.workspaceId, "/runtimes"),
|
||||||
),
|
),
|
||||||
loadJson<BrowserWorkingDirectoryListResponse>(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
params.workspaceId,
|
params.workspaceId,
|
||||||
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
||||||
),
|
),
|
||||||
|
undefined,
|
||||||
|
parseWorkingDirectoryListResponse,
|
||||||
),
|
),
|
||||||
loadJson<RuntimeCleanupPlanResponse>(
|
loadJson<RuntimeCleanupPlanResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
|
import {
|
||||||
|
parseWorkingDirectoryCreateResponse,
|
||||||
|
validateWorkingDirectoryCreateRequest,
|
||||||
|
} from '$lib/workspace/api/workdirs';
|
||||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||||
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
||||||
import type {
|
import type {
|
||||||
BrowserCreateWorkerResponse,
|
BrowserCreateWorkerResponse,
|
||||||
BrowserWorkingDirectoryCreateResponse,
|
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
WorkerLaunchOptionsResponse,
|
WorkerLaunchOptionsResponse,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
@@ -160,22 +163,23 @@
|
|||||||
creatingWorkingDirectory = true;
|
creatingWorkingDirectory = true;
|
||||||
submitError = null;
|
submitError = null;
|
||||||
try {
|
try {
|
||||||
|
const request = validateWorkingDirectoryCreateRequest({
|
||||||
|
runtime_id: runtimeId,
|
||||||
|
repository_id: workingDirectoryRepositoryId,
|
||||||
|
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
|
||||||
|
});
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(request),
|
||||||
runtime_id: runtimeId,
|
|
||||||
repository_id: workingDirectoryRepositoryId,
|
|
||||||
selector: workingDirectorySelector || null,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
submitError = await responseDisplayError(response, 'workdir create failed');
|
submitError = await responseDisplayError(response, 'workdir create failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
|
const payload = parseWorkingDirectoryCreateResponse(await response.json());
|
||||||
const items = options?.working_directories ?? [];
|
const items = options?.working_directories ?? [];
|
||||||
options = options
|
options = options
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
parseRepositoryAccessProjection,
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
RepositoryAccessSchemaError,
|
||||||
|
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSchemaError(body: () => unknown, path: string): void {
|
||||||
|
try {
|
||||||
|
body();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof RepositoryAccessSchemaError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!error.message.includes(path)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected schema error path ${path}, got ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`expected RepositoryAccessSchemaError for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = {
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
name: "Deploy key",
|
||||||
|
public_key_algorithm: "ssh-ed25519",
|
||||||
|
public_key_fingerprint: "SHA256:credential",
|
||||||
|
current_revision: 2,
|
||||||
|
status: "active",
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
rotated_at: null,
|
||||||
|
referenced_repositories: ["main"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostTrust = {
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
hostname: "gitea.example.test",
|
||||||
|
port: 22,
|
||||||
|
key_algorithm: "ssh-ed25519",
|
||||||
|
host_key: "ssh-ed25519 AAAA",
|
||||||
|
fingerprint: "SHA256:host",
|
||||||
|
current_revision: 3,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-02T00:00:00Z",
|
||||||
|
referenced_repositories: ["main"],
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||||
|
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||||
|
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||||
|
assertEquals(
|
||||||
|
parseRepositoryAccessProjection({
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "read_only",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "read_only",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject malformed list responses", () => {
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshCredentials({ credentials: [credential] }),
|
||||||
|
"credentials",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshHostTrusts({ host_trusts: [hostTrust] }),
|
||||||
|
"host_trusts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
|
||||||
|
const { current_revision: _revision, ...missingRevision } = credential;
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshCredentials([missingRevision]),
|
||||||
|
"credentials[0].current_revision",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
|
||||||
|
"host_trusts[0].port",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() =>
|
||||||
|
parseRepositoryAccessProjection({
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "admin",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"access_projection.bindings[0].access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject unknown response fields", () => {
|
||||||
|
assertSchemaError(
|
||||||
|
() =>
|
||||||
|
parseRepositorySshCredentials([{ ...credential, private_key: "secret" }]),
|
||||||
|
"credentials[0].private_key",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||||
|
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
|
||||||
|
|
||||||
|
type HttpFailure = { status?: number; body?: { message?: string } };
|
||||||
|
|
||||||
|
async function captureHttpFailure(
|
||||||
|
run: () => Promise<unknown>,
|
||||||
|
expectedStatus: number,
|
||||||
|
expectedMessage: string,
|
||||||
|
): Promise<HttpFailure> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as HttpFailure;
|
||||||
|
if (failure.status !== expectedStatus) {
|
||||||
|
throw new Error(
|
||||||
|
`expected bounded ${expectedStatus}, got ${String(failure.status)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failure.body?.message !== expectedMessage) {
|
||||||
|
throw new Error(
|
||||||
|
`unexpected bounded error: ${JSON.stringify(failure.body)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
throw new Error(`expected bounded ${expectedStatus} error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of [401, 403]) {
|
||||||
|
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() => {
|
||||||
|
requests += 1;
|
||||||
|
return Promise.resolve(new Response(null, { status }));
|
||||||
|
},
|
||||||
|
"/api/w/workspace-1/settings/repository-access",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
403,
|
||||||
|
"Repository Access is unavailable for this account.",
|
||||||
|
);
|
||||||
|
if (requests !== 1) {
|
||||||
|
throw new Error(`expected one bounded request, got ${requests}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader maps invalid JSON to safe bounded 502", async () => {
|
||||||
|
const upstreamSecret = "private-key-must-not-leak";
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(upstreamSecret, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access returned an invalid JSON response.",
|
||||||
|
);
|
||||||
|
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||||
|
throw new Error("invalid JSON error exposed upstream response content");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502", async () => {
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() => Promise.resolve(Response.json({ stale: true })),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||||
|
() => {
|
||||||
|
throw new RepositoryAccessSchemaError("credentials", "an array");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access response schema mismatch at credentials: expected an array",
|
||||||
|
);
|
||||||
|
if (!failure.body?.message?.includes("credentials")) {
|
||||||
|
throw new Error("schema mismatch error omitted the failing response path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
|
||||||
|
const upstreamSecret = "secret-ref-must-not-leak";
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
Response.json(
|
||||||
|
{ message: upstreamSecret, secret_ref: upstreamSecret },
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/host-trusts",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access request failed with status 500.",
|
||||||
|
);
|
||||||
|
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||||
|
throw new Error("bounded upstream error exposed response content");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
|
|||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const loaderSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
||||||
|
assert(
|
||||||
|
source.includes("$lib/generated/repository-access-api"),
|
||||||
|
"mutation code should import generated request and response contracts",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
loaderSource.includes("parseRepositorySshCredentials") &&
|
||||||
|
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
||||||
|
loaderSource.includes("parseRepositoryAccessProjection"),
|
||||||
|
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
loaderSource.indexOf('"/settings/repository-access"') <
|
||||||
|
loaderSource.indexOf("Promise.all"),
|
||||||
|
"loader should check Repository Access permission before starting list preloads",
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const duplicate of [
|
||||||
|
"interface RepositorySshCredential",
|
||||||
|
"interface RepositorySshHostTrust",
|
||||||
|
"interface RepositoryAccessProjection",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
!loaderSource.includes(duplicate) && !source.includes(duplicate),
|
||||||
|
`Web code must not redeclare ${duplicate}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Repository Access renders the shared access projection fields", () => {
|
||||||
|
for (
|
||||||
|
const field of [
|
||||||
|
"accessProjection.config_revision",
|
||||||
|
"accessProjection.projection_digest",
|
||||||
|
"accessProjection.bindings",
|
||||||
|
"binding.repository_id",
|
||||||
|
"binding.credential_id",
|
||||||
|
"binding.host_trust_id",
|
||||||
|
"binding.access",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(source.includes(field), `missing access projection field ${field}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||||
const createStart = source.indexOf("async function createCredential()");
|
const createStart = source.indexOf("async function createCredential()");
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseWorkingDirectoryCreateResponse,
|
||||||
|
parseWorkingDirectoryListResponse,
|
||||||
|
validateWorkingDirectoryCreateRequest,
|
||||||
|
} from "../src/lib/workspace/api/workdirs.ts";
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
working_directory_id: "workdir-1",
|
||||||
|
repository_id: "main",
|
||||||
|
materializer_kind: "runtime_git_cache",
|
||||||
|
status: "active",
|
||||||
|
occupied_by: {
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
worker_id: "worker-1",
|
||||||
|
display_name: "Coder",
|
||||||
|
linked_at: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Workdir REST validation accepts the generated list and create contracts", () => {
|
||||||
|
const list = parseWorkingDirectoryListResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
items: [summary],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
if (list.items[0]?.occupied_by?.runtime_id !== "arcadia") {
|
||||||
|
throw new Error("occupancy subject was not preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = parseWorkingDirectoryCreateResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
item: summary,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
if (created.runtime_id !== "arcadia") {
|
||||||
|
throw new Error("create Runtime was not preserved");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workdir REST validation rejects stale response JSON", () => {
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
parseWorkingDirectoryListResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
items: [summary],
|
||||||
|
diagnostics: [],
|
||||||
|
source: "legacy-runtime",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if (!rejected) throw new Error("stale response field was accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workdir REST validation enforces create operation fields", () => {
|
||||||
|
const request = validateWorkingDirectoryCreateRequest({
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
repository_id: "main",
|
||||||
|
selector: "develop",
|
||||||
|
operation_id: "operation-1",
|
||||||
|
});
|
||||||
|
if (request.operation_id !== "operation-1") {
|
||||||
|
throw new Error("operation id was not preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
const invalid of [
|
||||||
|
{ runtime_id: "arcadia", operation_id: "operation-1" },
|
||||||
|
{ repository_id: "main", operation_key: "operation-1" },
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
validateWorkingDirectoryCreateRequest(invalid);
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if (!rejected) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid request was accepted: ${JSON.stringify(invalid)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user