36 Commits
Author SHA1 Message Date
Hare 5cc78d63c6 fix: merge Companion repository response validation 2026-09-01 13:21:13 +09:00
Hare 323f5dc09c fix: validate repository settings wire response 2026-09-01 13:13:01 +09:00
Hare fb97edfe95 fix: validate workspace schema before owner migration 2026-09-01 12:41:14 +09:00
Hare 070f62ef12 fix: harden workspace owner migration preflight 2026-09-01 12:22:29 +09:00
Hare 981749aa3d feat: require account owners for workspaces 2026-09-01 12:10:52 +09:00
Hare e01b46b30a chore: shard Cargo build cache by workspace 2026-09-01 09:33:47 +09:00
Hare a1b659c45d chore: merge develop into hare/develop
# Conflicts:
#	crates/client/src/lib.rs
#	web/workspace/deno.json
2026-09-01 09:15:10 +09:00
Hare 37a012ef92 chore: configure shared Cargo build caching 2026-09-01 09:04:00 +09:00
Hare 4927e8a843 chore: merge current develop into workspace catalog DTO source
# Conflicts:
#	crates/workspace-api/src/lib.rs
#	crates/workspace-server/src/server.rs
#	web/workspace/deno.json
#	web/workspace/src/lib/workspace/sidebar/types.ts
2026-09-01 08:17:25 +09:00
Hare 025d6ddb47 refactor: make workspace API own workdir projections 2026-09-01 03:16:31 +09:00
Hare 01a4dfd5d3 fix: remove unused workspace API dependency 2026-09-01 02:52:11 +09:00
Hare 1d7158a0bf chore: merge current develop into T-563 source 2026-09-01 02:39:08 +09:00
Hare 9de2afbfc6 fix: validate ticket repository summaries 2026-09-01 02:37:44 +09:00
Hare 9013754a3a refactor: name the workspace catalog list response 2026-09-01 02:24:11 +09:00
Hare 21eea0b104 fix: consume shared workdir create contract 2026-09-01 02:14:57 +09:00
Hare 15e8d7365c test: run workspace DTO boundary regressions 2026-09-01 02:05:07 +09:00
Hare 88e3bf7065 fix: preserve optional workdir wire fields 2026-09-01 02:00:19 +09:00
Hare 2765138bf3 chore: merge current develop into Companion DTO work
# Conflicts:
#	web/workspace/deno.json
2026-09-01 01:59:39 +09:00
Hare 6b20ceac46 fix: validate workspace repository browser payloads 2026-09-01 01:49:38 +09:00
Hare d748274905 refactor: centralize workspace repository response DTOs 2026-09-01 01:49:25 +09:00
Hare e1578217d5 fix: preserve workdir status display contract 2026-09-01 01:45:32 +09:00
Hare 3481682cb4 feat: validate shared workdir REST contracts in web 2026-09-01 01:42:17 +09:00
Hare 996b7f2468 refactor: centralize workdir REST DTOs 2026-09-01 01:42:05 +09:00
Hare 879993b9b1 fix: accept zero-length Companion transcripts 2026-09-01 01:41:16 +09:00
Hare 6604154e3f test: cover repository access loader failures 2026-09-01 01:39:55 +09:00
Hare a9ad42a970 fix: align Companion REST DTO contracts 2026-09-01 01:29:04 +09:00
Hare 8b3d1302c6 fix: share repository access API contracts 2026-09-01 01:22:27 +09:00
Hare ac9269d6ce fix: omit local bash spill path from remote commands 2026-08-31 23:14:33 +09:00
Hare 8ffb716817 fix: preserve workdir operation error classifications 2026-08-31 23:14:23 +09:00
Hare 23f671fa48 fix: remove redundant settings overview 2026-08-31 20:09:29 +09:00
Hare d7cdcde443 fix: preserve standalone scope conflict details 2026-08-31 19:28:09 +09:00
Hare 95a81faf63 chore: merge hare/develop into companion work 2026-08-31 18:45:32 +09:00
Hare bb8bb6d099 feat: restore interactive standalone profile selection 2026-08-31 18:43:46 +09:00
Hare 310801a29b feat: add repository settings registration 2026-08-31 18:20:17 +09:00
Hare 7d09b20445 feat: add workspace repository registration API 2026-08-31 18:12:08 +09:00
Hare 456a06f194 fix: propagate browser actor to workspace routes 2026-08-31 16:55:45 +09:00
72 changed files with 6539 additions and 1334 deletions
+3
View File
@@ -0,0 +1,3 @@
[build]
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
incremental = false
Generated
+1 -2
View File
@@ -650,7 +650,6 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"uuid", "uuid",
"workdir",
"workspace-api", "workspace-api",
] ]
@@ -6594,6 +6593,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"workspace-api",
] ]
[[package]] [[package]]
@@ -6687,7 +6687,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"ts-rs", "ts-rs",
"workdir",
] ]
[[package]] [[package]]
-1
View File
@@ -18,7 +18,6 @@ tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync"
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
workspace-api.workspace = true workspace-api.workspace = true
workdir = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }
+7 -3
View File
@@ -5,7 +5,6 @@ use std::fmt;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
@@ -14,6 +13,11 @@ pub use workspace_api::{
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -526,8 +530,8 @@ mod tests {
.unwrap() .unwrap()
.occupied_by .occupied_by
.expect("occupied Workdir"); .expect("occupied Workdir");
assert_eq!(occupied_by.worker.runtime_id, "arcadia"); assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload; let mut stale = payload;
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
+8 -35
View File
@@ -2,19 +2,16 @@ use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
WorkspaceSummary,
};
const DEFAULT_WORKSPACE_LIMIT: usize = 200; const DEFAULT_WORKSPACE_LIMIT: usize = 200;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub type BackendWorkspace = WorkspaceSummary;
pub struct BackendWorkspace { pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub workspace_id: String, pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
pub owner_account_id: Option<String>,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -32,30 +29,6 @@ pub struct CreateBackendWorkspaceRepository {
pub default_ref: Option<String>, pub default_ref: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceResponse {
pub workspace: BackendWorkspace,
pub repository: CreateBackendWorkspaceRepositoryRecord,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceRepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub provider: Option<String>,
pub source: RepositorySource,
pub default_ref: Option<String>,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
pub base_url: String, pub base_url: String,
@@ -118,7 +91,7 @@ async fn list_backend_workspaces_with_client(
.send() .send()
.await?; .await?;
client.check_status(response.status())?; client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?) Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
} }
pub async fn create_backend_workspace( pub async fn create_backend_workspace(
+6 -1
View File
@@ -39,5 +39,10 @@ pub use target::{
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
};
pub use workspace_product::BackendWorkspaceProductClient; pub use workspace_product::BackendWorkspaceProductClient;
+22 -1
View File
@@ -4,6 +4,7 @@ use std::time::Duration;
use agen::llm_client::client::LlmClient; use agen::llm_client::client::LlmClient;
use client::Client; use client::Client;
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket}; use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
use manifest::ScopeRule;
use protocol::stream::{decode_method, encode_event}; use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerId}; use protocol::{Event, Method, WorkerId};
use session_store::{ use session_store::{
@@ -18,6 +19,7 @@ use worker::ipc::protocol_session::{
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
subscribe_worker_protocol_session, subscribe_worker_protocol_session,
}; };
use worker::runtime::worker_allocation::ScopeLockError;
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
use crate::launch::ResolvedStandaloneLaunch; use crate::launch::ResolvedStandaloneLaunch;
@@ -43,7 +45,7 @@ pub struct StandaloneHost {
lease: Option<StandaloneWorkerLease>, lease: Option<StandaloneWorkerLease>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StandaloneStartupError { pub enum StandaloneStartupError {
#[error("the standalone state store could not be opened or validated")] #[error("the standalone state store could not be opened or validated")]
StateStore, StateStore,
@@ -53,6 +55,16 @@ pub enum StandaloneStartupError {
LeaseLivenessUnknown, LeaseLivenessUnknown,
#[error("the standalone Worker working directory is unavailable or changed")] #[error("the standalone Worker working directory is unavailable or changed")]
WorkingDirectoryUnavailable, WorkingDirectoryUnavailable,
#[error(
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
requested_rule.target.display(),
competitor_rule.target.display()
)]
ScopeConflict {
competitor: String,
requested_rule: ScopeRule,
competitor_rule: ScopeRule,
},
#[error("the resolved Worker configuration or persisted history is invalid")] #[error("the resolved Worker configuration or persisted history is invalid")]
WorkerConfiguration, WorkerConfiguration,
#[error("the configured model provider is unavailable")] #[error("the configured model provider is unavailable")]
@@ -509,6 +521,15 @@ fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartu
fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError { fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError {
match error { match error {
WorkerBootstrapError::Worker(WorkerError::ScopeLock(ScopeLockError::WriteConflict {
competitor,
rule,
competitor_rule,
})) => StandaloneStartupError::ScopeConflict {
competitor,
requested_rule: rule,
competitor_rule,
},
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => { WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
StandaloneStartupError::ModelProvider StandaloneStartupError::ModelProvider
} }
+59
View File
@@ -164,6 +164,65 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
host.shutdown().await.expect("graceful shutdown"); host.shutdown().await.expect("graceful shutdown");
} }
#[tokio::test]
async fn startup_preserves_occupied_scope_conflict_details() {
let temp = tempfile::tempdir().expect("tempdir");
let cwd = temp.path().join("project");
std::fs::create_dir(&cwd).expect("create project");
let first_launch = StandaloneLaunchConfig::new(
&cwd,
temp.path().join("first-state"),
manifest::ProfileSelector::Default,
"first-worker",
)
.resolve()
.expect("resolve first launch");
let first_host =
StandaloneHost::start_with_model_client(first_launch, ScriptedClient::new(Vec::new()))
.await
.expect("start first host");
let competitor = first_host.record().storage_key.clone();
let second_launch = StandaloneLaunchConfig::new(
&cwd,
temp.path().join("second-state"),
manifest::ProfileSelector::Default,
"second-worker",
)
.resolve()
.expect("resolve second launch");
let error =
StandaloneHost::start_with_model_client(second_launch, ScriptedClient::new(Vec::new()))
.await
.err()
.expect("occupied scope rejected");
first_host.shutdown().await.expect("shutdown first host");
let canonical_cwd = cwd.canonicalize().expect("canonical cwd");
match &error {
StandaloneStartupError::ScopeConflict {
competitor: actual_competitor,
requested_rule,
competitor_rule,
} => {
assert_eq!(actual_competitor, &competitor);
assert_eq!(requested_rule.target, canonical_cwd);
assert_eq!(competitor_rule.target, canonical_cwd);
}
other => panic!("expected scope conflict, got {other:?}"),
}
assert_eq!(
error.to_string(),
format!(
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
canonical_cwd.display(),
canonical_cwd.display()
)
);
}
#[tokio::test] #[tokio::test]
async fn state_store_failure_is_redacted_and_starts_no_controller() { async fn state_store_failure_is_redacted_and_starts_no_controller() {
let temp = tempfile::tempdir().expect("tempdir"); let temp = tempfile::tempdir().expect("tempdir");
+1
View File
@@ -42,6 +42,7 @@ impl From<ToolsError> for ToolError {
workdir::WorkdirError::NotFound(_) workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_), | workdir::WorkdirError::Transport(_),
) => ToolError::ExecutionFailed(err.to_string()), ) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
+9 -4
View File
@@ -16,6 +16,7 @@ mod markdown;
mod scroll; mod scroll;
pub mod setup_model; pub mod setup_model;
mod standalone_picker; mod standalone_picker;
mod standalone_spawn;
mod task; mod task;
mod text_selection; mod text_selection;
mod tool; mod tool;
@@ -136,17 +137,21 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
profile, profile,
} => match target.spawn_worker() { } => match standalone_spawn::select(&workspace_root, worker_name, profile) {
Ok(Some(selection)) => match target.spawn_worker() {
Ok(spawn) => { Ok(spawn) => {
console::run_standalone( console::run_standalone(
workspace_root.clone(), workspace_root.clone(),
spawn.state_dir, spawn.state_dir,
worker_name, Some(selection.worker_name),
profile, Some(selection.profile),
) )
.await .await
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
},
Ok(None) => Ok(()),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}, },
LaunchMode::StandaloneResume { include_all } => { LaunchMode::StandaloneResume { include_all } => {
match standalone_picker::pick(target.as_ref(), include_all) { match standalone_picker::pick(target.as_ref(), include_all) {
+494
View File
@@ -0,0 +1,494 @@
use std::io::{self, Stdout};
use std::path::Path;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use thiserror::Error;
const VIEWPORT_HEIGHT: u16 = 6;
const FALLBACK_WORKER_NAME: &str = "worker";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StandaloneSpawnSelection {
pub worker_name: String,
pub profile: String,
}
#[derive(Debug, Error)]
pub(crate) enum StandaloneSpawnError {
#[error("profile discovery failed: {0}")]
ProfileDiscovery(#[from] manifest::ProfileError),
#[error("no profiles are available")]
NoProfiles,
#[error("standalone spawn picker terminal error: {0}")]
Terminal(#[from] io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileChoice {
selector: String,
label: String,
is_default: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusKind {
Info,
Progress,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnAction {
None,
Submit,
Cancel,
}
struct SpawnForm {
worker_name: String,
cursor: usize,
profile_choices: Vec<ProfileChoice>,
selected_profile: usize,
status: Option<(String, StatusKind)>,
}
impl SpawnForm {
fn new(
worker_name: Option<String>,
default_worker_name: String,
profile_choices: Vec<ProfileChoice>,
) -> Self {
let worker_name = worker_name.unwrap_or(default_worker_name);
let cursor = worker_name.chars().count();
let selected_profile = profile_choices
.iter()
.position(|choice| choice.is_default)
.unwrap_or(0);
Self {
worker_name,
cursor,
profile_choices,
selected_profile,
status: None,
}
}
fn selected_profile(&self) -> &ProfileChoice {
&self.profile_choices[self.selected_profile]
}
fn apply_key(&mut self, key: KeyEvent) -> SpawnAction {
if key.kind == KeyEventKind::Release {
return SpawnAction::None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('u') => return SpawnAction::Cancel,
_ => return SpawnAction::None,
}
}
self.status = None;
match key.code {
KeyCode::Esc => SpawnAction::Cancel,
KeyCode::Enter => {
if self.worker_name.trim().is_empty() {
self.status =
Some(("worker name cannot be empty".to_owned(), StatusKind::Error));
SpawnAction::None
} else {
SpawnAction::Submit
}
}
KeyCode::Tab | KeyCode::Down => {
self.selected_profile = (self.selected_profile + 1) % self.profile_choices.len();
SpawnAction::None
}
KeyCode::BackTab | KeyCode::Up => {
self.selected_profile = if self.selected_profile == 0 {
self.profile_choices.len() - 1
} else {
self.selected_profile - 1
};
SpawnAction::None
}
KeyCode::Left => {
self.cursor = self.cursor.saturating_sub(1);
SpawnAction::None
}
KeyCode::Right => {
self.cursor = (self.cursor + 1).min(self.worker_name.chars().count());
SpawnAction::None
}
KeyCode::Home => {
self.cursor = 0;
SpawnAction::None
}
KeyCode::End => {
self.cursor = self.worker_name.chars().count();
SpawnAction::None
}
KeyCode::Backspace => {
if self.cursor > 0 {
let idx = byte_index(&self.worker_name, self.cursor - 1);
self.worker_name.remove(idx);
self.cursor -= 1;
}
SpawnAction::None
}
KeyCode::Delete => {
if self.cursor < self.worker_name.chars().count() {
let idx = byte_index(&self.worker_name, self.cursor);
self.worker_name.remove(idx);
}
SpawnAction::None
}
KeyCode::Char(ch) if is_safe_worker_char(ch) => {
let idx = byte_index(&self.worker_name, self.cursor);
self.worker_name.insert(idx, ch);
self.cursor += 1;
SpawnAction::None
}
_ => SpawnAction::None,
}
}
}
pub(crate) fn select(
workspace_root: &Path,
worker_name: Option<String>,
profile: Option<String>,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
let default_worker_name = default_worker_name(workspace_root);
if let Some(profile) = profile {
return Ok(Some(StandaloneSpawnSelection {
worker_name: worker_name.unwrap_or(default_worker_name),
profile,
}));
}
let registry = ProfileDiscovery::user_settings().discover()?;
let choices = profile_choices(&registry);
if choices.is_empty() {
return Err(StandaloneSpawnError::NoProfiles);
}
let terminal = open_inline_terminal()?;
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
)
}
fn run_picker(
mut terminal: Terminal<CrosstermBackend<Stdout>>,
mut form: SpawnForm,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
loop {
terminal.draw(|frame| draw_form(frame, &form))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
match form.apply_key(key) {
SpawnAction::None => {}
SpawnAction::Cancel => {
form.status = Some(("cancelled".to_owned(), StatusKind::Info));
terminal.draw(|frame| draw_form(frame, &form))?;
return Ok(None);
}
SpawnAction::Submit => {
let selection = StandaloneSpawnSelection {
worker_name: form.worker_name.trim().to_owned(),
profile: form.selected_profile().selector.clone(),
};
form.status = Some(("starting worker...".to_owned(), StatusKind::Progress));
terminal.draw(|frame| draw_form(frame, &form))?;
return Ok(Some(selection));
}
}
}
}
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
let options = ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
};
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
}
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
registry
.entries()
.iter()
.map(|entry| {
let selector = entry.qualified_name();
let default_marker = if entry.is_default { " (default)" } else { "" };
let mut label = format!("{selector}{default_marker}");
if let Some(description) = &entry.description {
label.push_str("");
label.push_str(description);
}
ProfileChoice {
selector,
label,
is_default: entry.is_default,
}
})
.collect()
}
fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(
"spawn worker",
Style::default().add_modifier(Modifier::BOLD),
),
])),
chunks[0],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("name: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&form.worker_name,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
])),
chunks[1],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&form.selected_profile().label,
Style::default().fg(Color::Green),
),
Span::styled(
" (tab/down to change)",
Style::default().fg(Color::DarkGray),
),
])),
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" enter spawn · left/right edit · esc cancel",
Style::default().fg(Color::DarkGray),
))),
chunks[3],
);
let (message, color) = form
.status
.as_ref()
.map(|(message, kind)| {
let color = match kind {
StatusKind::Info => Color::DarkGray,
StatusKind::Progress => Color::Yellow,
StatusKind::Error => Color::Red,
};
(message.as_str(), color)
})
.unwrap_or(("", Color::Reset));
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(message, Style::default().fg(color)),
])),
chunks[4],
);
let prefix_width = " name: ".chars().count() as u16;
let x = chunks[1]
.x
.saturating_add(prefix_width)
.saturating_add(form.cursor as u16)
.min(chunks[1].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[1].y));
}
fn default_worker_name(workspace_root: &Path) -> String {
workspace_root
.file_name()
.and_then(|name| name.to_str())
.map(sanitise_default_name)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| FALLBACK_WORKER_NAME.to_owned())
}
fn sanitise_default_name(name: &str) -> String {
name.chars()
.map(|ch| if is_safe_worker_char(ch) { ch } else { '-' })
.collect()
}
fn is_safe_worker_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
}
fn byte_index(input: &str, char_index: usize) -> usize {
input
.char_indices()
.nth(char_index)
.map_or(input.len(), |(idx, _)| idx)
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyEvent, KeyModifiers};
use super::*;
fn choices() -> Vec<ProfileChoice> {
vec![
ProfileChoice {
selector: "builtin:default".to_owned(),
label: "builtin:default (default) — Default".to_owned(),
is_default: true,
},
ProfileChoice {
selector: "builtin:coder".to_owned(),
label: "builtin:coder — Coder".to_owned(),
is_default: false,
},
]
}
#[test]
fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi");
assert_eq!(form.selected_profile().selector, "builtin:default");
}
#[test]
fn tab_and_arrows_cycle_profiles() {
let mut form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
SpawnAction::None
);
assert_eq!(form.selected_profile().selector, "builtin:coder");
form.apply_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:default");
form.apply_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder");
}
#[test]
fn name_input_uses_old_safe_character_policy() {
let mut form = SpawnForm::new(Some("worker".to_owned()), "yoi".to_owned(), choices());
form.apply_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(form.worker_name, "worker-1");
}
#[test]
fn enter_rejects_empty_name_and_escape_cancels() {
let mut form = SpawnForm::new(Some(String::new()), "yoi".to_owned(), choices());
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
SpawnAction::None
);
assert_eq!(
form.status.as_ref().map(|(message, _)| message.as_str()),
Some("worker name cannot be empty")
);
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
SpawnAction::Cancel
);
}
#[test]
fn renderer_preserves_legacy_inline_spawn_form() {
let backend = ratatui::backend::TestBackend::new(100, VIEWPORT_HEIGHT);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
terminal.draw(|frame| draw_form(frame, &form)).unwrap();
let buffer = terminal.backend().buffer();
let rendered = buffer
.content
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi"));
assert!(rendered.contains("profile: builtin:default (default) — Default"));
assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
}
#[test]
fn builtin_discovery_produces_a_default_profile_choice() {
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
let choices = profile_choices(&registry);
let default = choices.iter().find(|choice| choice.is_default).unwrap();
assert_eq!(default.selector, "builtin:default");
assert!(default.label.contains("(default)"));
}
#[test]
fn default_worker_name_comes_from_sanitised_directory_basename() {
assert_eq!(
default_worker_name(Path::new("/home/hare/Project/yoi")),
"yoi"
);
assert_eq!(
default_worker_name(Path::new("/home/hare/Project/my project")),
"my-project"
);
assert_eq!(default_worker_name(Path::new("/")), "worker");
}
#[test]
fn explicit_profile_bypasses_discovery_and_uses_directory_name() {
let selection = select(
Path::new("/home/hare/Project/yoi"),
None,
Some("builtin:coder".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(selection.worker_name, "yoi");
assert_eq!(selection.profile, "builtin:coder");
}
}
+1
View File
@@ -18,6 +18,7 @@ sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
workspace-api = { workspace = true }
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
+149 -17
View File
@@ -102,8 +102,18 @@ pub enum WorkdirTransportErrorCode {
Conflict, Conflict,
Unsupported, Unsupported,
InvalidRequest, InvalidRequest,
Denied,
OutOfScope,
SymlinkOutOfScope,
BrokenSymlink,
SymlinkTargetIsDirectory,
ReadOnly,
IsDirectory,
SymlinkDirectoryNotTraversed,
UnknownCommand, UnknownCommand,
Unavailable, Unavailable,
Io,
Transport,
Internal, Internal,
} }
@@ -114,8 +124,18 @@ impl WorkdirTransportErrorCode {
Self::Conflict => "conflict", Self::Conflict => "conflict",
Self::Unsupported => "unsupported", Self::Unsupported => "unsupported",
Self::InvalidRequest => "invalid_request", Self::InvalidRequest => "invalid_request",
Self::Denied => "denied",
Self::OutOfScope => "out_of_scope",
Self::SymlinkOutOfScope => "symlink_out_of_scope",
Self::BrokenSymlink => "broken_symlink",
Self::SymlinkTargetIsDirectory => "symlink_target_is_directory",
Self::ReadOnly => "read_only",
Self::IsDirectory => "is_directory",
Self::SymlinkDirectoryNotTraversed => "symlink_directory_not_traversed",
Self::UnknownCommand => "unknown_command", Self::UnknownCommand => "unknown_command",
Self::Unavailable => "unavailable", Self::Unavailable => "unavailable",
Self::Io => "io",
Self::Transport => "transport",
Self::Internal => "internal", Self::Internal => "internal",
} }
} }
@@ -125,9 +145,16 @@ impl WorkdirTransportErrorCode {
match self { match self {
Self::NotFound | Self::UnknownCommand => 404, Self::NotFound | Self::UnknownCommand => 404,
Self::Conflict => 409, Self::Conflict => 409,
Self::Unsupported | Self::InvalidRequest => 400, Self::Denied | Self::OutOfScope | Self::SymlinkOutOfScope | Self::ReadOnly => 403,
Self::Unsupported
| Self::InvalidRequest
| Self::BrokenSymlink
| Self::SymlinkTargetIsDirectory
| Self::IsDirectory
| Self::SymlinkDirectoryNotTraversed => 400,
Self::Unavailable => 503, Self::Unavailable => 503,
Self::Internal => 500, Self::Io | Self::Internal => 500,
Self::Transport => 502,
} }
} }
} }
@@ -160,8 +187,41 @@ impl WorkdirTransportError {
WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => { WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => {
(Code::Unavailable, "Workdir session is unavailable") (Code::Unavailable, "Workdir session is unavailable")
} }
WorkdirError::Denied(_) => (Code::InvalidRequest, "Workdir operation was denied"), WorkdirError::Denied(_) => (Code::Denied, "Workdir operation was denied"),
WorkdirError::Transport(_) => (Code::Internal, "Workdir transport failed"), WorkdirError::OutOfScope(_) => (Code::OutOfScope, "Workdir path is out of scope"),
WorkdirError::SymlinkOutOfScope { .. } => (
Code::SymlinkOutOfScope,
"Workdir symlink target is out of scope",
),
WorkdirError::BrokenSymlink { .. } => {
(Code::BrokenSymlink, "Workdir symlink target does not exist")
}
WorkdirError::SymlinkTargetIsDirectory { .. } => (
Code::SymlinkTargetIsDirectory,
"Workdir symlink target is a directory",
),
WorkdirError::ReadOnly(_) => (Code::ReadOnly, "Workdir path is read-only"),
WorkdirError::IsDirectory(_) => (Code::IsDirectory, "Workdir path is a directory"),
WorkdirError::SymlinkDirectoryNotTraversed { .. } => (
Code::SymlinkDirectoryNotTraversed,
"Workdir symlink directory was not traversed",
),
WorkdirError::Io { source, .. } => match source.kind() {
std::io::ErrorKind::NotFound => (Code::NotFound, "Workdir path was not found"),
std::io::ErrorKind::PermissionDenied => {
(Code::Denied, "Workdir operation was denied")
}
std::io::ErrorKind::AlreadyExists => {
(Code::Conflict, "Workdir resource already exists")
}
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => {
(Code::InvalidRequest, "Workdir operation request is invalid")
}
std::io::ErrorKind::TimedOut => (Code::Unavailable, "Workdir operation timed out"),
_ => (Code::Io, "Workdir I/O operation failed"),
},
WorkdirError::OperationFailed => (Code::Internal, "Workdir operation failed"),
WorkdirError::Transport(_) => (Code::Transport, "Workdir transport failed"),
WorkdirError::InvalidPath(_) WorkdirError::InvalidPath(_)
| WorkdirError::RelativePath(_) | WorkdirError::RelativePath(_)
| WorkdirError::InvalidGlob(_) | WorkdirError::InvalidGlob(_)
@@ -169,14 +229,6 @@ impl WorkdirTransportError {
| WorkdirError::InvalidArgument(_) => { | WorkdirError::InvalidArgument(_) => {
(Code::InvalidRequest, "Workdir operation request is invalid") (Code::InvalidRequest, "Workdir operation request is invalid")
} }
WorkdirError::OutOfScope(_)
| WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::BrokenSymlink { .. }
| WorkdirError::SymlinkTargetIsDirectory { .. }
| WorkdirError::ReadOnly(_)
| WorkdirError::IsDirectory(_)
| WorkdirError::SymlinkDirectoryNotTraversed { .. }
| WorkdirError::Io { .. } => (Code::Internal, "Workdir operation failed"),
}; };
Self { Self {
code, code,
@@ -190,10 +242,38 @@ impl WorkdirTransportError {
Code::NotFound => WorkdirError::NotFound("<remote>".into()), Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message), Code::Conflict => WorkdirError::Conflict(self.message),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message), Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message), Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(),
target: "<remote-target>".into(),
required_permission: "requested",
},
Code::BrokenSymlink => WorkdirError::BrokenSymlink {
path: "<remote>".into(),
link: "<remote-link>".into(),
target: "<remote-target>".into(),
},
Code::SymlinkTargetIsDirectory => WorkdirError::SymlinkTargetIsDirectory {
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::ReadOnly => WorkdirError::ReadOnly("<remote>".into()),
Code::IsDirectory => WorkdirError::IsDirectory("<remote>".into()),
Code::SymlinkDirectoryNotTraversed => WorkdirError::SymlinkDirectoryNotTraversed {
tool: "remote operation",
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::Unavailable => WorkdirError::Unavailable(self.message), Code::Unavailable => WorkdirError::Unavailable(self.message),
Code::Internal => WorkdirError::Transport(self.message), Code::Io => WorkdirError::Io {
path: "<remote>".into(),
source: std::io::Error::other(self.message),
},
Code::Transport => WorkdirError::Transport(self.message),
Code::Internal => WorkdirError::OperationFailed,
} }
} }
} }
@@ -584,8 +664,42 @@ mod tests {
"modified externally", "modified externally",
), ),
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"), (WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
(WorkdirTransportErrorCode::Denied, 403, "denied"),
(
WorkdirTransportErrorCode::OutOfScope,
403,
"outside allowed scope",
),
(
WorkdirTransportErrorCode::SymlinkOutOfScope,
403,
"outside allowed requested scope",
),
(
WorkdirTransportErrorCode::BrokenSymlink,
400,
"broken symlink",
),
(
WorkdirTransportErrorCode::SymlinkTargetIsDirectory,
400,
"symlink to a directory",
),
(WorkdirTransportErrorCode::ReadOnly, 403, "read-only"),
(WorkdirTransportErrorCode::IsDirectory, 400, "expected file"),
(
WorkdirTransportErrorCode::SymlinkDirectoryNotTraversed,
400,
"does not follow symlink directories",
),
(WorkdirTransportErrorCode::Unavailable, 503, "unavailable"), (WorkdirTransportErrorCode::Unavailable, 503, "unavailable"),
(WorkdirTransportErrorCode::Internal, 500, "transport failed"), (WorkdirTransportErrorCode::Io, 500, "I/O error"),
(
WorkdirTransportErrorCode::Transport,
502,
"transport failed",
),
(WorkdirTransportErrorCode::Internal, 500, "operation failed"),
] { ] {
let transport = WorkdirTransportError { let transport = WorkdirTransportError {
code, code,
@@ -620,7 +734,8 @@ mod tests {
let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport( let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport(
"Workspace API request timed out".to_string(), "Workspace API request timed out".to_string(),
)); ));
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Transport);
assert_eq!(transport.code.http_status(), 502);
assert_eq!(transport.message, "Workdir transport failed"); assert_eq!(transport.message, "Workdir transport failed");
assert!(matches!( assert!(matches!(
transport.into_workdir_error(), transport.into_workdir_error(),
@@ -635,8 +750,25 @@ mod tests {
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"),
}; };
let transport = WorkdirTransportError::from_workdir_error(&error); let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Denied);
assert!(!transport.message.contains("/secret")); assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail")); assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Denied(_)
));
let error = WorkdirError::Io {
path: "/secret/runtime/root/file".into(),
source: std::io::Error::other("host detail"),
};
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Io);
assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Io { .. }
));
} }
} }
+3
View File
@@ -234,6 +234,9 @@ pub enum WorkdirError {
#[error("Workdir session is unavailable: {0}")] #[error("Workdir session is unavailable: {0}")]
Unavailable(String), Unavailable(String),
#[error("Workdir operation failed")]
OperationFailed,
#[error("Workdir transport failed: {0}")] #[error("Workdir transport failed: {0}")]
Transport(String), Transport(String),
+11 -251
View File
@@ -6,7 +6,11 @@
//! [`crate::http`]. //! [`crate::http`].
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
pub use workspace_api::{
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
/// Stable Workspace identity for a Worker hosted by a Runtime. /// Stable Workspace identity for a Worker hosted by a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -26,83 +30,6 @@ impl RuntimeWorkerRef {
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind {
Active,
CleanupPending,
Corrupted,
NotFound,
Unknown,
}
impl WorkingDirectoryStatusKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::CleanupPending => "cleanup_pending",
Self::Corrupted => "corrupted",
Self::NotFound => "not_found",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for WorkingDirectoryStatusKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryCleanupTarget {
pub kind: String,
pub working_directory_id: String,
pub repository_id: String,
}
/// Durable Workspace occupancy projection for one Workdir.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WorkingDirectoryOccupancy {
#[serde(flatten)]
pub worker: RuntimeWorkerRef,
pub display_name: String,
pub linked_at: String,
}
impl<'de> Deserialize<'de> for WorkingDirectoryOccupancy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
runtime_id: String,
worker_id: String,
display_name: String,
linked_at: String,
}
let wire = Wire::deserialize(deserializer)?;
Ok(Self {
worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id),
display_name: wire.display_name,
linked_at: wire.linked_at,
})
}
}
/// Immutable materialization provenance retained by Workspace inventory. /// Immutable materialization provenance retained by Workspace inventory.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation {
pub occupied_by: Option<WorkingDirectoryOccupancy>, pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectorySummary {
pub working_directory_id: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
impl WorkingDirectorySummary {
/// Workspace-managed inventory rows carry explicit cleanup authority.
pub fn is_workspace_managed(&self) -> bool {
self.cleanup_target.is_some()
}
pub fn provenance(&self) -> WorkingDirectoryProvenance {
WorkingDirectoryProvenance {
creation_selector: self.creation_selector.clone(),
creation_ref: self.creation_ref.clone(),
creation_tree: self.creation_tree.clone(),
materializer_kind: self.materializer_kind.clone(),
cleanup_target: self.cleanup_target.clone(),
}
}
pub fn current_observation(&self) -> WorkingDirectoryCurrentObservation {
WorkingDirectoryCurrentObservation {
current_selector: self.current_selector.clone(),
current_ref: self.current_ref.clone(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(),
cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(),
occupied_by: self.occupied_by.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryDiagnosticSeverity {
Info,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDiagnostic {
pub code: String,
pub severity: WorkingDirectoryDiagnosticSeverity,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryListResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDetailResponse {
pub workspace_id: String,
pub runtime_id: String,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -255,88 +88,15 @@ mod tests {
} }
#[test] #[test]
fn occupied_and_free_list_response_round_trips() { fn workspace_workdir_projection_reexports_workspace_api_authority() {
let response = WorkingDirectoryListResponse {
workspace_id: "workspace".to_string(),
items: vec![
WorkingDirectorySummary {
working_directory_id: "occupied".to_string(),
repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()),
creation_ref: Some("abc123".to_string()),
creation_tree: Some("tree123".to_string()),
current_selector: Some("work/ticket".to_string()),
current_ref: Some("def456".to_string()),
current_tree: Some("tree456".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
working_directory_id: "occupied".to_string(),
repository_id: "repo".to_string(),
}),
status: WorkingDirectoryStatusKind::Active,
cleanliness: Some("clean".to_string()),
primary_worker_id: None,
occupied_by: Some(WorkingDirectoryOccupancy {
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
display_name: "Coder".to_string(),
linked_at: "2026-08-12T00:00:00Z".to_string(),
}),
},
WorkingDirectorySummary {
working_directory_id: "free".to_string(),
repository_id: "repo".to_string(),
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: Some("987fed".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: Some("unknown".to_string()),
primary_worker_id: None,
occupied_by: None,
},
],
diagnostics: vec![WorkingDirectoryDiagnostic {
code: "observed".to_string(),
severity: WorkingDirectoryDiagnosticSeverity::Info,
message: "inventory observed".to_string(),
}],
};
let encoded = serde_json::to_value(&response).unwrap();
assert_eq!( assert_eq!(
encoded["items"][0]["occupied_by"]["worker_id"], std::any::TypeId::of::<WorkingDirectorySummary>(),
"worker-opaque-64" std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
); );
assert!( assert_eq!(
encoded["items"][0]["occupied_by"] std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
.get("runtime_worker_id") std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
.is_none()
); );
assert!(encoded["items"][1].get("occupied_by").is_none());
let mut stale = encoded.clone();
stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
assert!(serde_json::from_value::<WorkingDirectoryListResponse>(stale).is_err());
let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, response);
let detail = WorkingDirectoryDetailResponse {
workspace_id: decoded.workspace_id.clone(),
runtime_id: "arcadia".to_string(),
item: decoded.items[0].clone(),
diagnostics: decoded.diagnostics.clone(),
};
let encoded = serde_json::to_value(&detail).unwrap();
let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, detail);
} }
} }
@@ -12,11 +12,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{ use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -24,6 +20,13 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
};
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDeclaration,
@@ -339,7 +342,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
} }
} }
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(
&self,
mut request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
// `spill_dir` belongs to the Worker host running BashTool. This remote
// WorkdirSession provider cannot safely resolve or write that host path.
request.spill_dir = None;
match self.operate(WorkdirSessionOperation::CommandStart(request))? { match self.operate(WorkdirSessionOperation::CommandStart(request))? {
WorkdirSessionOperationResult::CommandStart(result) => Ok(result), WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
_ => Err(Self::mismatch("command_start")), _ => Err(Self::mismatch("command_start")),
@@ -420,9 +429,9 @@ impl WorkspaceHttpWorkdirBackend {
runtime_id: runtime_id.map(str::to_string), runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(), repository_id: repository_id.to_string(),
selector, selector,
operation_id, operation_id: Some(operation_id),
}; };
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json( let response = self.execute_json::<WorkdirCreateResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/working-directories"), format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?, serde_json::to_string(&request).map_err(decode_error)?,
@@ -701,16 +710,6 @@ struct WorkdirCreateInput {
selector: Option<String>, selector: Option<String>,
} }
#[derive(Debug, Serialize)]
struct WorkdirCreateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
runtime_id: Option<String>,
repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<String>,
operation_id: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct WorkdirAttachInput { struct WorkdirAttachInput {
@@ -1118,6 +1117,42 @@ mod tests {
assert!(body.get("session_id").is_none()); assert!(body.get("session_id").is_none());
} }
#[tokio::test]
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"operation": "command_start",
"result": "command-1"
}))]));
let session = WorkspaceAttachedWorkdirSession::handle(client.clone());
let handle = session
.start_command(CommandRequest {
command: "true".to_string(),
timeout_secs: 120,
output_limit: 1024,
spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()),
})
.await
.unwrap();
assert_eq!(handle, CommandHandle("command-1".to_string()));
let requests = client.requests();
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["operation"]["operation"], "command_start");
assert_eq!(body["operation"]["request"]["command"], "true");
assert!(body["operation"]["request"]["spill_dir"].is_null());
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
assert!(
!requests[0]
.body
.as_deref()
.unwrap()
.contains("/worker-local/bash-output")
);
}
#[tokio::test] #[tokio::test]
async fn delegated_attached_session_carries_captured_fence_on_operations() { async fn delegated_attached_session_carries_captured_fence_on_operations() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![ let client = Arc::new(RecordingWorkspaceClient::new(vec![
+16 -1
View File
@@ -12,7 +12,22 @@ typescript = ["dep:ts-rs"]
[dependencies] [dependencies]
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
workdir.workspace = true
[[example]]
name = "generate_typescript"
required-features = ["typescript"]
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
[[example]]
name = "generate_workdir_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_companion_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_repository_access_types"
required-features = ["typescript"]
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::companion_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::repository_access_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::catalog_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::workdir_api_typescript());
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3007,7 +3007,7 @@ mod tests {
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-test".to_string(), workspace_id: "workspace-test".to_string(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: "Workspace Test".to_string(), display_name: "Workspace Test".to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(),
@@ -3410,7 +3410,7 @@ VALUES ('workspace-test', 'ticket', 4);
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-test".to_string(), workspace_id: "workspace-test".to_string(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: "Workspace Test".to_string(), display_name: "Workspace Test".to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(),
+26 -107
View File
@@ -1,77 +1,14 @@
use serde::{Deserialize, Serialize}; use workspace_api::{
CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic,
DiagnosticSeverity,
};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary}; pub use workspace_api::{
CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse,
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] CompanionStatusResponse, CompanionTranscriptProjection,
#[serde(rename_all = "snake_case")] };
pub enum CompanionState {
Disabled,
Rejected,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionStatusResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
pub transport: CompanionTransportSummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTransportSummary {
pub kind: String,
pub completion: String,
pub limitation: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageRequest {
pub content: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_item: Option<CompanionTranscriptItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_item: Option<CompanionTranscriptItem>,
pub transcript: CompanionTranscriptProjection,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptProjection {
pub state: CompanionState,
pub start: usize,
pub limit: usize,
pub total_items: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_start: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptItem {
pub sequence: u64,
pub role: String,
pub content: String,
pub created_at: String,
pub source: String,
pub status: String,
}
#[derive(Clone, Default)]
pub struct CompanionConsole; pub struct CompanionConsole;
impl CompanionConsole { impl CompanionConsole {
@@ -81,68 +18,50 @@ impl CompanionConsole {
pub fn status(&self) -> CompanionStatusResponse { pub fn status(&self) -> CompanionStatusResponse {
CompanionStatusResponse { CompanionStatusResponse {
state: CompanionState::Disabled, state: CompanionLifecycleState::Stopped,
worker: None, worker: None,
transport: disabled_transport(), transport: CompanionTransportSummary {
mode: "disabled".to_string(),
available: false,
},
diagnostics: vec![disabled_diagnostic()], diagnostics: vec![disabled_diagnostic()],
} }
} }
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection { pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
CompanionTranscriptProjection { CompanionTranscriptProjection {
state: CompanionState::Disabled, state: CompanionLifecycleState::Stopped,
start, start,
limit, limit,
total_items: 0, total: 0,
next_start: None, next: None,
items: Vec::new(), items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
} }
} }
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse { pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
disabled_message_response(CompanionState::Rejected) disabled_message_response()
} }
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse { pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
disabled_message_response(CompanionState::Cancelled) disabled_message_response()
} }
} }
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse { fn disabled_message_response() -> CompanionMessageResponse {
CompanionMessageResponse { CompanionMessageResponse {
state, state: CompanionMessageDisposition::Rejected,
worker: None, message: "Workspace Companion auto-start is disabled; create or select an explicit Worker instead."
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Disabled,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![disabled_diagnostic()],
},
diagnostics: vec![disabled_diagnostic()],
}
}
fn disabled_transport() -> CompanionTransportSummary {
CompanionTransportSummary {
kind: "none".to_string(),
completion: "disabled".to_string(),
limitation:
"Workspace Companion auto-start has been removed; create an explicit Worker instead."
.to_string(), .to_string(),
} }
} }
fn disabled_diagnostic() -> RuntimeDiagnostic { fn disabled_diagnostic() -> Diagnostic {
RuntimeDiagnostic { Diagnostic {
code: "companion_disabled".to_string(), code: "companion_disabled".to_string(),
severity: DiagnosticSeverity::Info, severity: DiagnosticSeverity::Info,
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead." message:
"Workspace Companion auto-start was removed; use the explicit Worker lifecycle instead."
.to_string(), .to_string(),
} }
} }
+1 -1
View File
@@ -874,7 +874,7 @@ mod tests {
fn workspace() -> WorkspaceRecord { fn workspace() -> WorkspaceRecord {
WorkspaceRecord { WorkspaceRecord {
workspace_id: "w-config".into(), workspace_id: "w-config".into(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: "Config".into(), display_name: "Config".into(),
state: "active".into(), state: "active".into(),
created_at: "2026-08-13T00:00:00Z".into(), created_at: "2026-08-13T00:00:00Z".into(),
+8 -6
View File
@@ -42,19 +42,19 @@ pub use authority::{
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
};
pub use server::{ pub use server::{
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
build_workspace_server_router, serve, serve_workspace_catalog, build_workspace_server_router, serve, serve_workspace_catalog,
}; };
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
pub use workspace_catalog::{ pub use workspace_api::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary,
WorkspaceCreateResponse, WorkspaceCreateResponse,
}; };
pub use workspace_catalog::{
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult,
};
use worker_runtime::identity::RuntimeWorkerRef; use worker_runtime::identity::RuntimeWorkerRef;
@@ -117,6 +117,8 @@ pub enum Error {
WorkspacePermissionDenied(String), WorkspacePermissionDenied(String),
#[error("Workspace config update conflict: {0}")] #[error("Workspace config update conflict: {0}")]
WorkspaceConfigConflict(String), WorkspaceConfigConflict(String),
#[error("Repository conflict: {0}")]
RepositoryConflict(String),
#[error("Registry inconsistency: {0}")] #[error("Registry inconsistency: {0}")]
RegistryInconsistency(String), RegistryInconsistency(String),
#[error("Worker source identity is invalid: {0}")] #[error("Worker source identity is invalid: {0}")]
@@ -395,10 +395,17 @@ mod tests {
let db_path = temp.path().join("workspace.sqlite3"); let db_path = temp.path().join("workspace.sqlite3");
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace").unwrap(); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace").unwrap();
let conn = rusqlite::Connection::open(&db_path).unwrap(); let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO accounts (
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', ?1, ?1)",
["2026-01-01T00:00:00Z"],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO workspaces ( "INSERT INTO workspaces (
workspace_id, owner_account_id, display_name, state, created_at, updated_at workspace_id, owner_account_id, display_name, state, created_at, updated_at
) VALUES (?1, NULL, ?2, ?3, ?4, ?4)", ) VALUES (?1, 'owner-account', ?2, ?3, ?4, ?4)",
rusqlite::params!["workspace", "Workspace", "active", "2026-01-01T00:00:00Z"], rusqlite::params!["workspace", "Workspace", "active", "2026-01-01T00:00:00Z"],
) )
.unwrap(); .unwrap();
@@ -218,7 +218,7 @@ mod tests {
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-test".to_string(), workspace_id: "workspace-test".to_string(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: "Workspace Test".to_string(), display_name: "Workspace Test".to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(),
+16 -66
View File
@@ -4,8 +4,10 @@ use std::{
process::Command, process::Command,
}; };
use serde::{Deserialize, Serialize}; use workspace_api::{
use workspace_api::{RepositoryObservedStatus, RepositorySource}; Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary,
};
pub type RepositoryId = String; pub type RepositoryId = String;
pub type RepositorySelector = String; pub type RepositorySelector = String;
@@ -24,74 +26,19 @@ pub struct ConfiguredRepository {
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySummary {
pub id: RepositoryId,
pub display_name: String,
pub kind: String,
pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>,
pub record_authority: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<GitRepositorySummary>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRepositorySummary {
pub status: String,
pub head: Option<String>,
pub branch: Option<String>,
pub dirty: bool,
pub remotes: Vec<GitRemoteSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRemoteSummary {
pub name: String,
pub fetch_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryDiagnostic {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryListProjection { pub struct RepositoryListProjection {
pub items: Vec<RepositorySummary>, pub items: Vec<RepositorySummary>,
pub diagnostics: Vec<RepositoryDiagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryLogRead { pub struct RepositoryLogRead {
pub repository_id: RepositoryId, pub repository_id: RepositoryId,
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
pub limit: usize, pub limit: usize,
pub commits: Vec<GitCommitSummary>, pub commits: Vec<GitCommitSummary>,
pub diagnostics: Vec<RepositoryDiagnostic>, pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitCommitSummary {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub author_date: String,
pub parents: Vec<String>,
pub refs: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -131,8 +78,8 @@ impl RepositoryRegistryReader {
if self.repositories.is_empty() { if self.repositories.is_empty() {
return RepositoryListProjection { return RepositoryListProjection {
items: Vec::new(), items: Vec::new(),
diagnostics: vec![RepositoryDiagnostic { diagnostics: vec![Diagnostic {
severity: "warning".to_string(), severity: DiagnosticSeverity::Warning,
code: "repository_config_empty".to_string(), code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend." message: "No repositories are configured for this workspace backend."
.to_string(), .to_string(),
@@ -177,8 +124,8 @@ impl RepositoryRegistryReader {
let commits = match self.git_log(repository, limit) { let commits = match self.git_log(repository, limit) {
Ok(commits) => commits, Ok(commits) => commits,
Err(message) => { Err(message) => {
diagnostics.push(RepositoryDiagnostic { diagnostics.push(Diagnostic {
severity: "warning".to_string(), severity: DiagnosticSeverity::Warning,
code: "repository_git_log_unavailable".to_string(), code: "repository_git_log_unavailable".to_string(),
message, message,
}); });
@@ -379,7 +326,7 @@ impl RepositoryRegistryReader {
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
record_authority: "workspace-control-plane".to_string(), record_authority: "workspace-control-plane".to_string(),
git, git,
diagnostics, diagnostics: (!diagnostics.is_empty()).then_some(diagnostics),
} }
} }
@@ -675,7 +622,10 @@ mod tests {
RepositoryObservedStatus::Unverified RepositoryObservedStatus::Unverified
); );
assert!(summary.git.is_none()); assert!(summary.git.is_none());
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified"); assert_eq!(
summary.diagnostics.as_ref().unwrap()[0].code,
"repository_source_unverified"
);
let repository = reader.merge_repository("remote").unwrap(); let repository = reader.merge_repository("remote").unwrap();
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err(); let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
@@ -1669,7 +1669,7 @@ mod tests {
for workspace_id in ["workspace-a", "workspace-b"] { for workspace_id in ["workspace-a", "workspace-b"] {
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: workspace_id.to_string(), display_name: workspace_id.to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: timestamp.clone(), created_at: timestamp.clone(),
+17 -4
View File
@@ -1056,8 +1056,13 @@ mod tests {
let s = SqliteWorkspaceStore::in_memory().unwrap(); let s = SqliteWorkspaceStore::in_memory().unwrap();
s.with_conn(|c| { s.with_conn(|c| {
c.execute( c.execute(
"INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at) \ "INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) \
VALUES('w','W','active','t','t')", VALUES('owner-account','user','owner-account','Owner Account','t','t')",
[],
)?;
c.execute(
"INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at,owner_account_id) \
VALUES('w','W','active','t','t','owner-account')",
[], [],
)?; )?;
c.execute( c.execute(
@@ -1728,11 +1733,19 @@ mod tests {
let connection = rusqlite::Connection::open(&path).unwrap(); let connection = rusqlite::Connection::open(&path).unwrap();
crate::store::configure_sqlite(&connection).unwrap(); crate::store::configure_sqlite(&connection).unwrap();
crate::store::apply_migrations_through(&connection, 27).unwrap(); crate::store::apply_migrations_through(&connection, 27).unwrap();
connection
.execute(
"INSERT INTO accounts(
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', 'old', 'old')",
[],
)
.unwrap();
connection connection
.execute( .execute(
"INSERT INTO workspaces( "INSERT INTO workspaces(
workspace_id, display_name, state, created_at, updated_at workspace_id, display_name, state, created_at, updated_at, owner_account_id
) VALUES ('legacy', 'Legacy', 'active', 'old', 'old')", ) VALUES ('legacy', 'Legacy', 'active', 'old', 'old', 'owner-account')",
[], [],
) )
.unwrap(); .unwrap();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -299,7 +299,7 @@ mod tests {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
owner_account_id: None, owner_account_id: "owner-account".to_string(),
display_name: "Workspace".to_string(), display_name: "Workspace".to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: "2026-08-24T00:00:00Z".to_string(), created_at: "2026-08-24T00:00:00Z".to_string(),
+114 -17
View File
@@ -35,8 +35,8 @@ pub struct WorkspaceCreateRequest {
pub repository: InitialRepositoryIntent, pub repository: InitialRepositoryIntent,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResponse { pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord, pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord, pub repository: RepositoryRecord,
pub config_revision: u64, pub config_revision: u64,
@@ -58,21 +58,13 @@ impl WorkspaceCatalogService {
Ok(self.store.list_workspaces()?.is_empty()) Ok(self.store.list_workspaces()?.is_empty())
} }
pub fn list( pub fn list(&self, owner_account_id: &str, limit: usize) -> Result<Vec<WorkspaceRecord>> {
&self,
owner_account_id: Option<&str>,
limit: usize,
) -> Result<Vec<WorkspaceRecord>> {
let limit = limit.clamp(1, 200); let limit = limit.clamp(1, 200);
Ok(self Ok(self
.store .store
.list_workspaces()? .list_workspaces()?
.into_iter() .into_iter()
.filter(|workspace| { .filter(|workspace| workspace.owner_account_id == owner_account_id)
workspace.owner_account_id.is_none()
|| owner_account_id
.is_some_and(|owner| workspace.owner_account_id.as_deref() == Some(owner))
})
.take(limit) .take(limit)
.collect()) .collect())
} }
@@ -81,7 +73,7 @@ impl WorkspaceCatalogService {
&self, &self,
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: String, owner_account_id: String,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResult> {
self.create_internal(request, owner_account_id, None) self.create_internal(request, owner_account_id, None)
} }
@@ -90,7 +82,17 @@ impl WorkspaceCatalogService {
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: String, owner_account_id: String,
requested_workspace_id: Option<String>, requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResult> {
let owner = self.store.get_account(&owner_account_id)?.ok_or_else(|| {
Error::InvalidInput(
"Workspace owner must reference an existing user account".to_string(),
)
})?;
if owner.kind != "user" {
return Err(Error::InvalidInput(
"Workspace owner must be a user account".to_string(),
));
}
let operation_key = normalize_required( let operation_key = normalize_required(
"operation_key", "operation_key",
request.operation_key, request.operation_key,
@@ -140,10 +142,9 @@ impl WorkspaceCatalogService {
.create_workspace_bootstrap(&WorkspaceBootstrapRecord { .create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key, operation_key,
request_fingerprint: fingerprint.clone(), request_fingerprint: fingerprint.clone(),
require_empty_catalog: false,
workspace: WorkspaceRecord { workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(), workspace_id: workspace_id.clone(),
owner_account_id: Some(owner_account_id), owner_account_id,
display_name, display_name,
state: "active".to_string(), state: "active".to_string(),
created_at: now.clone(), created_at: now.clone(),
@@ -165,7 +166,7 @@ impl WorkspaceCatalogService {
updated_at: now, updated_at: now,
}, },
})?; })?;
Ok(WorkspaceCreateResponse { Ok(WorkspaceCreateResult {
workspace: result.workspace, workspace: result.workspace,
repository: result.repository, repository: result.repository,
config_revision: result.config_revision, config_revision: result.config_revision,
@@ -332,6 +333,102 @@ mod tests {
assert!(validate_repository_source("relative/repository").is_err()); assert!(validate_repository_source("relative/repository").is_err());
} }
#[test]
fn create_rejects_non_user_account_owners() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
store
.upsert_account(&AccountRecord {
account_id: "organization-owner".to_string(),
kind: "organization".to_string(),
handle: "organization-owner".to_string(),
display_name: "Organization Owner".to_string(),
created_at: "2026-07-03T00:00:00Z".to_string(),
updated_at: "2026-07-03T00:00:00Z".to_string(),
})
.unwrap();
let service = WorkspaceCatalogService::new(store);
let repository = git_repository();
let error = service
.create(
WorkspaceCreateRequest {
operation_key: "organization-owner-create".to_string(),
display_name: "Organization Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
"organization-owner".to_string(),
)
.unwrap_err()
.to_string();
assert!(error.contains("must be a user account"), "{error}");
}
#[test]
fn catalog_list_is_scoped_to_the_required_owner_account() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_a = owner_account(store.as_ref());
let owner_b = "account-owner-b".to_string();
store
.upsert_account(&AccountRecord {
account_id: owner_b.clone(),
kind: "user".to_string(),
handle: "owner-b".to_string(),
display_name: "Owner B".to_string(),
created_at: "2026-07-03T00:00:00Z".to_string(),
updated_at: "2026-07-03T00:00:00Z".to_string(),
})
.unwrap();
let service = WorkspaceCatalogService::new(store);
let repository_a = git_repository();
let repository_b = git_repository();
let created_a = service
.create(
WorkspaceCreateRequest {
operation_key: "owner-a-create".to_string(),
display_name: "Owner A Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
owner_a.clone(),
)
.unwrap();
service
.create(
WorkspaceCreateRequest {
operation_key: "owner-b-create".to_string(),
display_name: "Owner B Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
owner_b.clone(),
)
.unwrap();
let owner_a_workspaces = service.list(&owner_a, 100).unwrap();
assert_eq!(owner_a_workspaces.len(), 1);
assert_eq!(
owner_a_workspaces[0].workspace_id,
created_a.workspace.workspace_id
);
assert_eq!(owner_a_workspaces[0].owner_account_id, owner_a);
assert!(
service
.list(&owner_b, 100)
.unwrap()
.into_iter()
.all(|workspace| workspace.owner_account_id == owner_b)
);
}
#[test] #[test]
fn remote_repository_creation_persists_typed_source_without_auth_metadata() { fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
+7
View File
@@ -6,11 +6,18 @@ pkgs.mkShell {
git git
rustc rustc
cargo cargo
pkgs.sccache
]; ];
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
SCCACHE_CACHE_SIZE = "5G";
buildInputs = with pkgs; [ buildInputs = with pkgs; [
pkg-config pkg-config
openssl openssl
]; ];
shellHook = '' shellHook = ''
if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
: # export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi" : # export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi"
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -0,0 +1,64 @@
// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.
// Do not edit manually.
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type WorkspaceWorkerSubject = {
"kind": "runtime_worker";
runtime_id: string;
worker_id: string;
};
export type WorkspaceWorkerDiscoveryItem = {
subject: WorkspaceWorkerSubject;
resource_key: string;
display_name: string;
profile: string | null;
status?: string | null;
};
export type CompanionLifecycleState = "idle" | "running" | "stopped";
export type CompanionMessageDisposition = "accepted" | "rejected";
export type CompanionTransportSummary = { mode: string; available: boolean };
export type CompanionStatusResponse = {
state: CompanionLifecycleState;
worker: WorkspaceWorkerDiscoveryItem | null;
transport: CompanionTransportSummary;
diagnostics: Array<Diagnostic>;
};
export type CompanionMessageRequest = { content: string };
export type CompanionCancelRequest = { reason?: string | null };
export type CompanionMessageResponse = {
state: CompanionMessageDisposition;
message: string;
};
export type CompanionTranscriptRole = "user" | "assistant";
export type CompanionTranscriptItem = {
sequence: number;
role: CompanionTranscriptRole;
content: string;
created_at: string;
};
export type CompanionTranscriptProjection = {
state: CompanionLifecycleState;
start: number;
limit: number;
total: number;
next: number | null;
items: Array<CompanionTranscriptItem>;
};
@@ -0,0 +1,79 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts
export type RepositorySshCredential = {
credential_id: string;
workspace_id: string;
name: string;
public_key_algorithm: string;
public_key_fingerprint: string;
current_revision: number;
status: string;
created_at: string;
rotated_at: string | null;
referenced_repositories: Array<string>;
};
export type CreateRepositorySshCredentialRequest = {
operation_id: string;
credential_id: string;
name: string;
private_key: string;
passphrase: string | null;
};
export type RotateRepositorySshCredentialRequest = {
operation_id: string;
expected_revision: number;
private_key: string;
passphrase: string | null;
};
export type DeleteRepositorySshCredentialRequest = {
operation_id: string;
expected_revision: number;
};
export type RepositorySshHostTrust = {
host_trust_id: string;
workspace_id: string;
hostname: string;
port: number;
key_algorithm: string;
host_key: string;
fingerprint: string;
current_revision: number;
created_at: string;
updated_at: string;
referenced_repositories: Array<string>;
};
export type PutRepositorySshHostTrustRequest = {
operation_id: string;
host_trust_id: string;
hostname: string;
port: number;
host_key: string;
expected_revision: number | null;
};
export type DeleteRepositorySshHostTrustRequest = {
operation_id: string;
expected_revision: number;
};
export type RepositoryAccessMode = "read_only" | "read_write";
export type RepositorySshAccessBinding = {
repository_id: string;
credential_id: string;
host_trust_id: string;
access: RepositoryAccessMode;
};
export type RepositoryAccessProjection = {
workspace_id: string;
config_revision: number;
projection_digest: string;
bindings: Array<RepositorySshAccessBinding>;
};
@@ -0,0 +1,24 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree";
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, };
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, };
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
@@ -0,0 +1,166 @@
// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.
// Do not edit this file directly.
export type WorkspaceSummary = {
workspace_id: string;
owner_account_id: string;
display_name: string;
state: string;
created_at: string;
updated_at: string;
};
export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
provider: string | null;
source: RepositorySource;
default_ref: string | null;
source_revision: number;
source_fingerprint: string;
observed_status: RepositoryObservedStatus;
observed_at: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceCreateResponse = {
workspace: WorkspaceSummary;
repository: WorkspaceRepositoryRecord;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
};
export type WorkspaceAuthConfig = {
"Passkey": {
rp_id: string;
origin: string;
public_base_url: string;
cookie_name: string;
};
};
export type WorkspacePermissionSummary = {
manage_repositories: boolean;
manage_secrets: boolean;
};
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type WorkspaceExtensionPointState = {
status: string;
note: string;
diagnostics: Array<Diagnostic>;
};
export type WorkspaceExtensionPoints = {
store: string;
event_stream: WorkspaceExtensionPointState;
host_worker_bridge: WorkspaceExtensionPointState;
companion_console: WorkspaceExtensionPointState;
};
export type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
schema_version: number;
auth: WorkspaceAuthConfig;
permissions: WorkspacePermissionSummary;
extension_points: WorkspaceExtensionPoints;
};
export type RepositorySourceKind =
| "local_path"
| "file"
| "ssh"
| "http"
| "https"
| "invalid";
export type RepositorySource = {
kind: RepositorySourceKind;
/**
* Canonical source representation. This is an absolute local path for
* `local_path`, and a normalized URI/remote specification otherwise.
*/
uri: string;
};
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
export type RepositoryDiagnostic = {
severity: string;
code: string;
message: string;
};
export type GitRemoteSummary = { name: string; fetch_url: string };
export type GitRepositorySummary = {
status: string;
head: string | null;
branch: string | null;
dirty: boolean;
remotes: Array<GitRemoteSummary>;
};
export type RepositorySummary = {
id: string;
display_name: string;
kind: string;
provider: string;
source: RepositorySource;
source_revision: number;
source_fingerprint: string;
observed_status: RepositoryObservedStatus;
observed_at?: string | null;
default_selector?: string | null;
record_authority: string;
git?: GitRepositorySummary | null;
diagnostics?: Array<RepositoryDiagnostic> | null;
};
export type GitCommitSummary = {
hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
author_date: string;
parents: Array<string>;
refs: Array<string>;
};
export type RepositoryListResponse = {
workspace_id: string;
items: Array<RepositorySummary>;
source: string;
diagnostics: Array<Diagnostic>;
};
export type RepositoryDetailResponse = {
workspace_id: string;
item: RepositorySummary;
source: string;
};
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: Array<GitCommitSummary>;
diagnostics: Array<Diagnostic>;
};
+3 -1
View File
@@ -113,6 +113,7 @@ export async function loadJson<T>(
fetchFn: typeof fetch, fetchFn: typeof fetch,
path: string, path: string,
init?: RequestInit, init?: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
): Promise<ApiResult<T>> { ): Promise<ApiResult<T>> {
try { try {
const response = await fetchFn(path, init); const response = await fetchFn(path, init);
@@ -123,7 +124,8 @@ export async function loadJson<T>(
error: text || `${path} request failed (${response.status})`, error: text || `${path} request failed (${response.status})`,
}; };
} }
return { data: (await response.json()) as T, error: null }; const payload: unknown = await response.json();
return { data: parse(payload), error: null };
} catch (error) { } catch (error) {
return { return {
data: null, data: null,
@@ -0,0 +1,45 @@
import { error } from "@sveltejs/kit";
import { RepositoryAccessSchemaError } from "./repository-access.ts";
export async function loadRepositoryAccessJson<T>(
fetcher: typeof fetch,
path: string,
parse: (value: unknown) => T,
): Promise<T> {
let response: Response;
try {
response = await fetcher(path, { headers: { accept: "application/json" } });
} catch {
error(503, { message: "Repository Access is temporarily unavailable." });
}
if (response.status === 401 || response.status === 403) {
error(403, {
message: "Repository Access is unavailable for this account.",
});
}
if (!response.ok) {
error(502, {
message:
`Repository Access request failed with status ${response.status}.`,
});
}
let payload: unknown;
try {
payload = await response.json();
} catch {
error(502, {
message: "Repository Access returned an invalid JSON response.",
});
}
try {
return parse(payload);
} catch (cause) {
if (cause instanceof RepositoryAccessSchemaError) {
error(502, { message: cause.message });
}
throw cause;
}
}
@@ -0,0 +1,228 @@
import type {
RepositoryAccessProjection,
RepositorySshCredential,
RepositorySshHostTrust,
} from "../../generated/repository-access-api.ts";
export class RepositoryAccessSchemaError extends Error {
constructor(path: string, expected: string) {
super(
`Repository Access response schema mismatch at ${path}: expected ${expected}`,
);
this.name = "RepositoryAccessSchemaError";
}
}
export function parseRepositorySshCredentials(
value: unknown,
): RepositorySshCredential[] {
return readArray(value, "credentials").map((entry, index) =>
parseRepositorySshCredential(entry, `credentials[${index}]`)
);
}
export function parseRepositorySshCredential(
value: unknown,
path = "credential",
): RepositorySshCredential {
const record = readRecord(value, path, [
"credential_id",
"workspace_id",
"name",
"public_key_algorithm",
"public_key_fingerprint",
"current_revision",
"status",
"created_at",
"rotated_at",
"referenced_repositories",
]);
readString(record, "credential_id", path);
readString(record, "workspace_id", path);
readString(record, "name", path);
readString(record, "public_key_algorithm", path);
readString(record, "public_key_fingerprint", path);
readRevision(record, "current_revision", path);
readString(record, "status", path);
readString(record, "created_at", path);
readNullableString(record, "rotated_at", path);
readStringArray(record, "referenced_repositories", path);
return record as RepositorySshCredential;
}
export function parseRepositorySshHostTrusts(
value: unknown,
): RepositorySshHostTrust[] {
return readArray(value, "host_trusts").map((entry, index) =>
parseRepositorySshHostTrust(entry, `host_trusts[${index}]`)
);
}
export function parseRepositorySshHostTrust(
value: unknown,
path = "host_trust",
): RepositorySshHostTrust {
const record = readRecord(value, path, [
"host_trust_id",
"workspace_id",
"hostname",
"port",
"key_algorithm",
"host_key",
"fingerprint",
"current_revision",
"created_at",
"updated_at",
"referenced_repositories",
]);
readString(record, "host_trust_id", path);
readString(record, "workspace_id", path);
readString(record, "hostname", path);
const port = readInteger(record, "port", path);
if (port < 1 || port > 65_535) {
throw new RepositoryAccessSchemaError(
`${path}.port`,
"an integer from 1 to 65535",
);
}
readString(record, "key_algorithm", path);
readString(record, "host_key", path);
readString(record, "fingerprint", path);
readRevision(record, "current_revision", path);
readString(record, "created_at", path);
readString(record, "updated_at", path);
readStringArray(record, "referenced_repositories", path);
return record as RepositorySshHostTrust;
}
export function parseRepositoryAccessProjection(
value: unknown,
): RepositoryAccessProjection {
const path = "access_projection";
const record = readRecord(value, path, [
"workspace_id",
"config_revision",
"projection_digest",
"bindings",
]);
readString(record, "workspace_id", path);
readRevision(record, "config_revision", path);
readString(record, "projection_digest", path);
const bindings = readArray(record.bindings, `${path}.bindings`);
bindings.forEach((binding, index) => {
const bindingPath = `${path}.bindings[${index}]`;
const bindingRecord = readRecord(binding, bindingPath, [
"repository_id",
"credential_id",
"host_trust_id",
"access",
]);
readString(bindingRecord, "repository_id", bindingPath);
readString(bindingRecord, "credential_id", bindingPath);
readString(bindingRecord, "host_trust_id", bindingPath);
const access = readString(bindingRecord, "access", bindingPath);
if (access !== "read_only" && access !== "read_write") {
throw new RepositoryAccessSchemaError(
`${bindingPath}.access`,
'"read_only" or "read_write"',
);
}
});
return record as RepositoryAccessProjection;
}
function readRecord(
value: unknown,
path: string,
allowedKeys: readonly string[],
): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new RepositoryAccessSchemaError(path, "an object");
}
const record = value as Record<string, unknown>;
const unknownKey = Object.keys(record).find((key) =>
!allowedKeys.includes(key)
);
if (unknownKey !== undefined) {
throw new RepositoryAccessSchemaError(
`${path}.${unknownKey}`,
"no unknown field",
);
}
return record;
}
function readArray(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new RepositoryAccessSchemaError(path, "an array");
}
return value;
}
function readString(
record: Record<string, unknown>,
key: string,
path: string,
): string {
const value = record[key];
if (typeof value !== "string") {
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string");
}
return value;
}
function readNullableString(
record: Record<string, unknown>,
key: string,
path: string,
): string | null {
const value = record[key];
if (value !== null && typeof value !== "string") {
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null");
}
return value;
}
function readStringArray(
record: Record<string, unknown>,
key: string,
path: string,
): string[] {
const values = readArray(record[key], `${path}.${key}`);
values.forEach((value, index) => {
if (typeof value !== "string") {
throw new RepositoryAccessSchemaError(
`${path}.${key}[${index}]`,
"a string",
);
}
});
return values as string[];
}
function readInteger(
record: Record<string, unknown>,
key: string,
path: string,
): number {
const value = record[key];
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
throw new RepositoryAccessSchemaError(`${path}.${key}`, "a safe integer");
}
return value;
}
function readRevision(
record: Record<string, unknown>,
key: string,
path: string,
): number {
const revision = readInteger(record, key, path);
if (revision < 0) {
throw new RepositoryAccessSchemaError(
`${path}.${key}`,
"a non-negative safe integer",
);
}
return revision;
}
@@ -0,0 +1,250 @@
import type {
Diagnostic,
WorkingDirectoryCleanupTarget,
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
} from "../../generated/workdir-api";
const SUMMARY_KEYS = new Set([
"working_directory_id",
"repository_id",
"creation_selector",
"creation_ref",
"creation_tree",
"current_selector",
"current_ref",
"current_tree",
"observed_at_epoch_seconds",
"materializer_kind",
"cleanup_target",
"status",
"cleanliness",
"primary_worker_id",
"occupied_by",
]);
const CREATE_REQUEST_KEYS = new Set([
"runtime_id",
"repository_id",
"selector",
"operation_id",
]);
const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]);
const CLEANUP_TARGET_KEYS = new Set([
"kind",
"working_directory_id",
"repository_id",
]);
const OCCUPANCY_KEYS = new Set([
"runtime_id",
"worker_id",
"display_name",
"linked_at",
]);
export function parseWorkingDirectoryListResponse(
value: unknown,
): WorkingDirectoryListResponse {
const record = exactRecord(
value,
new Set(["workspace_id", "items", "diagnostics"]),
"Workdir list response",
);
return {
workspace_id: stringField(record, "workspace_id"),
items: arrayField(record, "items").map(parseSummary),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
export function parseWorkingDirectoryDetailResponse(
value: unknown,
): WorkingDirectoryDetailResponse {
return parseDetailLike(value, "Workdir detail response");
}
export function parseWorkingDirectoryCreateResponse(
value: unknown,
): WorkingDirectoryCreateResponse {
return parseDetailLike(value, "Workdir create response");
}
export function validateWorkingDirectoryCreateRequest(
value: unknown,
): WorkingDirectoryCreateRequest {
const record = exactRecord(
value,
CREATE_REQUEST_KEYS,
"Workdir create request",
);
const request: WorkingDirectoryCreateRequest = {
repository_id: stringField(record, "repository_id"),
};
assignOptionalString(request, record, "runtime_id");
assignOptionalString(request, record, "selector");
assignOptionalString(request, record, "operation_id");
return request;
}
function parseDetailLike(
value: unknown,
label: string,
): WorkingDirectoryDetailResponse {
const record = exactRecord(
value,
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
label,
);
return {
workspace_id: stringField(record, "workspace_id"),
runtime_id: stringField(record, "runtime_id"),
item: parseSummary(record.item),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
function parseSummary(value: unknown): WorkingDirectorySummary {
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
const summary: WorkingDirectorySummary = {
working_directory_id: stringField(record, "working_directory_id"),
repository_id: stringField(record, "repository_id"),
materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_cache",
"local_git_worktree",
]),
status: enumField(record, "status", [
"active",
"cleanup_pending",
"corrupted",
"not_found",
"unknown",
]),
};
assignOptionalString(summary, record, "creation_selector");
assignOptionalString(summary, record, "creation_ref");
assignOptionalString(summary, record, "creation_tree");
assignOptionalString(summary, record, "current_selector");
assignOptionalString(summary, record, "current_ref");
assignOptionalString(summary, record, "current_tree");
assignOptionalString(summary, record, "cleanliness");
assignOptionalString(summary, record, "primary_worker_id");
if (record.observed_at_epoch_seconds !== undefined) {
const observedAt = record.observed_at_epoch_seconds;
if (observedAt === null) {
summary.observed_at_epoch_seconds = null;
} else {
if (!Number.isSafeInteger(observedAt) || Number(observedAt) < 0) {
throw new Error(
"Workdir summary.observed_at_epoch_seconds must be a non-negative safe integer or null",
);
}
summary.observed_at_epoch_seconds = Number(observedAt);
}
}
if (record.cleanup_target !== undefined) {
summary.cleanup_target = record.cleanup_target === null
? null
: parseCleanupTarget(record.cleanup_target);
}
if (record.occupied_by !== undefined) {
summary.occupied_by = record.occupied_by === null
? null
: parseOccupancy(record.occupied_by);
}
return summary;
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord(
value,
CLEANUP_TARGET_KEYS,
"Workdir cleanup target",
);
return {
kind: stringField(record, "kind"),
working_directory_id: stringField(record, "working_directory_id"),
repository_id: stringField(record, "repository_id"),
};
}
function parseOccupancy(value: unknown): WorkingDirectoryOccupancy {
const record = exactRecord(value, OCCUPANCY_KEYS, "Workdir occupancy");
return {
runtime_id: stringField(record, "runtime_id"),
worker_id: stringField(record, "worker_id"),
display_name: stringField(record, "display_name"),
linked_at: stringField(record, "linked_at"),
};
}
function parseDiagnostic(value: unknown): Diagnostic {
const record = exactRecord(value, DIAGNOSTIC_KEYS, "Workdir diagnostic");
return {
code: stringField(record, "code"),
severity: enumField(record, "severity", ["info", "warning", "error"]),
message: stringField(record, "message"),
};
}
function exactRecord(
value: unknown,
keys: ReadonlySet<string>,
label: string,
): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!keys.has(key)) {
throw new Error(`${label} contains unknown field ${key}`);
}
}
return record;
}
function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${key} must be a non-empty string`);
}
return value;
}
function arrayField(record: Record<string, unknown>, key: string): unknown[] {
const value = record[key];
if (!Array.isArray(value)) throw new Error(`${key} must be an array`);
return value;
}
function enumField<T extends string>(
record: Record<string, unknown>,
key: string,
values: readonly T[],
): T {
const value = record[key];
if (typeof value !== "string" || !values.includes(value as T)) {
throw new Error(`${key} has an unsupported value`);
}
return value as T;
}
function assignOptionalString<T extends object>(
target: T,
source: Record<string, unknown>,
key: string,
): void {
const value = source[key];
if (value === undefined) return;
if (value === null) {
(target as Record<string, unknown>)[key] = null;
return;
}
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${key} must be a non-empty string or null`);
}
(target as Record<string, unknown>)[key] = value;
}
@@ -1,41 +1,18 @@
export type WorkspaceCatalogRecord = { import {
workspace_id: string; parseRepositoryListResponse,
owner_account_id: string | null; parseWorkspaceCatalogResponse,
display_name: string; parseWorkspaceCreateResponse,
state: string; type RepositorySummary,
created_at: string; type WorkspaceCreateResponse,
updated_at: string; type WorkspaceSummary,
}; } from "$lib/workspace/api/workspace-model";
export type RepositorySourceKind =
| "local_path"
| "file"
| "ssh"
| "http"
| "https"
| "invalid";
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
provider: string | null;
source: {
kind: RepositorySourceKind;
uri: string;
};
default_ref: string | null;
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at: string | null;
};
export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
repositories: WorkspaceRepositoryRecord[]; repositories: RepositorySummary[];
repository_error?: string; repository_error?: string;
}; };
export type CreateWorkspaceResponse = WorkspaceCreateResponse;
export type CreateWorkspaceRequest = { export type CreateWorkspaceRequest = {
operation_key: string; operation_key: string;
@@ -47,14 +24,6 @@ export type CreateWorkspaceRequest = {
}; };
}; };
export type CreateWorkspaceResponse = {
workspace: WorkspaceCatalogRecord;
repository: WorkspaceRepositoryRecord;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
};
export class WorkspaceCatalogError extends Error { export class WorkspaceCatalogError extends Error {
constructor( constructor(
public readonly status: number | null, public readonly status: number | null,
@@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch;
export async function listWorkspaces( export async function listWorkspaces(
fetcher: Fetch, fetcher: Fetch,
): Promise<WorkspaceCatalogRecord[]> { ): Promise<WorkspaceCatalogRecord[]> {
return await fetchJson<WorkspaceCatalogRecord[]>( return parseWorkspaceCatalogResponse(
fetcher, await fetchJson(fetcher, "/api/workspaces?limit=200"),
"/api/workspaces?limit=200",
); );
} }
export async function listWorkspaceRepositories( export async function listWorkspaceRepositories(
fetcher: Fetch, fetcher: Fetch,
workspaceId: string, workspaceId: string,
): Promise<WorkspaceRepositoryRecord[]> { ): Promise<RepositorySummary[]> {
return await fetchJson<WorkspaceRepositoryRecord[]>( return parseRepositoryListResponse(
await fetchJson(
fetcher, fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`, `/api/w/${encodeURIComponent(workspaceId)}/repositories`,
); ),
).items;
} }
export async function loadWorkspaceCatalog( export async function loadWorkspaceCatalog(
@@ -115,11 +85,13 @@ export async function createWorkspace(
fetcher: Fetch, fetcher: Fetch,
request: CreateWorkspaceRequest, request: CreateWorkspaceRequest,
): Promise<CreateWorkspaceResponse> { ): Promise<CreateWorkspaceResponse> {
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", { return parseWorkspaceCreateResponse(
await fetchJson(fetcher, "/api/workspaces", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(request), body: JSON.stringify(request),
}); }),
);
} }
export function creationErrorMessage(error: unknown): string { export function creationErrorMessage(error: unknown): string {
@@ -150,11 +122,11 @@ export function createOperationKey(): string {
}`; }`;
} }
async function fetchJson<T>( async function fetchJson(
fetcher: Fetch, fetcher: Fetch,
input: string, input: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<unknown> {
let response: Response; let response: Response;
try { try {
response = await fetcher(input, init); response = await fetcher(input, init);
@@ -172,7 +144,7 @@ async function fetchJson<T>(
} }
throw new WorkspaceCatalogError(response.status, detail); throw new WorkspaceCatalogError(response.status, detail);
} }
return await response.json() as T; return await response.json() as unknown;
} }
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
@@ -0,0 +1,610 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
Diagnostic,
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryDiagnostic,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySource,
RepositorySourceKind,
RepositorySummary,
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceExtensionPoints,
WorkspaceExtensionPointState,
WorkspacePermissionSummary,
WorkspaceRepositoryRecord,
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/workspace-api.ts";
export type {
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspacePermissionSummary,
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/workspace-api.ts";
type JsonObject = Record<string, unknown>;
const SOURCE_KINDS = new Set<RepositorySourceKind>([
"local_path",
"file",
"ssh",
"http",
"https",
"invalid",
]);
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} must be an object`);
}
return value as JsonObject;
}
function array(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value;
}
function string(value: unknown, path: string): string {
if (typeof value !== "string") throw new Error(`${path} must be a string`);
return value;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
throw new Error(`${path} must be a safe integer`);
}
return value;
}
function nullableString(value: unknown, path: string): string | null {
return value === null ? null : string(value, path);
}
function optionalNullableString(
value: unknown,
path: string,
): string | null | undefined {
return value === undefined ? undefined : nullableString(value, path);
}
function exactKeys(
value: JsonObject,
keys: readonly string[],
path: string,
): void {
const allowed = new Set(keys);
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
if (unexpected) {
throw new Error(`${path}.${unexpected} is not part of the wire contract`);
}
}
function diagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], path);
const severity = string(item.severity, `${path}.severity`);
if (!DIAGNOSTIC_SEVERITIES.has(severity)) {
throw new Error(`${path}.severity is invalid`);
}
return {
code: string(item.code, `${path}.code`),
severity: severity as Diagnostic["severity"],
message: string(item.message, `${path}.message`),
};
}
function repositoryDiagnostic(
value: unknown,
path: string,
): RepositoryDiagnostic {
const item = object(value, path);
exactKeys(item, ["severity", "code", "message"], path);
return {
severity: string(item.severity, `${path}.severity`),
code: string(item.code, `${path}.code`),
message: string(item.message, `${path}.message`),
};
}
function repositorySource(value: unknown, path: string): RepositorySource {
const source = object(value, path);
exactKeys(source, ["kind", "uri"], path);
const kind = string(source.kind, `${path}.kind`);
if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
throw new Error(`${path}.kind is invalid`);
}
return {
kind: kind as RepositorySourceKind,
uri: string(source.uri, `${path}.uri`),
};
}
function gitRemote(value: unknown, path: string): GitRemoteSummary {
const remote = object(value, path);
exactKeys(remote, ["name", "fetch_url"], path);
return {
name: string(remote.name, `${path}.name`),
fetch_url: string(remote.fetch_url, `${path}.fetch_url`),
};
}
function gitSummary(value: unknown, path: string): GitRepositorySummary {
const git = object(value, path);
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
return {
status: string(git.status, `${path}.status`),
head: nullableString(git.head, `${path}.head`),
branch: nullableString(git.branch, `${path}.branch`),
dirty: boolean(git.dirty, `${path}.dirty`),
remotes: array(git.remotes, `${path}.remotes`).map((item, index) =>
gitRemote(item, `${path}.remotes[${index}]`)
),
};
}
function repositorySummary(value: unknown, path: string): RepositorySummary {
const item = object(value, path);
exactKeys(
item,
[
"id",
"display_name",
"kind",
"provider",
"source",
"source_revision",
"source_fingerprint",
"observed_status",
"observed_at",
"default_selector",
"record_authority",
"git",
"diagnostics",
],
path,
);
const observedStatus = string(
item.observed_status,
`${path}.observed_status`,
);
if (!OBSERVED_STATUSES.has(observedStatus)) {
throw new Error(`${path}.observed_status is invalid`);
}
const diagnostics =
item.diagnostics === undefined || item.diagnostics === null
? item.diagnostics
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) =>
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
);
return {
id: string(item.id, `${path}.id`),
display_name: string(item.display_name, `${path}.display_name`),
kind: string(item.kind, `${path}.kind`),
provider: string(item.provider, `${path}.provider`),
source: repositorySource(item.source, `${path}.source`),
source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string(
item.source_fingerprint,
`${path}.source_fingerprint`,
),
observed_status: observedStatus as RepositorySummary["observed_status"],
observed_at: optionalNullableString(
item.observed_at,
`${path}.observed_at`,
),
default_selector: optionalNullableString(
item.default_selector,
`${path}.default_selector`,
),
record_authority: string(item.record_authority, `${path}.record_authority`),
git: item.git === undefined || item.git === null
? item.git
: gitSummary(item.git, `${path}.git`),
diagnostics,
};
}
function workspaceSummary(value: unknown, path: string): WorkspaceSummary {
const item = object(value, path);
exactKeys(
item,
[
"workspace_id",
"owner_account_id",
"display_name",
"state",
"created_at",
"updated_at",
],
path,
);
return {
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
owner_account_id: string(
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;
}
@@ -53,6 +53,19 @@ Deno.test("settings shell advertises scoped account authority", () => {
); );
}); });
Deno.test("Repository settings expose the canonical list and Add route", () => {
const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories");
assert(section?.status === "editable", "Repositories should be editable");
assert(
settingsSectionHref("repositories") === "/settings/repositories",
"Repositories should have a dedicated settings route",
);
assert(
section?.bullets.join("\n").includes("without network access"),
"Repository registration copy should preserve the no-network boundary",
);
});
Deno.test("Repository access settings are editable and canonically routed", () => { Deno.test("Repository access settings are editable and canonically routed", () => {
const section = SETTINGS_SECTIONS.find((entry) => const section = SETTINGS_SECTIONS.find((entry) =>
entry.id === "repository-access" entry.id === "repository-access"
@@ -7,6 +7,7 @@ export type Diagnostic = {
export type SettingsSectionId = export type SettingsSectionId =
| "runtimes" | "runtimes"
| "configuration-sources" | "configuration-sources"
| "repositories"
| "repository-access" | "repository-access"
| "profile-sources" | "profile-sources"
| "workspace-identity"; | "workspace-identity";
@@ -54,6 +55,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.", "Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
], ],
}, },
{
id: "repositories",
label: "Repositories",
status: "editable",
summary:
"Register the local and remote Git repositories available to this Workspace.",
bullets: [
"Repository identity and source registration are Workspace-scoped Server authority.",
"Adding a Repository performs validation and persistence without network access.",
"SSH credentials and pinned host trust remain separate Repository Access resources.",
],
},
{ {
id: "repository-access", id: "repository-access",
label: "Repository Access", label: "Repository Access",
@@ -116,6 +129,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}/runtimes`; return `${SETTINGS_ROUTE}/runtimes`;
case "configuration-sources": case "configuration-sources":
return `${SETTINGS_ROUTE}/configuration`; return `${SETTINGS_ROUTE}/configuration`;
case "repositories":
return `${SETTINGS_ROUTE}/repositories`;
case "repository-access": case "repository-access":
return `${SETTINGS_ROUTE}/repository-access`; return `${SETTINGS_ROUTE}/repository-access`;
case "profile-sources": case "profile-sources":
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model'; import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { SidebarSnippet } from './context'; import type { SidebarSnippet } from './context';
let { let {
@@ -13,8 +13,6 @@
content?: SidebarSnippet | null; content?: SidebarSnippet | null;
} = $props(); } = $props();
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
function sectionHref(path: string): string { function sectionHref(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path; return workspaceId ? workspaceRoute(workspaceId, path) : path;
} }
@@ -25,24 +23,12 @@
</script> </script>
<div class="settings-sidebar"> <div class="settings-sidebar">
<div class="section-heading">
<h2>Settings</h2>
</div>
{#if content} {#if content}
{@render content()} {@render content()}
{:else} {:else}
<nav class="sidebar-sections" aria-label="Settings sections"> <nav class="sidebar-sections" aria-label="Settings sections">
<div class="sidebar-nav-section"> <div class="sidebar-nav-section">
<div class="sidebar-list"> <div class="sidebar-list">
<a
class:active={currentPath === settingsHref}
class="sidebar-link"
href={settingsHref}
aria-current={currentPath === settingsHref ? 'page' : undefined}
>
<span class="sidebar-link-label">Overview</span>
</a>
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))} {@const href = sectionHref(settingsSectionHref(section.id))}
<a <a
+48 -178
View File
@@ -1,27 +1,39 @@
import type {
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySummary,
} from "$lib/generated/workdir-api";
import type { import type {
Event as PodProtocolEvent, Event as PodProtocolEvent,
Method as PodProtocolMethod, Method as PodProtocolMethod,
Segment as PodProtocolSegment, Segment as PodProtocolSegment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import type {
GitCommitSummary as SharedGitCommitSummary,
GitRemoteSummary as SharedGitRemoteSummary,
GitRepositorySummary as SharedGitRepositorySummary,
RepositoryDetailResponse as SharedRepositoryDetailResponse,
RepositoryListResponse as SharedRepositoryListResponse,
RepositoryLogResponse as SharedRepositoryLogResponse,
RepositorySummary as SharedRepositorySummary,
WorkspaceResponse as SharedWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; export type {
PodProtocolEvent,
export type ExtensionPoint = { PodProtocolMethod,
status: string; PodProtocolSegment,
note: string; WorkingDirectoryCreateRequest,
diagnostics: Diagnostic[]; WorkingDirectoryCreateResponse,
}; WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
export type WorkspaceResponse = { WorkingDirectoryOccupancy,
workspace_id: string; WorkingDirectorySummary,
display_name: string;
record_authority: string;
extension_points: {
event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint;
companion_console: ExtensionPoint;
};
}; };
export type WorkspaceResponse = SharedWorkspaceResponse;
export type Diagnostic = { export type Diagnostic = {
code: string; code: string;
@@ -111,44 +123,6 @@ export type WorkingDirectoryRepositoryOption = {
default_selector?: string | null; default_selector?: string | null;
}; };
export type WorkingDirectoryOccupancy = {
runtime_id: string;
worker_id: string;
display_name: string;
linked_at: string;
};
export type WorkingDirectorySummary = {
working_directory_id: string;
repository_id: string;
creation_selector?: string | null;
creation_ref?: string | null;
current_selector?: string | null;
current_ref?: string | null;
materializer_kind: string;
status: string;
cleanliness?: string | null;
primary_worker_id?: string | null;
occupied_by?: WorkingDirectoryOccupancy | null;
cleanup_target: {
kind: string;
working_directory_id: string;
repository_id: string;
};
};
export type BrowserWorkingDirectoryCreateResponse = {
workspace_id: string;
item: WorkingDirectorySummary;
diagnostics: Diagnostic[];
};
export type BrowserWorkingDirectoryListResponse = {
workspace_id: string;
items: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
export type CleanupTargetKind = export type CleanupTargetKind =
| "worker_delete" | "worker_delete"
| "workdir_clean_cleanup" | "workdir_clean_cleanup"
@@ -217,12 +191,6 @@ export type BrowserWorkerWorkingDirectorySelection = {
relative_cwd?: string | null; relative_cwd?: string | null;
}; };
export type BrowserWorkingDirectoryCreateRequest = {
runtime_id: string;
repository_id: string;
selector?: string | null;
};
export type WorkerLaunchOptionsResponse = { export type WorkerLaunchOptionsResponse = {
workspace_id: string; workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[]; runtimes: WorkerLaunchRuntimeOption[];
@@ -257,70 +225,13 @@ export type ListResponse<T> = {
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };
export type RepositorySummary = { export type RepositorySummary = SharedRepositorySummary;
id: string; export type GitRepositorySummary = SharedGitRepositorySummary;
display_name: string; export type GitRemoteSummary = SharedGitRemoteSummary;
kind: string; export type GitCommitSummary = SharedGitCommitSummary;
provider: string; export type RepositoryListResponse = SharedRepositoryListResponse;
source: { export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid"; export type RepositoryLogResponse = SharedRepositoryLogResponse;
uri: string;
};
source_revision: number;
source_fingerprint: string;
observed_status: "unverified" | "ready" | "invalid";
observed_at?: string | null;
default_selector?: string | null;
record_authority: string;
git?: GitRepositorySummary | null;
diagnostics?: Diagnostic[];
};
export type GitRepositorySummary = {
status: string;
branch?: string | null;
head?: string | null;
dirty: boolean;
remotes: GitRemoteSummary[];
};
export type GitRemoteSummary = {
name: string;
fetch_url: string;
};
export type GitCommitSummary = {
hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
author_date: string;
parents: string[];
refs: string[];
};
export type RepositoryListResponse = {
workspace_id: string;
items: RepositorySummary[];
source: string;
diagnostics: Diagnostic[];
};
export type RepositoryDetailResponse = {
workspace_id: string;
item: RepositorySummary;
source: string;
};
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: GitCommitSummary[];
diagnostics: Diagnostic[];
};
export type MemoryDocumentResponse = { export type MemoryDocumentResponse = {
body_md: string; body_md: string;
@@ -448,56 +359,15 @@ export type ObjectiveListResponse = {
record_authority: string; record_authority: string;
}; };
export type CompanionState = export type {
| "ready" CompanionCancelRequest,
| "busy" CompanionLifecycleState,
| "error" CompanionMessageDisposition,
| "timeout" CompanionMessageRequest,
| "cancelled" CompanionMessageResponse,
| "accepted" CompanionStatusResponse,
| "rejected"; CompanionTranscriptItem,
CompanionTranscriptProjection,
export type CompanionTransportSummary = { CompanionTranscriptRole,
kind: string; CompanionTransportSummary,
completion: string; } from "$lib/generated/companion-api";
limitation: string;
};
export type CompanionStatusResponse = {
state: CompanionState;
worker?: Worker | null;
transport: CompanionTransportSummary;
diagnostics: Diagnostic[];
};
export type CompanionTranscriptItem = {
sequence: number;
role: "user" | "assistant" | "system" | string;
content: string;
created_at: string;
source: string;
status: string;
};
export type CompanionTranscriptProjection = {
state: CompanionState;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: CompanionTranscriptItem[];
diagnostics: Diagnostic[];
};
export type CompanionMessageRequest = {
content: string;
};
export type CompanionMessageResponse = {
state: CompanionState;
worker?: Worker | null;
user_item?: CompanionTranscriptItem | null;
assistant_item?: CompanionTranscriptItem | null;
transcript: CompanionTranscriptProjection;
diagnostics: Diagnostic[];
};
@@ -680,6 +680,96 @@
} }
} }
.repositories-page {
display: grid;
gap: var(--space-5);
}
.settings-repository-form {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-form h2 {
margin: 0;
}
.settings-repository-form label {
display: grid;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.settings-repository-form input {
min-width: 0;
border: 1px solid var(--line);
border-radius: 0.45rem;
background: var(--bg);
color: var(--text-strong);
padding: 0.65rem 0.75rem;
font: inherit;
font-family: var(--font-mono);
}
.settings-repository-form small,
.settings-repository-table small {
color: var(--text-muted);
font-weight: 400;
}
.settings-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.settings-form-field-wide {
grid-column: 1 / -1;
}
.settings-repository-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-table {
width: 100%;
border-collapse: collapse;
}
.settings-repository-table th,
.settings-repository-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.settings-repository-table th {
color: var(--text-muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.settings-repository-table tbody tr:last-child td {
border-bottom: 0;
}
.settings-repository-table td:first-child,
.settings-repository-table td:nth-child(2) {
display: grid;
gap: var(--space-1);
}
.settings-repository-table code {
overflow-wrap: anywhere;
}
.settings-muted-action {
color: var(--text-muted);
}
@media (max-width: 48rem) {
.settings-form-grid {
grid-template-columns: 1fr;
}
.settings-form-field-wide {
grid-column: auto;
}
}
.status-message.error { .status-message.error {
color: var(--danger); color: var(--danger);
} }
+2 -2
View File
@@ -132,9 +132,9 @@
<code>{workspace.workspace_id}</code> <code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]} {#if workspace.repositories[0]}
<span class="workspace-repository-summary"> <span class="workspace-repository-summary">
{workspace.repositories[0].name} {workspace.repositories[0].display_name}
<small> <small>
{workspace.repositories[0].default_ref ?? "repository default"} · {workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind} {workspace.repositories[0].kind}
</small> </small>
</span> </span>
@@ -1,34 +1,51 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
export const load: LayoutLoad = async ({ fetch, params }) => { export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const [workspace, repositories] = await Promise.all([ const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<WorkspaceResponse>( loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
fetch, loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
workspaceApiPath(workspaceId, "/workspace"),
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(workspaceId, "/repositories"),
),
]); ]);
if (!workspace.data) { let workspace = null;
let workspaceError = workspaceResult.error;
if (workspaceResult.data !== null) {
try {
workspace = parseWorkspaceResponse(workspaceResult.data);
} catch (cause) {
workspaceError = cause instanceof Error
? cause.message
: "invalid workspace response";
}
}
if (!workspace) {
error(404, { error(404, {
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`, message: workspaceError ?? `Workspace ${workspaceId} is unavailable`,
}); });
} }
let repositories = null;
let repositoriesError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repositories = parseRepositoryListResponse(repositoryResult.data);
} catch (cause) {
repositoriesError = cause instanceof Error
? cause.message
: "invalid repository list response";
}
}
return { return {
workspace: workspace.data, workspace,
workspaceError: null, workspaceError: null,
repositories: repositories.data, repositories,
repositoriesError: repositories.error, repositoriesError,
}; };
}; };
@@ -1,29 +1,59 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { import {
RepositoryDetailResponse, parseRepositoryDetailResponse,
RepositoryLogResponse, parseRepositoryLogResponse,
} from "$lib/workspace/sidebar/types"; } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const workspaceId = params.workspaceId;
const repositoryId = params.repositoryId; const repositoryId = params.repositoryId;
const [repository, log] = await Promise.all([ const [repositoryResult, logResult] = await Promise.all([
loadJson<RepositoryDetailResponse>( loadJson<unknown>(
fetch, fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`), workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}`,
), ),
loadJson<RepositoryLogResponse>( ),
loadJson<unknown>(
fetch, fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`), workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}/log`,
),
), ),
]); ]);
let repository = null;
let repositoryError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repository = parseRepositoryDetailResponse(repositoryResult.data);
} catch (cause) {
repositoryError = cause instanceof Error
? cause.message
: "invalid repository detail response";
}
}
let log = null;
let logError = logResult.error;
if (logResult.data !== null) {
try {
log = parseRepositoryLogResponse(logResult.data);
} catch (cause) {
logError = cause instanceof Error
? cause.message
: "invalid repository log response";
}
}
return { return {
repositoryId, repositoryId,
repository: repository.data, repository,
repositoryError: repository.error, repositoryError,
repositoryLog: log.data, repositoryLog: log,
repositoryLogError: log.error, repositoryLogError: logError,
}; };
}; };
@@ -1,59 +0,0 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
function href(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
</script>
<section class="settings-card">
<div class="settings-card-header">
<div>
<p class="eyebrow">Overview</p>
<h2>Settings areas</h2>
</div>
</div>
<div class="settings-section-grid">
{#each SETTINGS_SECTIONS as section}
<a class="settings-section-card" href={href(settingsSectionHref(section.id))}>
<span class="section-status-pill">{section.status}</span>
<h3>{section.label}</h3>
<p>{section.summary}</p>
</a>
{/each}
</div>
</section>
<section class="settings-card">
<div class="settings-card-header">
<div>
<p class="eyebrow">Backend pattern</p>
<h2>Authority boundaries</h2>
</div>
</div>
<div class="settings-pattern-grid">
<article>
<h3>Backend-owned state</h3>
<p>
Workspace metadata and Decodal profile sources are edited through Backend APIs, not direct frontend filesystem access.
</p>
</article>
<article>
<h3>Runtime inputs</h3>
<p>
Runtime execution receives explicit resources and WorkingDirectory handles; raw workspace paths stay out of Browser-facing payloads.
</p>
</article>
<article>
<h3>Diagnostics first</h3>
<p>
Settings mutations return typed diagnostics so validation issues are visible without exposing internal paths or credentials.
</p>
</article>
</div>
</section>
@@ -0,0 +1,163 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { workspaceApiPath, workspaceRoute } from '$lib/workspace/api/http';
import type { RepositorySourceKind } from '$lib/generated/workspace-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function sourceLabel(kind: RepositorySourceKind): string {
if (kind === 'local_path' || kind === 'file') return 'Local';
if (kind === 'invalid') return 'Invalid';
return 'Remote Git';
}
function supportsRepositoryAccess(kind: RepositorySourceKind): boolean {
return kind === 'ssh' || kind === 'http' || kind === 'https';
}
let showAddRepository = $state(false);
let repositoryId = $state('');
let displayName = $state('');
let source = $state('');
let defaultRef = $state('');
let pending = $state(false);
let requestError = $state<string | null>(null);
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
}
async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault();
pending = true;
requestError = null;
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/repositories'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_id: repositoryId,
display_name: displayName,
source,
default_ref: defaultRef || null,
}),
});
if (!response.ok) throw new Error(await responseError(response));
repositoryId = '';
displayName = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
pending = false;
}
}
</script>
<svelte:head>
<title>Repositories · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Repository resources" />
</svelte:head>
<section class="repositories-page" aria-labelledby="repositories-heading">
<header class="page-header-row">
<div>
<p class="eyebrow">owner only</p>
<h1 id="repositories-heading">Repositories</h1>
<p>Register the local and remote Git repositories available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRepository = !showAddRepository}>
{showAddRepository ? 'Close' : 'Add Repository'}
</button>
</header>
{#if showAddRepository}
<form class="settings-repository-form" onsubmit={addRepository}>
<h2>Add Repository</h2>
<div class="settings-form-grid">
<label>
Repository ID
<input bind:value={repositoryId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" autocomplete="off" />
</label>
<label>
Display name
<input bind:value={displayName} required maxlength="256" autocomplete="off" />
</label>
<label class="settings-form-field-wide">
Source
<input bind:value={source} required autocomplete="off" placeholder="/absolute/path or ssh://git@example.test/org/repository.git" />
<small>Registration validates the source without accessing the filesystem or network.</small>
</label>
<label>
Default ref
<input bind:value={defaultRef} maxlength="512" autocomplete="off" placeholder="main" />
</label>
</div>
<p class="status-message">
SSH credentials and pinned host keys are managed separately in
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Repository Access</a>.
</p>
<div class="settings-action-row">
<button type="submit" disabled={pending}>{pending ? 'Adding…' : 'Add Repository'}</button>
<button type="button" disabled={pending} onclick={() => showAddRepository = false}>Cancel</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if data.repositoriesError}
<p class="section-state error">{data.repositoriesError}</p>
{:else if !data.repositories}
<p class="section-state">Loading Repositories…</p>
{:else if data.repositories.items.length === 0}
<p class="section-state">No Repositories are registered.</p>
{:else}
<div class="settings-repository-table-wrap">
<table class="settings-repository-table">
<thead>
<tr>
<th>Repository</th>
<th>Source</th>
<th>Default ref</th>
<th>Status</th>
<th>Access</th>
</tr>
</thead>
<tbody>
{#each data.repositories.items as repository (repository.id)}
<tr>
<td>
<a class="inline-link" href={workspaceRoute(data.workspaceId, `/repositories/${encodeURIComponent(repository.id)}`)}>
<strong>{repository.display_name}</strong>
</a>
<small><code>{repository.id}</code></small>
</td>
<td>
<span>{sourceLabel(repository.source.kind)}</span>
<small><code>{repository.source.uri}</code></small>
</td>
<td>{repository.default_selector ?? '—'}</td>
<td>{repository.observed_status}</td>
<td>
{#if supportsRepositoryAccess(repository.source.kind)}
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Configure access</a>
{:else}
<span class="settings-muted-action">Not required</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
@@ -0,0 +1,17 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListResponse } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const repositories = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
undefined,
parseRepositoryListResponse,
);
return {
workspaceId: params.workspaceId,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};
@@ -1,11 +1,24 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type {
CreateRepositorySshCredentialRequest,
DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest,
PutRepositorySshHostTrustRequest,
RepositorySshCredential,
RepositorySshHostTrust,
RotateRepositorySshCredentialRequest,
} from '$lib/generated/repository-access-api';
import {
parseRepositorySshCredential,
parseRepositorySshHostTrust,
} from '$lib/workspace/api/repository-access';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials)); let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts)); let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
const accessProjection = untrack(() => data.accessProjection);
let message = $state<string | null>(null); let message = $state<string | null>(null);
let pending = $state(false); let pending = $state(false);
@@ -29,37 +42,52 @@
return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${crypto.randomUUID()}`;
} }
async function request<T>(path: string, method: string, body: unknown): Promise<T> { function request<T>(
path: string,
method: string,
body: unknown,
parse: (value: unknown) => T
): Promise<T>;
function request(path: string, method: string, body: unknown, parse: null): Promise<void>;
async function request<T>(
path: string,
method: string,
body: unknown,
parse: ((value: unknown) => T) | null
): Promise<T | undefined> {
const response = await fetch(`${base}${path}`, { const response = await fetch(`${base}${path}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!response.ok) { if (!response.ok) {
let detail = `request failed (${response.status})`; throw new Error(`Repository Access request failed with status ${response.status}.`);
try {
const payload = (await response.json()) as { error?: string; message?: string };
detail = payload.message ?? payload.error ?? detail;
} catch {
// Do not surface submitted secret values from response bodies.
} }
throw new Error(detail); if (response.status === 204) return undefined;
const payload: unknown = await response.json();
if (parse === null) {
throw new Error('Repository Access returned an unexpected response body.');
} }
if (response.status === 204) return undefined as T; return parse(payload);
return (await response.json()) as T;
} }
async function createCredential() { async function createCredential() {
pending = true; pending = true;
message = null; message = null;
try { try {
const created = await request<RepositorySshCredential>('/credentials', 'POST', { const body: CreateRepositorySshCredentialRequest = {
operation_id: operationId('credential-create'), operation_id: operationId('credential-create'),
credential_id: credentialId, credential_id: credentialId,
name: credentialName, name: credentialName,
private_key: privateKey, private_key: privateKey,
passphrase: passphrase || null passphrase: passphrase || null
}); };
const created = await request<RepositorySshCredential>(
'/credentials',
'POST',
body,
parseRepositorySshCredential
);
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id)); credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
credentialId = ''; credentialId = '';
credentialName = ''; credentialName = '';
@@ -77,15 +105,17 @@
pending = true; pending = true;
message = null; message = null;
try { try {
const rotated = await request<RepositorySshCredential>( const body: RotateRepositorySshCredentialRequest = {
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
{
operation_id: operationId('credential-rotate'), operation_id: operationId('credential-rotate'),
expected_revision: credential.current_revision, expected_revision: credential.current_revision,
private_key: rotatePrivateKey, private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null passphrase: rotatePassphrase || null
} };
const rotated = await request<RepositorySshCredential>(
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
body,
parseRepositorySshCredential
); );
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
rotateCredentialId = null; rotateCredentialId = null;
@@ -104,10 +134,16 @@
pending = true; pending = true;
message = null; message = null;
try { try {
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', { const body: DeleteRepositorySshCredentialRequest = {
operation_id: operationId('credential-delete'), operation_id: operationId('credential-delete'),
expected_revision: credential.current_revision expected_revision: credential.current_revision
}); };
await request(
`/credentials/${encodeURIComponent(credential.credential_id)}`,
'DELETE',
body,
null
);
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
message = `Credential ${credential.credential_id} deleted.`; message = `Credential ${credential.credential_id} deleted.`;
} catch (error) { } catch (error) {
@@ -121,14 +157,20 @@
pending = true; pending = true;
message = null; message = null;
try { try {
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', { const body: PutRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-create'), operation_id: operationId('host-trust-create'),
host_trust_id: hostTrustId, host_trust_id: hostTrustId,
hostname, hostname,
port, port,
host_key: hostKey, host_key: hostKey,
expected_revision: hostExpectedRevision expected_revision: hostExpectedRevision
}); };
const created = await request<RepositorySshHostTrust>(
'/host-trusts',
'POST',
body,
parseRepositorySshHostTrust
);
hostTrusts = hostExpectedRevision === null hostTrusts = hostExpectedRevision === null
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id)) ? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry); : hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
@@ -158,10 +200,16 @@
pending = true; pending = true;
message = null; message = null;
try { try {
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', { const body: DeleteRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-delete'), operation_id: operationId('host-trust-delete'),
expected_revision: hostTrust.current_revision expected_revision: hostTrust.current_revision
}); };
await request(
`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`,
'DELETE',
body,
null
);
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id); hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
message = `Host trust ${hostTrust.host_trust_id} deleted.`; message = `Host trust ${hostTrust.host_trust_id} deleted.`;
} catch (error) { } catch (error) {
@@ -182,6 +230,18 @@
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p> <p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
{#if message}<p class="status-message">{message}</p>{/if} {#if message}<p class="status-message">{message}</p>{/if}
<div class="settings-runtime-list">
<h3>Active access projection</h3>
<p>Config revision {accessProjection.config_revision} · <code>{accessProjection.projection_digest}</code></p>
{#if accessProjection.bindings.length === 0}<p>No repository access bindings are active.</p>{/if}
{#each accessProjection.bindings as binding (binding.repository_id)}
<div class="card">
<strong>{binding.repository_id}</strong>
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
</div>
{/each}
</div>
<div class="settings-runtime-list"> <div class="settings-runtime-list">
<h3>SSH credentials</h3> <h3>SSH credentials</h3>
{#if credentials.length === 0}<p>No credentials configured.</p>{/if} {#if credentials.length === 0}<p>No credentials configured.</p>{/if}
@@ -1,50 +1,36 @@
import { workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryAccessProjection,
parseRepositorySshCredentials,
parseRepositorySshHostTrusts,
} from "$lib/workspace/api/repository-access";
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
export interface RepositorySshCredential {
credential_id: string;
workspace_id: string;
name: string;
public_key_algorithm: string;
public_key_fingerprint: string;
current_revision: number;
status: string;
created_at: string;
rotated_at: string | null;
referenced_repositories: string[];
}
export interface RepositorySshHostTrust {
host_trust_id: string;
workspace_id: string;
hostname: string;
port: number;
key_algorithm: string;
host_key: string;
fingerprint: string;
current_revision: number;
created_at: string;
updated_at: string;
referenced_repositories: string[];
}
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const base = `/api/w/${ const workspaceId = params.workspaceId;
encodeURIComponent(params.workspaceId) const accessProjection = await loadRepositoryAccessJson(
}/settings/repository-access`; fetch,
const [credentialResult, hostTrustResult] = await Promise.all([ workspaceApiPath(workspaceId, "/settings/repository-access"),
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`), parseRepositoryAccessProjection,
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
]);
if (!credentialResult.data || !hostTrustResult.data) {
throw new Error(
credentialResult.error ?? hostTrustResult.error ??
"Repository access settings unavailable",
); );
} const [credentials, hostTrusts] = await Promise.all([
loadRepositoryAccessJson(
fetch,
workspaceApiPath(workspaceId, "/settings/repository-access/credentials"),
parseRepositorySshCredentials,
),
loadRepositoryAccessJson(
fetch,
workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"),
parseRepositorySshHostTrusts,
),
]);
return { return {
workspaceId: params.workspaceId, workspaceId,
credentials: credentialResult.data, credentials,
hostTrusts: hostTrustResult.data, hostTrusts,
accessProjection,
}; };
}; };
@@ -1,6 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
import type { import type {
BrowserWorkingDirectoryListResponse,
ListResponse, ListResponse,
Runtime, Runtime,
RuntimeCleanupPlanResponse, RuntimeCleanupPlanResponse,
@@ -14,12 +14,14 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch, fetch,
workspaceApiPath(params.workspaceId, "/runtimes"), workspaceApiPath(params.workspaceId, "/runtimes"),
), ),
loadJson<BrowserWorkingDirectoryListResponse>( loadJson(
fetch, fetch,
workspaceApiPath( workspaceApiPath(
params.workspaceId, params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`, `/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
), ),
undefined,
parseWorkingDirectoryListResponse,
), ),
loadJson<RuntimeCleanupPlanResponse>( loadJson<RuntimeCleanupPlanResponse>(
fetch, fetch,
@@ -1,14 +1,12 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
import { import {
canonicalResourceReference, canonicalResourceReference,
resourceKey, resourceKey,
} from "$lib/workspace/resource-links"; } from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { import type { TicketDetail } from "$lib/workspace/sidebar/types";
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
@@ -17,9 +15,9 @@ export const load = (async ({ fetch, params }) => {
params.workspaceId, params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`, `/tickets/${encodeURIComponent(reference)}`,
); );
const [ticket, repositories, orchestrator] = await Promise.all([ const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath), loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>( loadJson<unknown>(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/repositories"), workspaceApiPath(params.workspaceId, "/repositories"),
), ),
@@ -42,6 +40,8 @@ export const load = (async ({ fetch, params }) => {
); );
} }
} }
const repositories = parseRepositoryListApiResult(repositoriesRaw);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
ticketId: ticket.data?.id ?? reference, ticketId: ticket.data?.id ?? reference,
@@ -2,11 +2,14 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '$lib/workspace/api/http';
import {
parseWorkingDirectoryCreateResponse,
validateWorkingDirectoryCreateRequest,
} from '$lib/workspace/api/workdirs';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch'; import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type { import type {
BrowserCreateWorkerResponse, BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic, Diagnostic,
WorkerLaunchOptionsResponse, WorkerLaunchOptionsResponse,
WorkingDirectorySummary, WorkingDirectorySummary,
@@ -160,22 +163,23 @@
creatingWorkingDirectory = true; creatingWorkingDirectory = true;
submitError = null; submitError = null;
try { try {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
});
const response = await fetch( const response = await fetch(
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), { workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(request),
runtime_id: runtimeId,
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
}),
}, },
); );
if (!response.ok) { if (!response.ok) {
submitError = await responseDisplayError(response, 'workdir create failed'); submitError = await responseDisplayError(response, 'workdir create failed');
return; return;
} }
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse; const payload = parseWorkingDirectoryCreateResponse(await response.json());
const items = options?.working_directories ?? []; const items = options?.working_directories ?? [];
options = options options = options
? { ? {
@@ -0,0 +1,56 @@
type TestRegistrar = (name: string, fn: () => void | Promise<void>) => void;
const test =
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const pageSource = await Deno.readTextFile(
new URL(
"../../src/routes/w/[workspaceId]/settings/repositories/+page.svelte",
import.meta.url,
),
);
const loadSource = await Deno.readTextFile(
new URL(
"../../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
import.meta.url,
),
);
test("Repository settings use the scoped list and typed create collection", () => {
for (const token of [
"workspaceApiPath(params.workspaceId, \"/repositories\")",
"workspaceApiPath(data.workspaceId, '/repositories')",
"method: 'POST'",
"repository_id: repositoryId",
"display_name: displayName",
"default_ref: defaultRef || null",
"await invalidateAll()",
]) {
assert(
pageSource.includes(token) || loadSource.includes(token),
`Repository settings should include ${token}`,
);
}
});
test("Repository Add form keeps access secrets outside registration input", () => {
for (const forbidden of [
"private_key",
"passphrase",
"credential_id",
"host_trust_id",
]) {
assert(
!pageSource.includes(forbidden),
`Repository registration must not accept ${forbidden}`,
);
}
assert(
pageSource.includes("/settings/repository-access"),
"remote Repository rows should link to Repository Access",
);
});
@@ -0,0 +1,133 @@
import {
parseRepositoryAccessProjection,
parseRepositorySshCredentials,
parseRepositorySshHostTrusts,
RepositoryAccessSchemaError,
} from "../../src/lib/workspace/api/repository-access.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertSchemaError(body: () => unknown, path: string): void {
try {
body();
} catch (error) {
if (!(error instanceof RepositoryAccessSchemaError)) {
throw error;
}
if (!error.message.includes(path)) {
throw new Error(
`expected schema error path ${path}, got ${error.message}`,
);
}
return;
}
throw new Error(`expected RepositoryAccessSchemaError for ${path}`);
}
const credential = {
credential_id: "deploy-key",
workspace_id: "workspace-1",
name: "Deploy key",
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:credential",
current_revision: 2,
status: "active",
created_at: "2026-09-01T00:00:00Z",
rotated_at: null,
referenced_repositories: ["main"],
};
const hostTrust = {
host_trust_id: "gitea",
workspace_id: "workspace-1",
hostname: "gitea.example.test",
port: 22,
key_algorithm: "ssh-ed25519",
host_key: "ssh-ed25519 AAAA",
fingerprint: "SHA256:host",
current_revision: 3,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-02T00:00:00Z",
referenced_repositories: ["main"],
};
Deno.test("Repository Access parsers accept generated response contracts", () => {
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
assertEquals(
parseRepositoryAccessProjection({
workspace_id: "workspace-1",
config_revision: 4,
projection_digest: "sha256:projection",
bindings: [{
repository_id: "main",
credential_id: "deploy-key",
host_trust_id: "gitea",
access: "read_only",
}],
}),
{
workspace_id: "workspace-1",
config_revision: 4,
projection_digest: "sha256:projection",
bindings: [{
repository_id: "main",
credential_id: "deploy-key",
host_trust_id: "gitea",
access: "read_only",
}],
},
);
});
Deno.test("Repository Access parsers reject malformed list responses", () => {
assertSchemaError(
() => parseRepositorySshCredentials({ credentials: [credential] }),
"credentials",
);
assertSchemaError(
() => parseRepositorySshHostTrusts({ host_trusts: [hostTrust] }),
"host_trusts",
);
});
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
const { current_revision: _revision, ...missingRevision } = credential;
assertSchemaError(
() => parseRepositorySshCredentials([missingRevision]),
"credentials[0].current_revision",
);
assertSchemaError(
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
"host_trusts[0].port",
);
assertSchemaError(
() =>
parseRepositoryAccessProjection({
workspace_id: "workspace-1",
config_revision: 4,
projection_digest: "sha256:projection",
bindings: [{
repository_id: "main",
credential_id: "deploy-key",
host_trust_id: "gitea",
access: "admin",
}],
}),
"access_projection.bindings[0].access",
);
});
Deno.test("Repository Access parsers reject unknown response fields", () => {
assertSchemaError(
() =>
parseRepositorySshCredentials([{ ...credential, private_key: "secret" }]),
"credentials[0].private_key",
);
});
@@ -0,0 +1,114 @@
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
type HttpFailure = { status?: number; body?: { message?: string } };
async function captureHttpFailure(
run: () => Promise<unknown>,
expectedStatus: number,
expectedMessage: string,
): Promise<HttpFailure> {
try {
await run();
} catch (error) {
const failure = error as HttpFailure;
if (failure.status !== expectedStatus) {
throw new Error(
`expected bounded ${expectedStatus}, got ${String(failure.status)}`,
);
}
if (failure.body?.message !== expectedMessage) {
throw new Error(
`unexpected bounded error: ${JSON.stringify(failure.body)}`,
);
}
return failure;
}
throw new Error(`expected bounded ${expectedStatus} error`);
}
for (const status of [401, 403]) {
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
let requests = 0;
await captureHttpFailure(
() =>
loadRepositoryAccessJson(
() => {
requests += 1;
return Promise.resolve(new Response(null, { status }));
},
"/api/w/workspace-1/settings/repository-access",
(value) => value,
),
403,
"Repository Access is unavailable for this account.",
);
if (requests !== 1) {
throw new Error(`expected one bounded request, got ${requests}`);
}
});
}
Deno.test("Repository Access loader maps invalid JSON to safe bounded 502", async () => {
const upstreamSecret = "private-key-must-not-leak";
const failure = await captureHttpFailure(
() =>
loadRepositoryAccessJson(
() =>
Promise.resolve(
new Response(upstreamSecret, {
status: 200,
headers: { "content-type": "application/json" },
}),
),
"/api/w/workspace-1/settings/repository-access/credentials",
(value) => value,
),
502,
"Repository Access returned an invalid JSON response.",
);
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
throw new Error("invalid JSON error exposed upstream response content");
}
});
Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502", async () => {
const failure = await captureHttpFailure(
() =>
loadRepositoryAccessJson(
() => Promise.resolve(Response.json({ stale: true })),
"/api/w/workspace-1/settings/repository-access/credentials",
() => {
throw new RepositoryAccessSchemaError("credentials", "an array");
},
),
502,
"Repository Access response schema mismatch at credentials: expected an array",
);
if (!failure.body?.message?.includes("credentials")) {
throw new Error("schema mismatch error omitted the failing response path");
}
});
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
const upstreamSecret = "secret-ref-must-not-leak";
const failure = await captureHttpFailure(
() =>
loadRepositoryAccessJson(
() =>
Promise.resolve(
Response.json(
{ message: upstreamSecret, secret_ref: upstreamSecret },
{ status: 500 },
),
),
"/api/w/workspace-1/settings/repository-access/host-trusts",
(value) => value,
),
502,
"Repository Access request failed with status 500.",
);
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
throw new Error("bounded upstream error exposed response content");
}
});
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
import.meta.url, import.meta.url,
), ),
); );
const loaderSource = await Deno.readTextFile(
new URL(
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
import.meta.url,
),
);
test("Repository Access Web code consumes workspace-api generated DTOs", () => {
assert(
source.includes("$lib/generated/repository-access-api"),
"mutation code should import generated request and response contracts",
);
assert(
loaderSource.includes("parseRepositorySshCredentials") &&
loaderSource.includes("parseRepositorySshHostTrusts") &&
loaderSource.includes("parseRepositoryAccessProjection"),
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
);
assert(
loaderSource.indexOf('"/settings/repository-access"') <
loaderSource.indexOf("Promise.all"),
"loader should check Repository Access permission before starting list preloads",
);
for (
const duplicate of [
"interface RepositorySshCredential",
"interface RepositorySshHostTrust",
"interface RepositoryAccessProjection",
]
) {
assert(
!loaderSource.includes(duplicate) && !source.includes(duplicate),
`Web code must not redeclare ${duplicate}`,
);
}
});
test("Repository Access renders the shared access projection fields", () => {
for (
const field of [
"accessProjection.config_revision",
"accessProjection.projection_digest",
"accessProjection.bindings",
"binding.repository_id",
"binding.credential_id",
"binding.host_trust_id",
"binding.access",
]
) {
assert(source.includes(field), `missing access projection field ${field}`);
}
});
test("Repository credential submissions clear write-only fields in finally blocks", () => { test("Repository credential submissions clear write-only fields in finally blocks", () => {
const createStart = source.indexOf("async function createCredential()"); const createStart = source.indexOf("async function createCredential()");
+89
View File
@@ -0,0 +1,89 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseWorkingDirectoryCreateResponse,
parseWorkingDirectoryListResponse,
validateWorkingDirectoryCreateRequest,
} from "../src/lib/workspace/api/workdirs.ts";
const summary = {
working_directory_id: "workdir-1",
repository_id: "main",
materializer_kind: "runtime_git_cache",
status: "active",
occupied_by: {
runtime_id: "arcadia",
worker_id: "worker-1",
display_name: "Coder",
linked_at: "2026-01-01T00:00:00Z",
},
};
Deno.test("Workdir REST validation accepts the generated list and create contracts", () => {
const list = parseWorkingDirectoryListResponse({
workspace_id: "workspace-a",
items: [summary],
diagnostics: [],
});
if (list.items[0]?.occupied_by?.runtime_id !== "arcadia") {
throw new Error("occupancy subject was not preserved");
}
const created = parseWorkingDirectoryCreateResponse({
workspace_id: "workspace-a",
runtime_id: "arcadia",
item: summary,
diagnostics: [],
});
if (created.runtime_id !== "arcadia") {
throw new Error("create Runtime was not preserved");
}
});
Deno.test("Workdir REST validation rejects stale response JSON", () => {
let rejected = false;
try {
parseWorkingDirectoryListResponse({
workspace_id: "workspace-a",
items: [summary],
diagnostics: [],
source: "legacy-runtime",
});
} catch {
rejected = true;
}
if (!rejected) throw new Error("stale response field was accepted");
});
Deno.test("Workdir REST validation enforces create operation fields", () => {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: "arcadia",
repository_id: "main",
selector: "develop",
operation_id: "operation-1",
});
if (request.operation_id !== "operation-1") {
throw new Error("operation id was not preserved");
}
for (
const invalid of [
{ runtime_id: "arcadia", operation_id: "operation-1" },
{ repository_id: "main", operation_key: "operation-1" },
]
) {
let rejected = false;
try {
validateWorkingDirectoryCreateRequest(invalid);
} catch {
rejected = true;
}
if (!rejected) {
throw new Error(
`invalid request was accepted: ${JSON.stringify(invalid)}`,
);
}
}
});
+19 -10
View File
@@ -38,7 +38,7 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
return Promise.resolve(Response.json([ return Promise.resolve(Response.json([
{ {
workspace_id: "w-a", workspace_id: "w-a",
owner_account_id: null, owner_account_id: "account-1",
display_name: "Alpha", display_name: "Alpha",
state: "active", state: "active",
created_at: "1", created_at: "1",
@@ -46,7 +46,7 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
}, },
{ {
workspace_id: "w-b", workspace_id: "w-b",
owner_account_id: null, owner_account_id: "account-1",
display_name: "Beta", display_name: "Beta",
state: "active", state: "active",
created_at: "1", created_at: "1",
@@ -55,21 +55,30 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
])); ]));
} }
if (url.includes("w-a")) { if (url.includes("w-a")) {
return Promise.resolve(Response.json([{ return Promise.resolve(Response.json({
workspace_id: "w-a", workspace_id: "w-a",
repository_id: "main", items: [{
name: "Main", id: "main",
kind: "local_path", display_name: "Main",
uri: "/srv/alpha", kind: "git",
default_ref: "develop", provider: "git",
}])); source: { kind: "local_path", uri: "/srv/alpha" },
source_revision: 1,
source_fingerprint: "sha256:alpha",
observed_status: "ready",
default_selector: "develop",
record_authority: "workspace-control-plane",
}],
source: "workspace-control-plane",
diagnostics: [],
}));
} }
return Promise.resolve(new Response("unavailable", { status: 503 })); return Promise.resolve(new Response("unavailable", { status: 503 }));
}; };
const items = await loadWorkspaceCatalog(fetcher as typeof fetch); const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
assertEquals(items.length, 2); assertEquals(items.length, 2);
assertEquals(items[0].repositories[0].repository_id, "main"); assertEquals(items[0].repositories[0].id, "main");
assertEquals(items[1].repositories, []); assertEquals(items[1].repositories, []);
assertEquals(typeof items[1].repository_error, "string"); assertEquals(typeof items[1].repository_error, "string");
}); });
+153
View File
@@ -0,0 +1,153 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFile(path: URL): Promise<string>;
};
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",
);
});
Deno.test("Repository settings consume the validated shared wire shape", async () => {
const [loadSource, pageSource] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
import.meta.url,
),
),
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/repositories/+page.svelte",
import.meta.url,
),
),
]);
for (
const token of [
"parseRepositoryListResponse",
"repository.id",
"repository.observed_status",
"sourceLabel(repository.source.kind)",
"supportsRepositoryAccess(repository.source.kind)",
]
) {
if (!loadSource.includes(token) && !pageSource.includes(token)) {
throw new Error(`Repository settings should include ${token}`);
}
}
for (
const staleToken of [
"repository.repository_id",
"repository.observed.status",
"repository.source.kind === 'remote_git'",
]
) {
if (loadSource.includes(staleToken) || pageSource.includes(staleToken)) {
throw new Error(`Repository settings must not use ${staleToken}`);
}
}
});