chore: merge develop into hare/develop

# Conflicts:
#	crates/client/src/lib.rs
#	web/workspace/deno.json
This commit is contained in:
2026-09-01 09:15:10 +09:00
47 changed files with 4128 additions and 1035 deletions
Generated
+1 -2
View File
@@ -650,7 +650,6 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"uuid", "uuid",
"workdir",
"workspace-api", "workspace-api",
] ]
@@ -6594,6 +6593,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"workspace-api",
] ]
[[package]] [[package]]
@@ -6687,7 +6687,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"ts-rs", "ts-rs",
"workdir",
] ]
[[package]] [[package]]
-1
View File
@@ -18,7 +18,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 }
+7 -3
View File
@@ -5,7 +5,6 @@ use std::fmt;
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,
@@ -14,6 +13,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)]
@@ -526,8 +530,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);
+8 -35
View File
@@ -2,19 +2,16 @@ use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
WorkspaceSummary,
};
const DEFAULT_WORKSPACE_LIMIT: usize = 200; const DEFAULT_WORKSPACE_LIMIT: usize = 200;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub type BackendWorkspace = WorkspaceSummary;
pub struct BackendWorkspace { pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub workspace_id: String, pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
pub owner_account_id: Option<String>,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -32,30 +29,6 @@ pub struct CreateBackendWorkspaceRepository {
pub default_ref: Option<String>, pub default_ref: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceResponse {
pub workspace: BackendWorkspace,
pub repository: CreateBackendWorkspaceRepositoryRecord,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceRepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub provider: Option<String>,
pub source: RepositorySource,
pub default_ref: Option<String>,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
pub base_url: String, pub base_url: String,
@@ -118,7 +91,7 @@ async fn list_backend_workspaces_with_client(
.send() .send()
.await?; .await?;
client.check_status(response.status())?; client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?) Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
} }
pub async fn create_backend_workspace( pub async fn create_backend_workspace(
+6 -1
View File
@@ -39,5 +39,10 @@ pub use target::{
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
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;
+1
View File
@@ -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
View File
@@ -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,
@@ -426,9 +429,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)?,
@@ -707,16 +710,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 {
+16 -1
View File
@@ -12,7 +12,22 @@ 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]]
name = "generate_typescript"
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::catalog_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::workdir_api_typescript());
}
File diff suppressed because it is too large Load Diff
+26 -107
View File
@@ -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(),
} }
} }
+6 -6
View File
@@ -42,19 +42,19 @@ pub use authority::{
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
};
pub use server::{ pub use server::{
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
build_workspace_server_router, serve, serve_workspace_catalog, build_workspace_server_router, serve, serve_workspace_catalog,
}; };
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
pub use workspace_catalog::{ pub use workspace_api::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary,
WorkspaceCreateResponse, WorkspaceCreateResponse,
}; };
pub use workspace_catalog::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult,
};
use worker_runtime::identity::RuntimeWorkerRef; use worker_runtime::identity::RuntimeWorkerRef;
+16 -66
View File
@@ -4,8 +4,10 @@ use std::{
process::Command, process::Command,
}; };
use serde::{Deserialize, Serialize}; use workspace_api::{
use workspace_api::{RepositoryObservedStatus, RepositorySource}; Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary,
};
pub type RepositoryId = String; pub type RepositoryId = String;
pub type RepositorySelector = String; pub type RepositorySelector = String;
@@ -24,74 +26,19 @@ pub struct ConfiguredRepository {
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySummary {
pub id: RepositoryId,
pub display_name: String,
pub kind: String,
pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>,
pub record_authority: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<GitRepositorySummary>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRepositorySummary {
pub status: String,
pub head: Option<String>,
pub branch: Option<String>,
pub dirty: bool,
pub remotes: Vec<GitRemoteSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRemoteSummary {
pub name: String,
pub fetch_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryDiagnostic {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryListProjection { pub struct RepositoryListProjection {
pub items: Vec<RepositorySummary>, pub items: Vec<RepositorySummary>,
pub diagnostics: Vec<RepositoryDiagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryLogRead { pub struct RepositoryLogRead {
pub repository_id: RepositoryId, pub repository_id: RepositoryId,
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
pub limit: usize, pub limit: usize,
pub commits: Vec<GitCommitSummary>, pub commits: Vec<GitCommitSummary>,
pub diagnostics: Vec<RepositoryDiagnostic>, pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitCommitSummary {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub author_date: String,
pub parents: Vec<String>,
pub refs: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -131,8 +78,8 @@ impl RepositoryRegistryReader {
if self.repositories.is_empty() { if self.repositories.is_empty() {
return RepositoryListProjection { return RepositoryListProjection {
items: Vec::new(), items: Vec::new(),
diagnostics: vec![RepositoryDiagnostic { diagnostics: vec![Diagnostic {
severity: "warning".to_string(), severity: DiagnosticSeverity::Warning,
code: "repository_config_empty".to_string(), code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend." message: "No repositories are configured for this workspace backend."
.to_string(), .to_string(),
@@ -177,8 +124,8 @@ impl RepositoryRegistryReader {
let commits = match self.git_log(repository, limit) { let commits = match self.git_log(repository, limit) {
Ok(commits) => commits, Ok(commits) => commits,
Err(message) => { Err(message) => {
diagnostics.push(RepositoryDiagnostic { diagnostics.push(Diagnostic {
severity: "warning".to_string(), severity: DiagnosticSeverity::Warning,
code: "repository_git_log_unavailable".to_string(), code: "repository_git_log_unavailable".to_string(),
message, message,
}); });
@@ -379,7 +326,7 @@ impl RepositoryRegistryReader {
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
record_authority: "workspace-control-plane".to_string(), record_authority: "workspace-control-plane".to_string(),
git, git,
diagnostics, diagnostics: (!diagnostics.is_empty()).then_some(diagnostics),
} }
} }
@@ -675,7 +622,10 @@ mod tests {
RepositoryObservedStatus::Unverified RepositoryObservedStatus::Unverified
); );
assert!(summary.git.is_none()); assert!(summary.git.is_none());
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified"); assert_eq!(
summary.diagnostics.as_ref().unwrap()[0].code,
"repository_source_unverified"
);
let repository = reader.merge_repository("remote").unwrap(); let repository = reader.merge_repository("remote").unwrap();
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err(); let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
+259 -162
View File
@@ -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,
}; };
@@ -65,9 +62,17 @@ use workspace_api::{
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
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, WorkspaceRuntimeResource, WorkspaceWorkerDiscoveryItem, TICKET_RELATIONS_QUERY_PATH,
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse,
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
WorkspaceExtensionPoints, WorkspacePermissionSummary, WorkspaceRepositoryRecord,
WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
}; };
@@ -116,7 +121,7 @@ use crate::records::{
}; };
use crate::repositories::{ use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
RepositoryRegistryReader, RepositorySummary, RepositoryRegistryReader,
}; };
use crate::repository_access::{ use crate::repository_access::{
RepositoryAccessConfigSchemaProvider, RepositorySecretService, RepositoryAccessConfigSchemaProvider, RepositorySecretService,
@@ -152,17 +157,7 @@ use worker_runtime::identity::{RuntimeWorkerRef, WorkerId};
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub use workspace_api::WorkspaceAuthConfig as AuthConfig;
pub enum AuthConfig {
/// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
/// CLI/TUI auth uses API tokens obtained through the device login flow.
Passkey {
rp_id: String,
origin: String,
public_base_url: String,
cookie_name: String,
},
}
#[derive(Clone)] #[derive(Clone)]
pub struct ServerConfig { pub struct ServerConfig {
@@ -989,7 +984,9 @@ async fn list_server_workspaces(
let owner = match resolve_server_actor(&api, &headers).await { let owner = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => Some(actor.account_id), Ok(Some(actor)) => Some(actor.account_id),
Ok(None) => match api.catalog.is_empty() { Ok(None) => match api.catalog.is_empty() {
Ok(true) => return Json(Vec::<WorkspaceRecord>::new()).into_response(), Ok(true) => {
return Json(WorkspaceCatalogListResponse(Vec::new())).into_response();
}
Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), Ok(false) => return StatusCode::UNAUTHORIZED.into_response(),
Err(error) => return server_error_response(error), Err(error) => return server_error_response(error),
}, },
@@ -999,7 +996,10 @@ async fn list_server_workspaces(
.catalog .catalog
.list(owner.as_deref(), query.limit.unwrap_or(100)) .list(owner.as_deref(), query.limit.unwrap_or(100))
{ {
Ok(workspaces) => Json(workspaces).into_response(), Ok(workspaces) => Json(WorkspaceCatalogListResponse(
workspaces.into_iter().map(workspace_summary).collect(),
))
.into_response(),
Err(error) => server_error_response(error), Err(error) => server_error_response(error),
} }
} }
@@ -1031,7 +1031,48 @@ async fn create_server_workspace(
} else { } else {
StatusCode::CREATED StatusCode::CREATED
}; };
(status, Json(created)).into_response() (status, Json(workspace_create_response(created))).into_response()
}
fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary {
WorkspaceSummary {
workspace_id: record.workspace_id,
owner_account_id: record.owner_account_id,
display_name: record.display_name,
state: record.state,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryRecord {
WorkspaceRepositoryRecord {
workspace_id: record.workspace_id,
repository_id: record.repository_id,
name: record.name,
kind: record.kind,
provider: record.provider,
source: record.source,
default_ref: record.default_ref,
source_revision: record.source_revision,
source_fingerprint: record.source_fingerprint,
observed_status: record.observed_status,
observed_at: record.observed_at,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn workspace_create_response(
created: crate::workspace_catalog::WorkspaceCreateResult,
) -> WorkspaceCreateResponse {
WorkspaceCreateResponse {
workspace: workspace_summary(created.workspace),
repository: workspace_repository_record(created.repository),
config_revision: created.config_revision,
request_fingerprint: created.request_fingerprint,
replayed: created.replayed,
}
} }
async fn resolve_server_actor( async fn resolve_server_actor(
@@ -2743,31 +2784,6 @@ pub async fn serve(
Ok(()) Ok(())
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceResponse {
pub workspace_id: String,
pub display_name: String,
pub record_authority: String,
pub schema_version: i64,
pub auth: AuthConfig,
pub extension_points: ExtensionPoints,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExtensionPoints {
pub store: String,
pub event_stream: ExtensionPointState,
pub host_worker_bridge: ExtensionPointState,
pub companion_console: ExtensionPointState,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExtensionPointState {
pub status: String,
pub note: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ListResponse<T> { pub struct ListResponse<T> {
pub workspace_id: String, pub workspace_id: String,
@@ -3003,18 +3019,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 {
@@ -3071,32 +3075,6 @@ pub struct BrowserCreateWorkerResponse {
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryListResponse {
pub workspace_id: String,
pub items: Vec<RepositorySummary>,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryDetailResponse {
pub workspace_id: String,
pub item: RepositorySummary,
pub source: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryLogResponse {
pub workspace_id: String,
pub repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<String>,
pub limit: usize,
pub items: Vec<crate::repositories::GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct LogQuery { struct LogQuery {
limit: Option<usize>, limit: Option<usize>,
@@ -3361,11 +3339,12 @@ async fn scoped_get_flow(
} }
async fn scoped_get_workspace( async fn scoped_get_workspace(
headers: HeaderMap,
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<WorkspaceResponse>> { ) -> ApiResult<Json<WorkspaceResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
get_workspace(State(api)).await get_workspace(headers, State(api)).await
} }
async fn scoped_get_workspace_settings( async fn scoped_get_workspace_settings(
@@ -8947,15 +8926,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,
}) })
@@ -8979,7 +8958,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,
@@ -9022,7 +9001,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
} }
@@ -9115,7 +9094,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())
@@ -9278,7 +9257,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
@@ -9457,7 +9446,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,
@@ -11171,9 +11160,20 @@ async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult<Re
}) })
} }
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> { async fn get_workspace(
headers: HeaderMap,
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<WorkspaceResponse>> {
let cookie_name = auth_public_config(&api.config).cookie_name;
let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?;
let schema_version = api.store.schema_version().await?; let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?; let stored = api.store.get_workspace(api.workspace_id()).await?;
let is_owner = actor.as_ref().is_some_and(|actor| {
stored
.as_ref()
.and_then(|workspace| workspace.owner_account_id.as_ref())
== Some(&actor.account_id)
});
let display_name = stored let display_name = stored
.as_ref() .as_ref()
.map(|record| record.display_name.clone()) .map(|record| record.display_name.clone())
@@ -11186,14 +11186,18 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
record_authority: "local_yoi_project_records".to_string(), record_authority: "local_yoi_project_records".to_string(),
schema_version, schema_version,
auth: api.config.auth.clone(), auth: api.config.auth.clone(),
extension_points: ExtensionPoints { permissions: WorkspacePermissionSummary {
manage_repositories: is_owner,
manage_secrets: is_owner,
},
extension_points: WorkspaceExtensionPoints {
store: "sqlite".to_string(), store: "sqlite".to_string(),
event_stream: ExtensionPointState { event_stream: WorkspaceExtensionPointState {
status: "backend_proxy".to_string(), status: "backend_proxy".to_string(),
note: "Worker observation streams are exposed only through the Workspace server proxy keyed by runtime_id + worker_id; browser clients never receive raw Runtime endpoints or socket paths.".to_string(), note: "Worker observation streams are exposed only through the Workspace server proxy keyed by runtime_id + worker_id; browser clients never receive raw Runtime endpoints or socket paths.".to_string(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}, },
host_worker_bridge: ExtensionPointState { host_worker_bridge: WorkspaceExtensionPointState {
status: "runtime_registry".to_string(), status: "runtime_registry".to_string(),
note: "Hosts and Workers are projected from the Workspace RuntimeRegistry; raw Runtime endpoints, sockets, and local metadata paths are not exposed.".to_string(), note: "Hosts and Workers are projected from the Workspace RuntimeRegistry; raw Runtime endpoints, sockets, and local metadata paths are not exposed.".to_string(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
@@ -11203,32 +11207,32 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
})) }))
} }
fn companion_console_extension_point(status: &CompanionStatusResponse) -> ExtensionPointState { fn companion_console_extension_point(
let completion = status.transport.completion.clone(); status: &CompanionStatusResponse,
let note = match completion.as_str() { ) -> WorkspaceExtensionPointState {
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(), let extension_status = match status.state {
"not_input_capable" => { workspace_api::CompanionLifecycleState::Idle => "idle",
workspace_api::CompanionLifecycleState::Running => "running",
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."
),
}; };
ExtensionPointState { WorkspaceExtensionPointState {
status: completion, status: extension_status,
note, note,
diagnostics: status.diagnostics.clone(), diagnostics: status.diagnostics.clone(),
} }
@@ -11313,7 +11317,7 @@ async fn list_repositories(
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
items, items,
source: "workspace-control-plane".to_string(), source: "workspace-control-plane".to_string(),
diagnostics: repository_diagnostics(diagnostics), diagnostics,
})) }))
} }
@@ -11350,7 +11354,7 @@ async fn repository_log(
default_selector, default_selector,
limit, limit,
items: commits, items: commits,
diagnostics: repository_diagnostics(diagnostics), diagnostics,
})) }))
} }
@@ -14261,7 +14265,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(),
}); });
@@ -14610,7 +14615,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(),
}); });
@@ -15057,23 +15063,6 @@ fn sanitize_backend_error(message: &str) -> String {
message.to_string() message.to_string()
} }
fn repository_diagnostics(
diagnostics: Vec<crate::repositories::RepositoryDiagnostic>,
) -> Vec<RuntimeDiagnostic> {
diagnostics
.into_iter()
.map(|diagnostic| RuntimeDiagnostic {
code: diagnostic.code,
severity: match diagnostic.severity.as_str() {
"error" => DiagnosticSeverity::Error,
"warning" => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
},
message: diagnostic.message,
})
.collect()
}
fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> { fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
result.map_err(|error| match error { result.map_err(|error| match error {
RepositoryLookupError::UnknownRepository { id } => { RepositoryLookupError::UnknownRepository { id } => {
@@ -16110,7 +16099,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");
@@ -17315,10 +17305,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");
@@ -17928,6 +17916,12 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(authenticated_legacy.status(), StatusCode::OK); assert_eq!(authenticated_legacy.status(), StatusCode::OK);
let workspace_body = to_bytes(authenticated_legacy.into_body(), usize::MAX)
.await
.unwrap();
let typed_workspace: workspace_api::WorkspaceResponse =
serde_json::from_slice(&workspace_body).unwrap();
assert!(typed_workspace.permissions.manage_repositories);
let anonymous_catalog = app let anonymous_catalog = app
.clone() .clone()
@@ -17952,6 +17946,15 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(authenticated_catalog.status(), StatusCode::OK); assert_eq!(authenticated_catalog.status(), StatusCode::OK);
let catalog_body = to_bytes(authenticated_catalog.into_body(), usize::MAX)
.await
.unwrap();
let typed_catalog: workspace_api::WorkspaceCatalogListResponse =
serde_json::from_slice(&catalog_body).unwrap();
assert_eq!(
typed_catalog.0[0].workspace_id,
workspace.workspace.workspace_id
);
for path in ["/api/workspaces", "/api/auth/device-login/approve"] { for path in ["/api/workspaces", "/api/auth/device-login/approve"] {
let cross_site = app let cross_site = app
@@ -23290,6 +23293,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();
@@ -24188,6 +24262,9 @@ mod tests {
let workspace = get_json(app.clone(), "/api/workspace").await; let workspace = get_json(app.clone(), "/api/workspace").await;
assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID); assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID);
assert_eq!(workspace["display_name"], "Test Workspace"); assert_eq!(workspace["display_name"], "Test Workspace");
let typed_workspace: workspace_api::WorkspaceResponse =
serde_json::from_value(workspace.clone()).unwrap();
assert!(!typed_workspace.permissions.manage_repositories);
assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!( assert_eq!(
workspace["extension_points"]["host_worker_bridge"]["status"], workspace["extension_points"]["host_worker_bridge"]["status"],
@@ -24374,6 +24451,9 @@ mod tests {
); );
let repositories = get_json(app.clone(), "/api/repositories").await; let repositories = get_json(app.clone(), "/api/repositories").await;
let typed_repositories: workspace_api::RepositoryListResponse =
serde_json::from_value(repositories.clone()).unwrap();
assert_eq!(typed_repositories.items[0].id, TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!(repositories["items"][0]["kind"], "git");
assert_eq!( assert_eq!(
@@ -24387,6 +24467,8 @@ mod tests {
); );
let repository_detail = get_json(app.clone(), "/api/repositories/main").await; let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
let _: workspace_api::RepositoryDetailResponse =
serde_json::from_value(repository_detail.clone()).unwrap();
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let scoped_repository_detail = get_json( let scoped_repository_detail = get_json(
app.clone(), app.clone(),
@@ -24396,6 +24478,8 @@ mod tests {
assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID); assert_eq!(scoped_repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
let _: workspace_api::RepositoryLogResponse =
serde_json::from_value(repository_log.clone()).unwrap();
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["default_selector"], "HEAD");
assert_eq!(repository_log["limit"], 3); assert_eq!(repository_log["limit"], 3);
@@ -24460,10 +24544,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(
@@ -24473,16 +24557,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!(
@@ -24587,7 +24681,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"
@@ -24596,12 +24690,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(
@@ -24611,13 +24706,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!(
@@ -25744,7 +25840,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(),
}), }),
@@ -35,8 +35,8 @@ pub struct WorkspaceCreateRequest {
pub repository: InitialRepositoryIntent, pub repository: InitialRepositoryIntent,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResponse { pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord, pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord, pub repository: RepositoryRecord,
pub config_revision: u64, pub config_revision: u64,
@@ -81,7 +81,7 @@ impl WorkspaceCatalogService {
&self, &self,
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: String, owner_account_id: String,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResult> {
self.create_internal(request, owner_account_id, None) self.create_internal(request, owner_account_id, None)
} }
@@ -90,7 +90,7 @@ impl WorkspaceCatalogService {
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: String, owner_account_id: String,
requested_workspace_id: Option<String>, requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResult> {
let operation_key = normalize_required( let operation_key = normalize_required(
"operation_key", "operation_key",
request.operation_key, request.operation_key,
@@ -165,7 +165,7 @@ impl WorkspaceCatalogService {
updated_at: now, updated_at: now,
}, },
})?; })?;
Ok(WorkspaceCreateResponse { Ok(WorkspaceCreateResult {
workspace: result.workspace, workspace: result.workspace,
repository: result.repository, repository: result.repository,
config_revision: result.config_revision, config_revision: result.config_revision,
+1 -1
View File
@@ -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 --allow-env=LOG,VSCODE_TEXTMATE_DEBUG 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 test/repository-access/ui.test.ts test/repositories/ui.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 test/repositories/ui.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>, };
@@ -0,0 +1,166 @@
// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.
// Do not edit this file directly.
export type WorkspaceSummary = {
workspace_id: string;
owner_account_id: string | null;
display_name: string;
state: string;
created_at: string;
updated_at: string;
};
export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
provider: string | null;
source: RepositorySource;
default_ref: string | null;
source_revision: number;
source_fingerprint: string;
observed_status: RepositoryObservedStatus;
observed_at: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceCreateResponse = {
workspace: WorkspaceSummary;
repository: WorkspaceRepositoryRecord;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
};
export type WorkspaceAuthConfig = {
"Passkey": {
rp_id: string;
origin: string;
public_base_url: string;
cookie_name: string;
};
};
export type WorkspacePermissionSummary = {
manage_repositories: boolean;
manage_secrets: boolean;
};
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type WorkspaceExtensionPointState = {
status: string;
note: string;
diagnostics: Array<Diagnostic>;
};
export type WorkspaceExtensionPoints = {
store: string;
event_stream: WorkspaceExtensionPointState;
host_worker_bridge: WorkspaceExtensionPointState;
companion_console: WorkspaceExtensionPointState;
};
export type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
schema_version: number;
auth: WorkspaceAuthConfig;
permissions: WorkspacePermissionSummary;
extension_points: WorkspaceExtensionPoints;
};
export type RepositorySourceKind =
| "local_path"
| "file"
| "ssh"
| "http"
| "https"
| "invalid";
export type RepositorySource = {
kind: RepositorySourceKind;
/**
* Canonical source representation. This is an absolute local path for
* `local_path`, and a normalized URI/remote specification otherwise.
*/
uri: string;
};
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
export type RepositoryDiagnostic = {
severity: string;
code: string;
message: string;
};
export type GitRemoteSummary = { name: string; fetch_url: string };
export type GitRepositorySummary = {
status: string;
head: string | null;
branch: string | null;
dirty: boolean;
remotes: Array<GitRemoteSummary>;
};
export type RepositorySummary = {
id: string;
display_name: string;
kind: string;
provider: string;
source: RepositorySource;
source_revision: number;
source_fingerprint: string;
observed_status: RepositoryObservedStatus;
observed_at?: string | null;
default_selector?: string | null;
record_authority: string;
git?: GitRepositorySummary | null;
diagnostics?: Array<RepositoryDiagnostic> | null;
};
export type GitCommitSummary = {
hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
author_date: string;
parents: Array<string>;
refs: Array<string>;
};
export type RepositoryListResponse = {
workspace_id: string;
items: Array<RepositorySummary>;
source: string;
diagnostics: Array<Diagnostic>;
};
export type RepositoryDetailResponse = {
workspace_id: string;
item: RepositorySummary;
source: string;
};
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: Array<GitCommitSummary>;
diagnostics: Array<Diagnostic>;
};
+3 -1
View File
@@ -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;
}
@@ -1,41 +1,18 @@
export type WorkspaceCatalogRecord = { import {
workspace_id: string; parseRepositoryListResponse,
owner_account_id: string | null; parseWorkspaceCatalogResponse,
display_name: string; parseWorkspaceCreateResponse,
state: string; type RepositorySummary,
created_at: string; type WorkspaceCreateResponse,
updated_at: string; type WorkspaceSummary,
}; } from "$lib/workspace/api/workspace-model";
export type RepositorySourceKind =
| "local_path"
| "file"
| "ssh"
| "http"
| "https"
| "invalid";
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
provider: string | null;
source: {
kind: RepositorySourceKind;
uri: string;
};
default_ref: string | null;
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at: string | null;
};
export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
repositories: WorkspaceRepositoryRecord[]; repositories: RepositorySummary[];
repository_error?: string; repository_error?: string;
}; };
export type CreateWorkspaceResponse = WorkspaceCreateResponse;
export type CreateWorkspaceRequest = { export type CreateWorkspaceRequest = {
operation_key: string; operation_key: string;
@@ -47,14 +24,6 @@ export type CreateWorkspaceRequest = {
}; };
}; };
export type CreateWorkspaceResponse = {
workspace: WorkspaceCatalogRecord;
repository: WorkspaceRepositoryRecord;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
};
export class WorkspaceCatalogError extends Error { export class WorkspaceCatalogError extends Error {
constructor( constructor(
public readonly status: number | null, public readonly status: number | null,
@@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch;
export async function listWorkspaces( export async function listWorkspaces(
fetcher: Fetch, fetcher: Fetch,
): Promise<WorkspaceCatalogRecord[]> { ): Promise<WorkspaceCatalogRecord[]> {
return await fetchJson<WorkspaceCatalogRecord[]>( return parseWorkspaceCatalogResponse(
fetcher, await fetchJson(fetcher, "/api/workspaces?limit=200"),
"/api/workspaces?limit=200",
); );
} }
export async function listWorkspaceRepositories( export async function listWorkspaceRepositories(
fetcher: Fetch, fetcher: Fetch,
workspaceId: string, workspaceId: string,
): Promise<WorkspaceRepositoryRecord[]> { ): Promise<RepositorySummary[]> {
return await fetchJson<WorkspaceRepositoryRecord[]>( return parseRepositoryListResponse(
await fetchJson(
fetcher, fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`, `/api/w/${encodeURIComponent(workspaceId)}/repositories`,
); ),
).items;
} }
export async function loadWorkspaceCatalog( export async function loadWorkspaceCatalog(
@@ -115,11 +85,13 @@ export async function createWorkspace(
fetcher: Fetch, fetcher: Fetch,
request: CreateWorkspaceRequest, request: CreateWorkspaceRequest,
): Promise<CreateWorkspaceResponse> { ): Promise<CreateWorkspaceResponse> {
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", { return parseWorkspaceCreateResponse(
await fetchJson(fetcher, "/api/workspaces", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(request), body: JSON.stringify(request),
}); }),
);
} }
export function creationErrorMessage(error: unknown): string { export function creationErrorMessage(error: unknown): string {
@@ -150,11 +122,11 @@ export function createOperationKey(): string {
}`; }`;
} }
async function fetchJson<T>( async function fetchJson(
fetcher: Fetch, fetcher: Fetch,
input: string, input: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<unknown> {
let response: Response; let response: Response;
try { try {
response = await fetcher(input, init); response = await fetcher(input, init);
@@ -172,7 +144,7 @@ async function fetchJson<T>(
} }
throw new WorkspaceCatalogError(response.status, detail); throw new WorkspaceCatalogError(response.status, detail);
} }
return await response.json() as T; return await response.json() as unknown;
} }
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
@@ -0,0 +1,610 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
Diagnostic,
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryDiagnostic,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySource,
RepositorySourceKind,
RepositorySummary,
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceExtensionPoints,
WorkspaceExtensionPointState,
WorkspacePermissionSummary,
WorkspaceRepositoryRecord,
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/workspace-api.ts";
export type {
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspacePermissionSummary,
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/workspace-api.ts";
type JsonObject = Record<string, unknown>;
const SOURCE_KINDS = new Set<RepositorySourceKind>([
"local_path",
"file",
"ssh",
"http",
"https",
"invalid",
]);
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} must be an object`);
}
return value as JsonObject;
}
function array(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value;
}
function string(value: unknown, path: string): string {
if (typeof value !== "string") throw new Error(`${path} must be a string`);
return value;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
throw new Error(`${path} must be a safe integer`);
}
return value;
}
function nullableString(value: unknown, path: string): string | null {
return value === null ? null : string(value, path);
}
function optionalNullableString(
value: unknown,
path: string,
): string | null | undefined {
return value === undefined ? undefined : nullableString(value, path);
}
function exactKeys(
value: JsonObject,
keys: readonly string[],
path: string,
): void {
const allowed = new Set(keys);
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
if (unexpected) {
throw new Error(`${path}.${unexpected} is not part of the wire contract`);
}
}
function diagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], path);
const severity = string(item.severity, `${path}.severity`);
if (!DIAGNOSTIC_SEVERITIES.has(severity)) {
throw new Error(`${path}.severity is invalid`);
}
return {
code: string(item.code, `${path}.code`),
severity: severity as Diagnostic["severity"],
message: string(item.message, `${path}.message`),
};
}
function repositoryDiagnostic(
value: unknown,
path: string,
): RepositoryDiagnostic {
const item = object(value, path);
exactKeys(item, ["severity", "code", "message"], path);
return {
severity: string(item.severity, `${path}.severity`),
code: string(item.code, `${path}.code`),
message: string(item.message, `${path}.message`),
};
}
function repositorySource(value: unknown, path: string): RepositorySource {
const source = object(value, path);
exactKeys(source, ["kind", "uri"], path);
const kind = string(source.kind, `${path}.kind`);
if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
throw new Error(`${path}.kind is invalid`);
}
return {
kind: kind as RepositorySourceKind,
uri: string(source.uri, `${path}.uri`),
};
}
function gitRemote(value: unknown, path: string): GitRemoteSummary {
const remote = object(value, path);
exactKeys(remote, ["name", "fetch_url"], path);
return {
name: string(remote.name, `${path}.name`),
fetch_url: string(remote.fetch_url, `${path}.fetch_url`),
};
}
function gitSummary(value: unknown, path: string): GitRepositorySummary {
const git = object(value, path);
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
return {
status: string(git.status, `${path}.status`),
head: nullableString(git.head, `${path}.head`),
branch: nullableString(git.branch, `${path}.branch`),
dirty: boolean(git.dirty, `${path}.dirty`),
remotes: array(git.remotes, `${path}.remotes`).map((item, index) =>
gitRemote(item, `${path}.remotes[${index}]`)
),
};
}
function repositorySummary(value: unknown, path: string): RepositorySummary {
const item = object(value, path);
exactKeys(
item,
[
"id",
"display_name",
"kind",
"provider",
"source",
"source_revision",
"source_fingerprint",
"observed_status",
"observed_at",
"default_selector",
"record_authority",
"git",
"diagnostics",
],
path,
);
const observedStatus = string(
item.observed_status,
`${path}.observed_status`,
);
if (!OBSERVED_STATUSES.has(observedStatus)) {
throw new Error(`${path}.observed_status is invalid`);
}
const diagnostics =
item.diagnostics === undefined || item.diagnostics === null
? item.diagnostics
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) =>
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
);
return {
id: string(item.id, `${path}.id`),
display_name: string(item.display_name, `${path}.display_name`),
kind: string(item.kind, `${path}.kind`),
provider: string(item.provider, `${path}.provider`),
source: repositorySource(item.source, `${path}.source`),
source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string(
item.source_fingerprint,
`${path}.source_fingerprint`,
),
observed_status: observedStatus as RepositorySummary["observed_status"],
observed_at: optionalNullableString(
item.observed_at,
`${path}.observed_at`,
),
default_selector: optionalNullableString(
item.default_selector,
`${path}.default_selector`,
),
record_authority: string(item.record_authority, `${path}.record_authority`),
git: item.git === undefined || item.git === null
? item.git
: gitSummary(item.git, `${path}.git`),
diagnostics,
};
}
function workspaceSummary(value: unknown, path: string): WorkspaceSummary {
const item = object(value, path);
exactKeys(
item,
[
"workspace_id",
"owner_account_id",
"display_name",
"state",
"created_at",
"updated_at",
],
path,
);
return {
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
owner_account_id: nullableString(
item.owner_account_id,
`${path}.owner_account_id`,
),
display_name: string(item.display_name, `${path}.display_name`),
state: string(item.state, `${path}.state`),
created_at: string(item.created_at, `${path}.created_at`),
updated_at: string(item.updated_at, `${path}.updated_at`),
};
}
function workspaceRepositoryRecord(
value: unknown,
path: string,
): WorkspaceRepositoryRecord {
const item = object(value, path);
exactKeys(
item,
[
"workspace_id",
"repository_id",
"name",
"kind",
"provider",
"source",
"default_ref",
"source_revision",
"source_fingerprint",
"observed_status",
"observed_at",
"created_at",
"updated_at",
],
path,
);
const observedStatus = string(
item.observed_status,
`${path}.observed_status`,
);
if (!OBSERVED_STATUSES.has(observedStatus)) {
throw new Error(`${path}.observed_status is invalid`);
}
return {
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
repository_id: string(item.repository_id, `${path}.repository_id`),
name: string(item.name, `${path}.name`),
kind: string(item.kind, `${path}.kind`),
provider: nullableString(item.provider, `${path}.provider`),
source: repositorySource(item.source, `${path}.source`),
default_ref: nullableString(item.default_ref, `${path}.default_ref`),
source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string(
item.source_fingerprint,
`${path}.source_fingerprint`,
),
observed_status:
observedStatus as WorkspaceRepositoryRecord["observed_status"],
observed_at: nullableString(item.observed_at, `${path}.observed_at`),
created_at: string(item.created_at, `${path}.created_at`),
updated_at: string(item.updated_at, `${path}.updated_at`),
};
}
function extensionPoint(
value: unknown,
path: string,
): WorkspaceExtensionPointState {
const item = object(value, path);
exactKeys(item, ["status", "note", "diagnostics"], path);
return {
status: string(item.status, `${path}.status`),
note: string(item.note, `${path}.note`),
diagnostics: array(item.diagnostics, `${path}.diagnostics`).map((
entry,
index,
) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
};
}
function extensionPoints(
value: unknown,
path: string,
): WorkspaceExtensionPoints {
const item = object(value, path);
exactKeys(item, [
"store",
"event_stream",
"host_worker_bridge",
"companion_console",
], path);
return {
store: string(item.store, `${path}.store`),
event_stream: extensionPoint(item.event_stream, `${path}.event_stream`),
host_worker_bridge: extensionPoint(
item.host_worker_bridge,
`${path}.host_worker_bridge`,
),
companion_console: extensionPoint(
item.companion_console,
`${path}.companion_console`,
),
};
}
function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
const auth = object(value, path);
exactKeys(auth, ["Passkey"], path);
const passkey = object(auth.Passkey, `${path}.Passkey`);
exactKeys(
passkey,
["rp_id", "origin", "public_base_url", "cookie_name"],
`${path}.Passkey`,
);
return {
Passkey: {
rp_id: string(passkey.rp_id, `${path}.Passkey.rp_id`),
origin: string(passkey.origin, `${path}.Passkey.origin`),
public_base_url: string(
passkey.public_base_url,
`${path}.Passkey.public_base_url`,
),
cookie_name: string(passkey.cookie_name, `${path}.Passkey.cookie_name`),
},
};
}
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
const item = object(value, path);
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
return {
manage_repositories: boolean(
item.manage_repositories,
`${path}.manage_repositories`,
),
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
};
}
function commitSummary(value: unknown, path: string): GitCommitSummary {
const item = object(value, path);
exactKeys(
item,
[
"hash",
"short_hash",
"summary",
"author_name",
"author_email",
"author_date",
"parents",
"refs",
],
path,
);
return {
hash: string(item.hash, `${path}.hash`),
short_hash: string(item.short_hash, `${path}.short_hash`),
summary: string(item.summary, `${path}.summary`),
author_name: string(item.author_name, `${path}.author_name`),
author_email: string(item.author_email, `${path}.author_email`),
author_date: string(item.author_date, `${path}.author_date`),
parents: array(item.parents, `${path}.parents`).map((entry, index) =>
string(entry, `${path}.parents[${index}]`)
),
refs: array(item.refs, `${path}.refs`).map((entry, index) =>
string(entry, `${path}.refs[${index}]`)
),
};
}
export function parseWorkspaceCatalogResponse(
value: unknown,
): WorkspaceCatalogListResponse {
return array(value, "workspaces").map((item, index) =>
workspaceSummary(item, `workspaces[${index}]`)
);
}
export function parseWorkspaceCreateResponse(
value: unknown,
): WorkspaceCreateResponse {
const response = object(value, "workspace create response");
exactKeys(
response,
[
"workspace",
"repository",
"config_revision",
"request_fingerprint",
"replayed",
],
"workspace create response",
);
return {
workspace: workspaceSummary(
response.workspace,
"workspace create response.workspace",
),
repository: workspaceRepositoryRecord(
response.repository,
"workspace create response.repository",
),
config_revision: integer(
response.config_revision,
"workspace create response.config_revision",
),
request_fingerprint: string(
response.request_fingerprint,
"workspace create response.request_fingerprint",
),
replayed: boolean(response.replayed, "workspace create response.replayed"),
};
}
export function parseWorkspaceResponse(value: unknown): WorkspaceResponse {
const response = object(value, "workspace response");
exactKeys(
response,
[
"workspace_id",
"display_name",
"record_authority",
"schema_version",
"auth",
"permissions",
"extension_points",
],
"workspace response",
);
return {
workspace_id: string(
response.workspace_id,
"workspace response.workspace_id",
),
display_name: string(
response.display_name,
"workspace response.display_name",
),
record_authority: string(
response.record_authority,
"workspace response.record_authority",
),
schema_version: integer(
response.schema_version,
"workspace response.schema_version",
),
auth: authConfig(response.auth, "workspace response.auth"),
permissions: permissions(
response.permissions,
"workspace response.permissions",
),
extension_points: extensionPoints(
response.extension_points,
"workspace response.extension_points",
),
};
}
export function parseRepositoryListResponse(
value: unknown,
): RepositoryListResponse {
const response = object(value, "repository list response");
exactKeys(
response,
["workspace_id", "items", "source", "diagnostics"],
"repository list response",
);
return {
workspace_id: string(
response.workspace_id,
"repository list response.workspace_id",
),
items: array(response.items, "repository list response.items").map((
item,
index,
) => repositorySummary(item, `repository list response.items[${index}]`)),
source: string(response.source, "repository list response.source"),
diagnostics: array(
response.diagnostics,
"repository list response.diagnostics",
).map(
(item, index) =>
diagnostic(item, `repository list response.diagnostics[${index}]`),
),
};
}
export function parseRepositoryListApiResult(
result: ApiResult<unknown>,
): ApiResult<RepositoryListResponse> {
if (result.data === null) return { data: null, error: result.error };
try {
return { data: parseRepositoryListResponse(result.data), error: null };
} catch (cause) {
return {
data: null,
error: cause instanceof Error
? cause.message
: "invalid repository list response",
};
}
}
export function parseRepositoryDetailResponse(
value: unknown,
): RepositoryDetailResponse {
const response = object(value, "repository detail response");
exactKeys(
response,
["workspace_id", "item", "source"],
"repository detail response",
);
return {
workspace_id: string(
response.workspace_id,
"repository detail response.workspace_id",
),
item: repositorySummary(response.item, "repository detail response.item"),
source: string(response.source, "repository detail response.source"),
};
}
export function parseRepositoryLogResponse(
value: unknown,
): RepositoryLogResponse {
const response = object(value, "repository log response");
exactKeys(
response,
[
"workspace_id",
"repository_id",
"default_selector",
"limit",
"items",
"diagnostics",
],
"repository log response",
);
return {
workspace_id: string(
response.workspace_id,
"repository log response.workspace_id",
),
repository_id: string(
response.repository_id,
"repository log response.repository_id",
),
default_selector: optionalNullableString(
response.default_selector,
"repository log response.default_selector",
),
limit: integer(response.limit, "repository log response.limit"),
items: array(response.items, "repository log response.items").map((
item,
index,
) => commitSummary(item, `repository log response.items[${index}]`)),
diagnostics: array(
response.diagnostics,
"repository log response.diagnostics",
).map(
(item, index) =>
diagnostic(item, `repository log response.diagnostics[${index}]`),
),
};
}
@@ -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;
}
+48 -178
View File
@@ -1,27 +1,39 @@
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,
Segment as PodProtocolSegment, Segment as PodProtocolSegment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import type {
GitCommitSummary as SharedGitCommitSummary,
GitRemoteSummary as SharedGitRemoteSummary,
GitRepositorySummary as SharedGitRepositorySummary,
RepositoryDetailResponse as SharedRepositoryDetailResponse,
RepositoryListResponse as SharedRepositoryListResponse,
RepositoryLogResponse as SharedRepositoryLogResponse,
RepositorySummary as SharedRepositorySummary,
WorkspaceResponse as SharedWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; export type {
PodProtocolEvent,
export type ExtensionPoint = { PodProtocolMethod,
status: string; PodProtocolSegment,
note: string; WorkingDirectoryCreateRequest,
diagnostics: Diagnostic[]; WorkingDirectoryCreateResponse,
}; WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
export type WorkspaceResponse = { WorkingDirectoryOccupancy,
workspace_id: string; WorkingDirectorySummary,
display_name: string;
record_authority: string;
extension_points: {
event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint;
companion_console: ExtensionPoint;
};
}; };
export type WorkspaceResponse = SharedWorkspaceResponse;
export type Diagnostic = { export type Diagnostic = {
code: string; code: string;
@@ -111,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"
@@ -217,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[];
@@ -257,70 +225,13 @@ export type ListResponse<T> = {
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };
export type RepositorySummary = { export type RepositorySummary = SharedRepositorySummary;
id: string; export type GitRepositorySummary = SharedGitRepositorySummary;
display_name: string; export type GitRemoteSummary = SharedGitRemoteSummary;
kind: string; export type GitCommitSummary = SharedGitCommitSummary;
provider: string; export type RepositoryListResponse = SharedRepositoryListResponse;
source: { export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid"; export type RepositoryLogResponse = SharedRepositoryLogResponse;
uri: string;
};
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at?: string | null;
default_selector?: string | null;
record_authority: string;
git?: GitRepositorySummary | null;
diagnostics?: Diagnostic[];
};
export type GitRepositorySummary = {
status: string;
branch?: string | null;
head?: string | null;
dirty: boolean;
remotes: GitRemoteSummary[];
};
export type GitRemoteSummary = {
name: string;
fetch_url: string;
};
export type GitCommitSummary = {
hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
author_date: string;
parents: string[];
refs: string[];
};
export type RepositoryListResponse = {
workspace_id: string;
items: RepositorySummary[];
source: string;
diagnostics: Diagnostic[];
};
export type RepositoryDetailResponse = {
workspace_id: string;
item: RepositorySummary;
source: string;
};
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: GitCommitSummary[];
diagnostics: Diagnostic[];
};
export type MemoryDocumentResponse = { export type MemoryDocumentResponse = {
body_md: string; body_md: string;
@@ -448,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[];
};
+2 -2
View File
@@ -132,9 +132,9 @@
<code>{workspace.workspace_id}</code> <code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]} {#if workspace.repositories[0]}
<span class="workspace-repository-summary"> <span class="workspace-repository-summary">
{workspace.repositories[0].name} {workspace.repositories[0].display_name}
<small> <small>
{workspace.repositories[0].default_ref ?? "repository default"} · {workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind} {workspace.repositories[0].kind}
</small> </small>
</span> </span>
@@ -1,34 +1,51 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
export const load: LayoutLoad = async ({ fetch, params }) => { export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const [workspace, repositories] = await Promise.all([ const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<WorkspaceResponse>( loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
fetch, loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
workspaceApiPath(workspaceId, "/workspace"),
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(workspaceId, "/repositories"),
),
]); ]);
if (!workspace.data) { let workspace = null;
let workspaceError = workspaceResult.error;
if (workspaceResult.data !== null) {
try {
workspace = parseWorkspaceResponse(workspaceResult.data);
} catch (cause) {
workspaceError = cause instanceof Error
? cause.message
: "invalid workspace response";
}
}
if (!workspace) {
error(404, { error(404, {
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`, message: workspaceError ?? `Workspace ${workspaceId} is unavailable`,
}); });
} }
let repositories = null;
let repositoriesError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repositories = parseRepositoryListResponse(repositoryResult.data);
} catch (cause) {
repositoriesError = cause instanceof Error
? cause.message
: "invalid repository list response";
}
}
return { return {
workspace: workspace.data, workspace,
workspaceError: null, workspaceError: null,
repositories: repositories.data, repositories,
repositoriesError: repositories.error, repositoriesError,
}; };
}; };
@@ -1,29 +1,59 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { import {
RepositoryDetailResponse, parseRepositoryDetailResponse,
RepositoryLogResponse, parseRepositoryLogResponse,
} from "$lib/workspace/sidebar/types"; } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const workspaceId = params.workspaceId;
const repositoryId = params.repositoryId; const repositoryId = params.repositoryId;
const [repository, log] = await Promise.all([ const [repositoryResult, logResult] = await Promise.all([
loadJson<RepositoryDetailResponse>( loadJson<unknown>(
fetch, fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`), workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}`,
), ),
loadJson<RepositoryLogResponse>( ),
loadJson<unknown>(
fetch, fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`), workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}/log`,
),
), ),
]); ]);
let repository = null;
let repositoryError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repository = parseRepositoryDetailResponse(repositoryResult.data);
} catch (cause) {
repositoryError = cause instanceof Error
? cause.message
: "invalid repository detail response";
}
}
let log = null;
let logError = logResult.error;
if (logResult.data !== null) {
try {
log = parseRepositoryLogResponse(logResult.data);
} catch (cause) {
logError = cause instanceof Error
? cause.message
: "invalid repository log response";
}
}
return { return {
repositoryId, repositoryId,
repository: repository.data, repository,
repositoryError: repository.error, repositoryError,
repositoryLog: log.data, repositoryLog: log,
repositoryLogError: log.error, repositoryLogError: logError,
}; };
}; };
@@ -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,
}; };
}; };
@@ -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,
@@ -1,14 +1,12 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
import { import {
canonicalResourceReference, canonicalResourceReference,
resourceKey, resourceKey,
} from "$lib/workspace/resource-links"; } from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { import type { TicketDetail } from "$lib/workspace/sidebar/types";
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
@@ -17,9 +15,9 @@ export const load = (async ({ fetch, params }) => {
params.workspaceId, params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`, `/tickets/${encodeURIComponent(reference)}`,
); );
const [ticket, repositories, orchestrator] = await Promise.all([ const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath), loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>( loadJson<unknown>(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/repositories"), workspaceApiPath(params.workspaceId, "/repositories"),
), ),
@@ -42,6 +40,8 @@ export const load = (async ({ fetch, params }) => {
); );
} }
} }
const repositories = parseRepositoryListApiResult(repositoriesRaw);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
ticketId: ticket.data?.id ?? reference, ticketId: ticket.data?.id ?? reference,
@@ -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()");
+89
View File
@@ -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)}`,
);
}
}
});
+17 -8
View File
@@ -55,21 +55,30 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
])); ]));
} }
if (url.includes("w-a")) { if (url.includes("w-a")) {
return Promise.resolve(Response.json([{ return Promise.resolve(Response.json({
workspace_id: "w-a", workspace_id: "w-a",
repository_id: "main", items: [{
name: "Main", id: "main",
kind: "local_path", display_name: "Main",
uri: "/srv/alpha", kind: "git",
default_ref: "develop", provider: "git",
}])); source: { kind: "local_path", uri: "/srv/alpha" },
source_revision: 1,
source_fingerprint: "sha256:alpha",
observed_status: "ready",
default_selector: "develop",
record_authority: "workspace-control-plane",
}],
source: "workspace-control-plane",
diagnostics: [],
}));
} }
return Promise.resolve(new Response("unavailable", { status: 503 })); return Promise.resolve(new Response("unavailable", { status: 503 }));
}; };
const items = await loadWorkspaceCatalog(fetcher as typeof fetch); const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
assertEquals(items.length, 2); assertEquals(items.length, 2);
assertEquals(items[0].repositories[0].repository_id, "main"); assertEquals(items[0].repositories[0].id, "main");
assertEquals(items[1].repositories, []); assertEquals(items[1].repositories, []);
assertEquals(typeof items[1].repository_error, "string"); assertEquals(typeof items[1].repository_error, "string");
}); });
+110
View File
@@ -0,0 +1,110 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseRepositoryListApiResult,
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "../src/lib/workspace/api/workspace-model.ts";
function assertThrows(operation: () => unknown, expected: string): void {
try {
operation();
} catch (error) {
if (error instanceof Error && error.message.includes(expected)) return;
throw error;
}
throw new Error("expected operation to throw");
}
const repositoryList = {
workspace_id: "w-a",
items: [{
id: "main",
display_name: "Main",
kind: "git",
provider: "git",
source: { kind: "local_path", uri: "/srv/alpha" },
source_revision: 1,
source_fingerprint: "sha256:alpha",
observed_status: "ready",
record_authority: "workspace-control-plane",
}],
source: "workspace-control-plane",
diagnostics: [],
};
Deno.test("generated repository wrapper validates current Backend JSON", () => {
const parsed = parseRepositoryListResponse(repositoryList);
if (parsed.items[0]?.id !== "main") {
throw new Error("repository id was not preserved");
}
if (parsed.items[0]?.source.kind !== "local_path") {
throw new Error("repository source kind was not preserved");
}
});
Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
const stale = structuredClone(repositoryList) as Record<string, unknown>;
const items = stale.items as Array<Record<string, unknown>>;
items[0].repository_id = items[0].id;
delete items[0].id;
assertThrows(
() => parseRepositoryListResponse(stale),
".repository_id is not part",
);
});
Deno.test("repository API result converts stale payloads into bounded page errors", () => {
const result = parseRepositoryListApiResult({
data: {
workspace_id: "w-a",
items: { main: repositoryList.items[0] },
source: "workspace-control-plane",
diagnostics: [],
},
error: null,
});
if (result.data !== null) {
throw new Error("stale payload must not reach the page");
}
if (!result.error?.includes("items must be an array")) {
throw new Error(`unexpected bounded error: ${result.error}`);
}
});
Deno.test("workspace response requires the permission projection", () => {
const stale = {
workspace_id: "w-a",
display_name: "Alpha",
record_authority: "workspace-control-plane",
schema_version: 46,
auth: {
Passkey: {
rp_id: "example.test",
origin: "https://example.test",
public_base_url: "https://example.test",
cookie_name: "yoi_session",
},
},
extension_points: {
store: "sqlite",
event_stream: { status: "available", note: "ready", diagnostics: [] },
host_worker_bridge: {
status: "available",
note: "ready",
diagnostics: [],
},
companion_console: {
status: "available",
note: "ready",
diagnostics: [],
},
},
};
assertThrows(
() => parseWorkspaceResponse(stale),
"permissions must be an object",
);
});