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-tungstenite 0.29.0",
"uuid",
"workdir",
"workspace-api",
]
@@ -6594,6 +6593,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"workspace-api",
]
[[package]]
@@ -6687,7 +6687,6 @@ dependencies = [
"serde",
"serde_json",
"ts-rs",
"workdir",
]
[[package]]
-1
View File
@@ -18,7 +18,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync"
tokio-tungstenite = { workspace = true }
uuid = { workspace = true }
workspace-api.workspace = true
workdir = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+7 -3
View File
@@ -5,7 +5,6 @@ use std::fmt;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
@@ -14,6 +13,11 @@ pub use workspace_api::{
WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -526,8 +530,8 @@ mod tests {
.unwrap()
.occupied_by
.expect("occupied Workdir");
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload;
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 serde::{Deserialize, Serialize};
use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
WorkspaceSummary,
};
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkspace {
pub workspace_id: String,
pub owner_account_id: Option<String>,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
}
pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
@@ -32,30 +29,6 @@ pub struct CreateBackendWorkspaceRepository {
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)]
pub struct BackendWorkspaceCatalogTarget {
pub base_url: String,
@@ -118,7 +91,7 @@ async fn list_backend_workspaces_with_client(
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?)
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
}
pub async fn create_backend_workspace(
+6 -1
View File
@@ -39,5 +39,10 @@ pub use target::{
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
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;
+1
View File
@@ -18,6 +18,7 @@ sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
workspace-api = { workspace = true }
[dev-dependencies]
serde_json.workspace = true
+11 -251
View File
@@ -6,7 +6,11 @@
//! [`crate::http`].
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.
#[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.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation {
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)]
mod tests {
use super::*;
@@ -255,88 +88,15 @@ mod tests {
}
#[test]
fn occupied_and_free_list_response_round_trips() {
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();
fn workspace_workdir_projection_reexports_workspace_api_authority() {
assert_eq!(
encoded["items"][0]["occupied_by"]["worker_id"],
"worker-opaque-64"
std::any::TypeId::of::<WorkingDirectorySummary>(),
std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
);
assert!(
encoded["items"][0]["occupied_by"]
.get("runtime_worker_id")
.is_none()
assert_eq!(
std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
);
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_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -24,6 +20,13 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
@@ -426,9 +429,9 @@ impl WorkspaceHttpWorkdirBackend {
runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(),
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,
format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?,
@@ -707,16 +710,6 @@ struct WorkdirCreateInput {
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)]
#[serde(deny_unknown_fields)]
struct WorkdirAttachInput {
+16 -1
View File
@@ -12,7 +12,22 @@ typescript = ["dep:ts-rs"]
[dependencies]
serde = { workspace = true, features = ["derive"] }
ts-rs = { version = "12.0.1", optional = true }
workdir.workspace = true
[[example]]
name = "generate_typescript"
required-features = ["typescript"]
[dev-dependencies]
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
+32 -113
View File
@@ -1,77 +1,14 @@
use serde::{Deserialize, Serialize};
use workspace_api::{
CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic,
DiagnosticSeverity,
};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[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,
}
pub use workspace_api::{
CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
#[derive(Clone, Default)]
pub struct CompanionConsole;
impl CompanionConsole {
@@ -81,68 +18,50 @@ impl CompanionConsole {
pub fn status(&self) -> CompanionStatusResponse {
CompanionStatusResponse {
state: CompanionState::Disabled,
state: CompanionLifecycleState::Stopped,
worker: None,
transport: disabled_transport(),
transport: CompanionTransportSummary {
mode: "disabled".to_string(),
available: false,
},
diagnostics: vec![disabled_diagnostic()],
}
}
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
CompanionTranscriptProjection {
state: CompanionState::Disabled,
state: CompanionLifecycleState::Stopped,
start,
limit,
total_items: 0,
next_start: None,
total: 0,
next: None,
items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
}
}
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
disabled_message_response(CompanionState::Rejected)
disabled_message_response()
}
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 {
state,
worker: None,
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(),
}
}
fn disabled_diagnostic() -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: "companion_disabled".to_string(),
severity: DiagnosticSeverity::Info,
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
state: CompanionMessageDisposition::Rejected,
message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead."
.to_string(),
}
}
fn disabled_diagnostic() -> Diagnostic {
Diagnostic {
code: "companion_disabled".to_string(),
severity: DiagnosticSeverity::Info,
message:
"Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead."
.to_string(),
}
}
+6 -6
View File
@@ -42,19 +42,19 @@ pub use authority::{
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
};
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
pub use server::{
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
build_workspace_server_router, serve, serve_workspace_catalog,
};
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
pub use workspace_catalog::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest,
pub use workspace_api::{
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary,
WorkspaceCreateResponse,
};
pub use workspace_catalog::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult,
};
use worker_runtime::identity::RuntimeWorkerRef;
+16 -66
View File
@@ -4,8 +4,10 @@ use std::{
process::Command,
};
use serde::{Deserialize, Serialize};
use workspace_api::{RepositoryObservedStatus, RepositorySource};
use workspace_api::{
Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary,
};
pub type RepositoryId = String;
pub type RepositorySelector = String;
@@ -24,74 +26,19 @@ pub struct ConfiguredRepository {
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)]
pub struct RepositoryListProjection {
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 repository_id: RepositoryId,
pub default_selector: Option<RepositorySelector>,
pub limit: usize,
pub commits: Vec<GitCommitSummary>,
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[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>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -131,8 +78,8 @@ impl RepositoryRegistryReader {
if self.repositories.is_empty() {
return RepositoryListProjection {
items: Vec::new(),
diagnostics: vec![RepositoryDiagnostic {
severity: "warning".to_string(),
diagnostics: vec![Diagnostic {
severity: DiagnosticSeverity::Warning,
code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend."
.to_string(),
@@ -177,8 +124,8 @@ impl RepositoryRegistryReader {
let commits = match self.git_log(repository, limit) {
Ok(commits) => commits,
Err(message) => {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
code: "repository_git_log_unavailable".to_string(),
message,
});
@@ -379,7 +326,7 @@ impl RepositoryRegistryReader {
default_selector: repository.default_selector.clone(),
record_authority: "workspace-control-plane".to_string(),
git,
diagnostics,
diagnostics: (!diagnostics.is_empty()).then_some(diagnostics),
}
}
@@ -675,7 +622,10 @@ mod tests {
RepositoryObservedStatus::Unverified
);
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 error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
+266 -169
View File
@@ -48,10 +48,7 @@ use workdir::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
};
use workdir::workspace::{
MaterializerKind, WorkingDirectoryCleanupTarget,
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
WorkingDirectoryDiagnostic, WorkingDirectoryDiagnosticSeverity,
WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryOccupancy,
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy,
WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
@@ -65,9 +62,17 @@ use workspace_api::{
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse,
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
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,
};
@@ -116,7 +121,7 @@ use crate::records::{
};
use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
RepositoryRegistryReader, RepositorySummary,
RepositoryRegistryReader,
};
use crate::repository_access::{
RepositoryAccessConfigSchemaProvider, RepositorySecretService,
@@ -152,17 +157,7 @@ use worker_runtime::identity::{RuntimeWorkerRef, WorkerId};
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
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,
},
}
pub use workspace_api::WorkspaceAuthConfig as AuthConfig;
#[derive(Clone)]
pub struct ServerConfig {
@@ -989,7 +984,9 @@ async fn list_server_workspaces(
let owner = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => Some(actor.account_id),
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(),
Err(error) => return server_error_response(error),
},
@@ -999,7 +996,10 @@ async fn list_server_workspaces(
.catalog
.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),
}
}
@@ -1031,7 +1031,48 @@ async fn create_server_workspace(
} else {
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(
@@ -2743,31 +2784,6 @@ pub async fn serve(
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)]
pub struct ListResponse<T> {
pub workspace_id: String,
@@ -3003,18 +3019,6 @@ pub struct WorkingDirectoryRepositoryOption {
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)]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkerWorkingDirectorySelection {
@@ -3071,32 +3075,6 @@ pub struct BrowserCreateWorkerResponse {
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)]
struct LogQuery {
limit: Option<usize>,
@@ -3361,11 +3339,12 @@ async fn scoped_get_flow(
}
async fn scoped_get_workspace(
headers: HeaderMap,
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<WorkspaceResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
get_workspace(State(api)).await
get_workspace(headers, State(api)).await
}
async fn scoped_get_workspace_settings(
@@ -8947,15 +8926,15 @@ async fn scoped_get_worker_launch_options(
fn working_directory_diagnostics(
diagnostics: Vec<RuntimeDiagnostic>,
) -> Vec<WorkingDirectoryDiagnostic> {
) -> Vec<workspace_api::Diagnostic> {
diagnostics
.into_iter()
.map(|diagnostic| WorkingDirectoryDiagnostic {
.map(|diagnostic| workspace_api::Diagnostic {
code: diagnostic.code,
severity: match diagnostic.severity {
DiagnosticSeverity::Info => WorkingDirectoryDiagnosticSeverity::Info,
DiagnosticSeverity::Warning => WorkingDirectoryDiagnosticSeverity::Warning,
DiagnosticSeverity::Error => WorkingDirectoryDiagnosticSeverity::Error,
DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
},
message: diagnostic.message,
})
@@ -8979,7 +8958,7 @@ async fn scoped_create_runtime_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory(
&api,
&path.workspace_id,
@@ -9022,7 +9001,7 @@ async fn scoped_create_working_directory(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserWorkingDirectoryCreateRequest>,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
create_workspace_working_directory(&api, &path.workspace_id, None, request).await
}
@@ -9115,7 +9094,7 @@ async fn create_workspace_working_directory(
workspace_id: &str,
route_runtime_id: Option<&str>,
request: BrowserWorkingDirectoryCreateRequest,
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryDetailResponse>)> {
) -> ApiResult<(StatusCode, Json<BrowserWorkingDirectoryCreateResponse>)> {
validate_workspace_scope(api, workspace_id)?;
if let (Some(route_runtime_id), Some(request_runtime_id)) =
(route_runtime_id, request.runtime_id.as_deref())
@@ -9278,7 +9257,17 @@ async fn create_workspace_working_directory(
&reserved.resolved_runtime_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
@@ -9457,7 +9446,7 @@ async fn create_workspace_working_directory(
apply_workdir_occupancy_projection(api, &mut summary)?;
Ok((
StatusCode::CREATED,
Json(BrowserWorkingDirectoryDetailResponse {
Json(BrowserWorkingDirectoryCreateResponse {
workspace_id: workspace_id.to_string(),
runtime_id: reserved.resolved_runtime_id,
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 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
.as_ref()
.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(),
schema_version,
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(),
event_stream: ExtensionPointState {
event_stream: WorkspaceExtensionPointState {
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(),
diagnostics: Vec::new(),
},
host_worker_bridge: ExtensionPointState {
host_worker_bridge: WorkspaceExtensionPointState {
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(),
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 {
let completion = status.transport.completion.clone();
let note = match completion.as_str() {
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(),
"not_input_capable" => {
let diagnostic_codes = status
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>()
.join(", ");
if diagnostic_codes.is_empty() {
"Workspace Companion is not input-capable; check provider, config, profile, secret, and authority diagnostics.".to_string()
} else {
format!(
"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."
),
fn companion_console_extension_point(
status: &CompanionStatusResponse,
) -> WorkspaceExtensionPointState {
let extension_status = match status.state {
workspace_api::CompanionLifecycleState::Idle => "idle",
workspace_api::CompanionLifecycleState::Running => "running",
workspace_api::CompanionLifecycleState::Stopped => "stopped",
}
.to_string();
let diagnostic_codes = status
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>()
.join(", ");
let note = if status.transport.available {
"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 {
format!("Workspace Companion is unavailable; check typed diagnostics: {diagnostic_codes}.")
};
ExtensionPointState {
status: completion,
WorkspaceExtensionPointState {
status: extension_status,
note,
diagnostics: status.diagnostics.clone(),
}
@@ -11313,7 +11317,7 @@ async fn list_repositories(
workspace_id: api.config.workspace_id,
items,
source: "workspace-control-plane".to_string(),
diagnostics: repository_diagnostics(diagnostics),
diagnostics,
}))
}
@@ -11350,7 +11354,7 @@ async fn repository_log(
default_selector,
limit,
items: commits,
diagnostics: repository_diagnostics(diagnostics),
diagnostics,
}))
}
@@ -14261,7 +14265,8 @@ fn merge_worker_registry_projection(
.map(|workdir| {
let mut workdir_summary = workdir_summary_from_record(workdir);
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(),
linked_at: link.linked_at.clone(),
});
@@ -14610,7 +14615,8 @@ fn apply_workdir_occupancy_projection(
})?;
summary.primary_worker_id = None;
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,
linked_at: link.linked_at.clone(),
});
@@ -15057,23 +15063,6 @@ fn sanitize_backend_error(message: &str) -> 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> {
result.map_err(|error| match error {
RepositoryLookupError::UnknownRepository { id } => {
@@ -16110,7 +16099,8 @@ mod tests {
assert_eq!(working_directory.current_selector, None);
assert_eq!(working_directory.current_ref.as_deref(), Some("fedcba"));
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());
let occupancy = serde_json::to_value(occupied_by).unwrap();
assert_eq!(occupancy["runtime_id"], "embedded");
@@ -17315,10 +17305,8 @@ mod tests {
.find(|summary| summary.working_directory_id == "managed")
.unwrap();
let occupied_by = managed.occupied_by.as_ref().unwrap();
assert_eq!(
occupied_by.worker,
RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7")
);
assert_eq!(occupied_by.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
assert_eq!(occupied_by.worker_id, "7");
assert_eq!(occupied_by.display_name, "Worker Seven");
assert_eq!(occupied_by.linked_at, "3");
@@ -17928,6 +17916,12 @@ mod tests {
.await
.unwrap();
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
.clone()
@@ -17952,6 +17946,15 @@ mod tests {
.await
.unwrap();
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"] {
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]
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -24188,6 +24262,9 @@ mod tests {
let workspace = get_json(app.clone(), "/api/workspace").await;
assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID);
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["extension_points"]["host_worker_bridge"]["status"],
@@ -24374,6 +24451,9 @@ mod tests {
);
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]["kind"], "git");
assert_eq!(
@@ -24387,6 +24467,8 @@ mod tests {
);
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);
let scoped_repository_detail = get_json(
app.clone(),
@@ -24396,6 +24478,8 @@ mod tests {
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 _: workspace_api::RepositoryLogResponse =
serde_json::from_value(repository_log.clone()).unwrap();
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(repository_log["default_selector"], "HEAD");
assert_eq!(repository_log["limit"], 3);
@@ -24460,10 +24544,10 @@ mod tests {
);
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_eq!(companion_status["transport"]["kind"], "none");
assert_eq!(companion_status["transport"]["completion"], "disabled");
assert_eq!(companion_status["transport"]["mode"], "disabled");
assert_eq!(companion_status["transport"]["available"], false);
assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json(
@@ -24473,16 +24557,26 @@ mod tests {
)
.await;
assert_eq!(companion_message["state"], "rejected");
assert_eq!(
companion_message["diagnostics"][0]["code"],
"companion_disabled"
);
assert!(companion_message["user_item"].is_null());
assert!(companion_message["assistant_item"].is_null());
assert!(companion_message.get("accepted").is_none());
assert!(companion_message.get("diagnostics").is_none());
assert!(companion_message.get("user_item").is_none());
assert!(companion_message.get("assistant_item").is_none());
assert!(!companion_message.to_string().contains("/workspace/demo"));
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;
assert!(
@@ -24587,7 +24681,7 @@ mod tests {
let workspace = get_json(app.clone(), "/api/workspace").await;
let workspace_companion = &workspace["extension_points"]["companion_console"];
assert_eq!(workspace_companion["status"], "disabled");
assert_eq!(workspace_companion["status"], "stopped");
assert_eq!(
workspace_companion["diagnostics"][0]["code"],
"companion_disabled"
@@ -24596,12 +24690,13 @@ mod tests {
workspace_companion["note"]
.as_str()
.unwrap()
.contains("auto-start has been removed")
.contains("typed diagnostics")
);
let status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(status["state"], "disabled");
assert_eq!(status["transport"]["completion"], "disabled");
assert_eq!(status["state"], "stopped");
assert_eq!(status["transport"]["mode"], "disabled");
assert_eq!(status["transport"]["available"], false);
assert!(status["worker"].is_null());
let response = post_json(
@@ -24611,13 +24706,14 @@ mod tests {
)
.await;
assert_eq!(response["state"], "rejected");
assert_eq!(response["diagnostics"][0]["code"], "companion_disabled");
assert!(response["user_item"].is_null());
assert!(response["assistant_item"].is_null());
assert!(response.get("accepted").is_none());
assert!(response.get("diagnostics").is_none());
assert!(response.get("user_item").is_none());
assert!(response.get("assistant_item").is_none());
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
assert_eq!(transcript["state"], "disabled");
assert_eq!(transcript["total_items"], 0);
assert_eq!(transcript["state"], "stopped");
assert_eq!(transcript["total"], 0);
let workers = get_json(app, "/api/workers").await;
assert!(
@@ -25744,7 +25840,8 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
cleanliness: Some("clean".to_string()),
primary_worker_id: None,
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(),
linked_at: "2026-08-12T00:00:00Z".to_string(),
}),
@@ -35,8 +35,8 @@ pub struct WorkspaceCreateRequest {
pub repository: InitialRepositoryIntent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceCreateResponse {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub config_revision: u64,
@@ -81,7 +81,7 @@ impl WorkspaceCatalogService {
&self,
request: WorkspaceCreateRequest,
owner_account_id: String,
) -> Result<WorkspaceCreateResponse> {
) -> Result<WorkspaceCreateResult> {
self.create_internal(request, owner_account_id, None)
}
@@ -90,7 +90,7 @@ impl WorkspaceCatalogService {
request: WorkspaceCreateRequest,
owner_account_id: String,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
) -> Result<WorkspaceCreateResult> {
let operation_key = normalize_required(
"operation_key",
request.operation_key,
@@ -165,7 +165,7 @@ impl WorkspaceCatalogService {
updated_at: now,
},
})?;
Ok(WorkspaceCreateResponse {
Ok(WorkspaceCreateResult {
workspace: result.workspace,
repository: result.repository,
config_revision: result.config_revision,
+1 -1
View File
@@ -6,7 +6,7 @@
"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",
"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",
"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,
path: string,
init?: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
): Promise<ApiResult<T>> {
try {
const response = await fetchFn(path, init);
@@ -123,7 +124,8 @@ export async function loadJson<T>(
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) {
return {
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 = {
workspace_id: string;
owner_account_id: string | null;
display_name: string;
state: string;
created_at: string;
updated_at: string;
};
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;
};
import {
parseRepositoryListResponse,
parseWorkspaceCatalogResponse,
parseWorkspaceCreateResponse,
type RepositorySummary,
type WorkspaceCreateResponse,
type WorkspaceSummary,
} from "$lib/workspace/api/workspace-model";
export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
repositories: WorkspaceRepositoryRecord[];
repositories: RepositorySummary[];
repository_error?: string;
};
export type CreateWorkspaceResponse = WorkspaceCreateResponse;
export type CreateWorkspaceRequest = {
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 {
constructor(
public readonly status: number | null,
@@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch;
export async function listWorkspaces(
fetcher: Fetch,
): Promise<WorkspaceCatalogRecord[]> {
return await fetchJson<WorkspaceCatalogRecord[]>(
fetcher,
"/api/workspaces?limit=200",
return parseWorkspaceCatalogResponse(
await fetchJson(fetcher, "/api/workspaces?limit=200"),
);
}
export async function listWorkspaceRepositories(
fetcher: Fetch,
workspaceId: string,
): Promise<WorkspaceRepositoryRecord[]> {
return await fetchJson<WorkspaceRepositoryRecord[]>(
fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
);
): Promise<RepositorySummary[]> {
return parseRepositoryListResponse(
await fetchJson(
fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
),
).items;
}
export async function loadWorkspaceCatalog(
@@ -115,11 +85,13 @@ export async function createWorkspace(
fetcher: Fetch,
request: CreateWorkspaceRequest,
): Promise<CreateWorkspaceResponse> {
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
return parseWorkspaceCreateResponse(
await fetchJson(fetcher, "/api/workspaces", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
);
}
export function creationErrorMessage(error: unknown): string {
@@ -150,11 +122,11 @@ export function createOperationKey(): string {
}`;
}
async function fetchJson<T>(
async function fetchJson(
fetcher: Fetch,
input: string,
init?: RequestInit,
): Promise<T> {
): Promise<unknown> {
let response: Response;
try {
response = await fetcher(input, init);
@@ -172,7 +144,7 @@ async function fetchJson<T>(
}
throw new WorkspaceCatalogError(response.status, detail);
}
return await response.json() as T;
return await response.json() as unknown;
}
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 {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
} 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 ExtensionPoint = {
status: string;
note: string;
diagnostics: Diagnostic[];
};
export type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
extension_points: {
event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint;
companion_console: ExtensionPoint;
};
export type {
PodProtocolEvent,
PodProtocolMethod,
PodProtocolSegment,
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
};
export type WorkspaceResponse = SharedWorkspaceResponse;
export type Diagnostic = {
code: string;
@@ -111,44 +123,6 @@ export type WorkingDirectoryRepositoryOption = {
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 =
| "worker_delete"
| "workdir_clean_cleanup"
@@ -217,12 +191,6 @@ export type BrowserWorkerWorkingDirectorySelection = {
relative_cwd?: string | null;
};
export type BrowserWorkingDirectoryCreateRequest = {
runtime_id: string;
repository_id: string;
selector?: string | null;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
@@ -257,70 +225,13 @@ export type ListResponse<T> = {
diagnostics: Diagnostic[];
};
export type RepositorySummary = {
id: string;
display_name: string;
kind: string;
provider: string;
source: {
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
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 RepositorySummary = SharedRepositorySummary;
export type GitRepositorySummary = SharedGitRepositorySummary;
export type GitRemoteSummary = SharedGitRemoteSummary;
export type GitCommitSummary = SharedGitCommitSummary;
export type RepositoryListResponse = SharedRepositoryListResponse;
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
export type RepositoryLogResponse = SharedRepositoryLogResponse;
export type MemoryDocumentResponse = {
body_md: string;
@@ -448,56 +359,15 @@ export type ObjectiveListResponse = {
record_authority: string;
};
export type CompanionState =
| "ready"
| "busy"
| "error"
| "timeout"
| "cancelled"
| "accepted"
| "rejected";
export type CompanionTransportSummary = {
kind: string;
completion: string;
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[];
};
export type {
CompanionCancelRequest,
CompanionLifecycleState,
CompanionMessageDisposition,
CompanionMessageRequest,
CompanionMessageResponse,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
CompanionTranscriptRole,
CompanionTransportSummary,
} from "$lib/generated/companion-api";
+2 -2
View File
@@ -132,9 +132,9 @@
<code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]}
<span class="workspace-repository-summary">
{workspace.repositories[0].name}
{workspace.repositories[0].display_name}
<small>
{workspace.repositories[0].default_ref ?? "repository default"} ·
{workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind}
</small>
</span>
@@ -1,34 +1,51 @@
import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types";
import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(
fetch,
workspaceApiPath(workspaceId, "/workspace"),
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(workspaceId, "/repositories"),
),
const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
loadJson<unknown>(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, {
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 {
workspace: workspace.data,
workspace,
workspaceError: null,
repositories: repositories.data,
repositoriesError: repositories.error,
repositories,
repositoriesError,
};
};
@@ -1,29 +1,59 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
RepositoryDetailResponse,
RepositoryLogResponse,
} from "$lib/workspace/sidebar/types";
import {
parseRepositoryDetailResponse,
parseRepositoryLogResponse,
} from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const workspaceId = params.workspaceId;
const repositoryId = params.repositoryId;
const [repository, log] = await Promise.all([
loadJson<RepositoryDetailResponse>(
const [repositoryResult, logResult] = await Promise.all([
loadJson<unknown>(
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}`,
),
),
loadJson<RepositoryLogResponse>(
loadJson<unknown>(
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 {
repositoryId,
repository: repository.data,
repositoryError: repository.error,
repositoryLog: log.data,
repositoryLogError: log.error,
repository,
repositoryError,
repositoryLog: log,
repositoryLogError: logError,
};
};
@@ -1,11 +1,24 @@
<script lang="ts">
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 { RepositorySshCredential, RepositorySshHostTrust } from './+page';
let { data }: PageProps = $props();
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
const accessProjection = untrack(() => data.accessProjection);
let message = $state<string | null>(null);
let pending = $state(false);
@@ -29,37 +42,52 @@
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}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let detail = `request failed (${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);
throw new Error(`Repository Access request failed with status ${response.status}.`);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
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.');
}
return parse(payload);
}
async function createCredential() {
pending = true;
message = null;
try {
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
const body: CreateRepositorySshCredentialRequest = {
operation_id: operationId('credential-create'),
credential_id: credentialId,
name: credentialName,
private_key: privateKey,
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));
credentialId = '';
credentialName = '';
@@ -77,15 +105,17 @@
pending = true;
message = null;
try {
const body: RotateRepositorySshCredentialRequest = {
operation_id: operationId('credential-rotate'),
expected_revision: credential.current_revision,
private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null
};
const rotated = await request<RepositorySshCredential>(
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
{
operation_id: operationId('credential-rotate'),
expected_revision: credential.current_revision,
private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null
}
body,
parseRepositorySshCredential
);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
rotateCredentialId = null;
@@ -104,10 +134,16 @@
pending = true;
message = null;
try {
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
const body: DeleteRepositorySshCredentialRequest = {
operation_id: operationId('credential-delete'),
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);
message = `Credential ${credential.credential_id} deleted.`;
} catch (error) {
@@ -121,14 +157,20 @@
pending = true;
message = null;
try {
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
const body: PutRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-create'),
host_trust_id: hostTrustId,
hostname,
port,
host_key: hostKey,
expected_revision: hostExpectedRevision
});
};
const created = await request<RepositorySshHostTrust>(
'/host-trusts',
'POST',
body,
parseRepositorySshHostTrust
);
hostTrusts = hostExpectedRevision === null
? [...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);
@@ -158,10 +200,16 @@
pending = true;
message = null;
try {
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
const body: DeleteRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-delete'),
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);
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
} 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>
{#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">
<h3>SSH credentials</h3>
{#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 { 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 }) => {
const base = `/api/w/${
encodeURIComponent(params.workspaceId)
}/settings/repository-access`;
const [credentialResult, hostTrustResult] = await Promise.all([
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
const workspaceId = params.workspaceId;
const accessProjection = await loadRepositoryAccessJson(
fetch,
workspaceApiPath(workspaceId, "/settings/repository-access"),
parseRepositoryAccessProjection,
);
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,
),
]);
if (!credentialResult.data || !hostTrustResult.data) {
throw new Error(
credentialResult.error ?? hostTrustResult.error ??
"Repository access settings unavailable",
);
}
return {
workspaceId: params.workspaceId,
credentials: credentialResult.data,
hostTrusts: hostTrustResult.data,
workspaceId,
credentials,
hostTrusts,
accessProjection,
};
};
@@ -1,6 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
import type {
BrowserWorkingDirectoryListResponse,
ListResponse,
Runtime,
RuntimeCleanupPlanResponse,
@@ -14,12 +14,14 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
),
loadJson<BrowserWorkingDirectoryListResponse>(
loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
),
undefined,
parseWorkingDirectoryListResponse,
),
loadJson<RuntimeCleanupPlanResponse>(
fetch,
@@ -1,14 +1,12 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type {
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
@@ -17,9 +15,9 @@ export const load = (async ({ fetch, params }) => {
params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositories, orchestrator] = await Promise.all([
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(
loadJson<unknown>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
@@ -42,6 +40,8 @@ export const load = (async ({ fetch, params }) => {
);
}
}
const repositories = parseRepositoryListApiResult(repositoriesRaw);
return {
workspaceId: params.workspaceId,
ticketId: ticket.data?.id ?? reference,
@@ -2,11 +2,14 @@
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http';
import {
parseWorkingDirectoryCreateResponse,
validateWorkingDirectoryCreateRequest,
} from '$lib/workspace/api/workdirs';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic,
WorkerLaunchOptionsResponse,
WorkingDirectorySummary,
@@ -160,22 +163,23 @@
creatingWorkingDirectory = true;
submitError = null;
try {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
});
const response = await fetch(
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
}),
body: JSON.stringify(request),
},
);
if (!response.ok) {
submitError = await responseDisplayError(response, 'workdir create failed');
return;
}
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
const payload = parseWorkingDirectoryCreateResponse(await response.json());
const items = options?.working_directories ?? [];
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,
),
);
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", () => {
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")) {
return Promise.resolve(Response.json([{
return Promise.resolve(Response.json({
workspace_id: "w-a",
repository_id: "main",
name: "Main",
kind: "local_path",
uri: "/srv/alpha",
default_ref: "develop",
}]));
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",
default_selector: "develop",
record_authority: "workspace-control-plane",
}],
source: "workspace-control-plane",
diagnostics: [],
}));
}
return Promise.resolve(new Response("unavailable", { status: 503 }));
};
const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
assertEquals(items.length, 2);
assertEquals(items[0].repositories[0].repository_id, "main");
assertEquals(items[0].repositories[0].id, "main");
assertEquals(items[1].repositories, []);
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",
);
});