Compare commits
36
Commits
62eaefb1fa
...
5cc78d63c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc78d63c6 | ||
|
|
323f5dc09c | ||
|
|
fb97edfe95 | ||
|
|
070f62ef12 | ||
|
|
981749aa3d | ||
|
|
e01b46b30a | ||
|
|
a1b659c45d | ||
|
|
37a012ef92 | ||
|
|
4927e8a843 | ||
|
|
025d6ddb47 | ||
|
|
01a4dfd5d3 | ||
|
|
1d7158a0bf | ||
|
|
9de2afbfc6 | ||
|
|
9013754a3a | ||
|
|
21eea0b104 | ||
|
|
15e8d7365c | ||
|
|
88e3bf7065 | ||
|
|
2765138bf3 | ||
|
|
6b20ceac46 | ||
|
|
d748274905 | ||
|
|
e1578217d5 | ||
|
|
3481682cb4 | ||
|
|
996b7f2468 | ||
|
|
879993b9b1 | ||
|
|
6604154e3f | ||
|
|
a9ad42a970 | ||
|
|
8b3d1302c6 | ||
|
|
ac9269d6ce | ||
|
|
8ffb716817 | ||
|
|
23f671fa48 | ||
|
|
d7cdcde443 | ||
|
|
95a81faf63 | ||
|
|
bb8bb6d099 | ||
|
|
310801a29b | ||
|
|
7d09b20445 | ||
|
|
456a06f194 |
@@ -0,0 +1,3 @@
|
|||||||
|
[build]
|
||||||
|
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
|
||||||
|
incremental = false
|
||||||
Generated
+1
-2
@@ -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]]
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(_)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(®istry);
|
||||||
|
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(®istry);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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![
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
+1052
-1
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
|
||||||
import type {
|
import type {
|
||||||
BrowserWorkingDirectoryListResponse,
|
|
||||||
ListResponse,
|
ListResponse,
|
||||||
Runtime,
|
Runtime,
|
||||||
RuntimeCleanupPlanResponse,
|
RuntimeCleanupPlanResponse,
|
||||||
@@ -14,12 +14,14 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
workspaceApiPath(params.workspaceId, "/runtimes"),
|
||||||
),
|
),
|
||||||
loadJson<BrowserWorkingDirectoryListResponse>(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
params.workspaceId,
|
params.workspaceId,
|
||||||
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`,
|
||||||
),
|
),
|
||||||
|
undefined,
|
||||||
|
parseWorkingDirectoryListResponse,
|
||||||
),
|
),
|
||||||
loadJson<RuntimeCleanupPlanResponse>(
|
loadJson<RuntimeCleanupPlanResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
|
|||||||
@@ -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()");
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user