Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab15aa227 | ||
|
|
4e935c6203 | ||
|
|
eac07cf5a8 | ||
|
|
260259d461 | ||
|
|
9d572d18bc | ||
|
|
f7852e8034 | ||
|
|
8c075de147 | ||
|
|
864367f4f5 | ||
|
|
33db2ea7f4 |
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
- "8787"
|
- "8787"
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/server-data
|
- server-data:/server-data
|
||||||
- ./docker/workspace:/workspace:ro
|
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BackendWorkerLaunchRuntime {
|
struct BackendWorkerLaunchRuntime {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
can_spawn_worker: bool,
|
worker_creation_available: bool,
|
||||||
working_directory_required: bool,
|
working_directory_required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
let runtime = options
|
let runtime = options
|
||||||
.runtimes
|
.runtimes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
|
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||||
@@ -777,7 +777,7 @@ mod tests {
|
|||||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||||
(
|
(
|
||||||
"200 OK",
|
"200 OK",
|
||||||
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#,
|
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"200 OK",
|
"200 OK",
|
||||||
|
|||||||
@@ -1405,7 +1405,7 @@ mod tests {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "completions");
|
assert_eq!(parsed["event"], "completions");
|
||||||
assert_eq!(parsed["data"]["kind"], "file");
|
assert_eq!(parsed["data"]["kind"], "file");
|
||||||
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs");
|
||||||
|
|
||||||
// is_dir defaults to false on inbound payloads that omit it.
|
// is_dir defaults to false on inbound payloads that omit it.
|
||||||
let inbound =
|
let inbound =
|
||||||
|
|||||||
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
|||||||
assert!(
|
assert!(
|
||||||
msg.contains("outside allowed read scope")
|
msg.contains("outside allowed read scope")
|
||||||
|| msg.contains("outside allowed write scope")
|
|| msg.contains("outside allowed write scope")
|
||||||
|
|| msg.contains("outside allowed scope")
|
||||||
|| msg.contains("has not been read"),
|
|| msg.contains("has not been read"),
|
||||||
"symlink escape not rejected: {msg}"
|
"symlink escape not rejected: {msg}"
|
||||||
);
|
);
|
||||||
if !msg.contains("has not been read") {
|
if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") {
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("add the symlink target"),
|
msg.contains("add the symlink target"),
|
||||||
"symlink escape diagnostic should include remediation: {msg}"
|
"symlink escape diagnostic should include remediation: {msg}"
|
||||||
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(format!("{err}").contains("invalid Workdir path"));
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("invalid logical filesystem path"),
|
||||||
|
"absolute path was not rejected as invalid: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn directory_target_is_rejected_for_read() {
|
async fn directory_target_is_rejected_for_read() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
let read = reg.get("Read");
|
let read = reg.get("Read");
|
||||||
let err = read
|
let err = read
|
||||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn glob_finds_written_files() {
|
async fn glob_finds_written_files() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
let glob = reg.get("Glob");
|
let glob = reg.get("Glob");
|
||||||
|
|
||||||
@@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() {
|
|||||||
.await;
|
.await;
|
||||||
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
|
assert!(
|
||||||
|
msg.contains("invalid logical filesystem path"),
|
||||||
|
"unexpected: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let a = dir.path().join("a.txt");
|
let a = dir.path().join("a.txt");
|
||||||
let b = dir.path().join("b.txt");
|
let _b = dir.path().join("b.txt");
|
||||||
std::fs::write(&a, "one\n").unwrap();
|
std::fs::write(&a, "one\n").unwrap();
|
||||||
|
|
||||||
// Read `a` — should appear in recency.
|
// Read `a` — should appear in recency.
|
||||||
|
|||||||
@@ -983,6 +983,14 @@ where
|
|||||||
|
|
||||||
if feature_config.sub_worker.enabled {
|
if feature_config.sub_worker.enabled {
|
||||||
worker.register_worker_orchestration_instruction();
|
worker.register_worker_orchestration_instruction();
|
||||||
|
if !feature_config.worker.enabled {
|
||||||
|
feature_registry.add_module(
|
||||||
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
spawned_registry.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
|
|||||||
@@ -2494,7 +2494,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string());
|
.unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string());
|
||||||
return Err(WorkerError::FlowInput(message));
|
return Err(WorkerError::FlowInput(format!(
|
||||||
|
"{message} (HTTP {})",
|
||||||
|
response.status
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body)
|
let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body)
|
||||||
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
|
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
|
||||||
|
|||||||
@@ -587,6 +587,9 @@ model_id = "test-model"
|
|||||||
max_tokens = 100
|
max_tokens = 100
|
||||||
|
|
||||||
[memory]
|
[memory]
|
||||||
|
workspace_id = "test-workspace"
|
||||||
|
settings_revision = 1
|
||||||
|
language = "English"
|
||||||
extract_threshold = 1
|
extract_threshold = 1
|
||||||
|
|
||||||
[compaction]
|
[compaction]
|
||||||
@@ -749,6 +752,9 @@ model_id = "test-model"
|
|||||||
max_tokens = 100
|
max_tokens = 100
|
||||||
|
|
||||||
[memory]
|
[memory]
|
||||||
|
workspace_id = "test-workspace"
|
||||||
|
settings_revision = 1
|
||||||
|
language = "English"
|
||||||
extract_threshold = 1
|
extract_threshold = 1
|
||||||
|
|
||||||
[[scope.allow]]
|
[[scope.allow]]
|
||||||
|
|||||||
@@ -247,24 +247,6 @@ pub struct RuntimeSourceSummary {
|
|||||||
pub note: String,
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct RuntimeCapabilitySummary {
|
|
||||||
pub can_list_hosts: bool,
|
|
||||||
pub can_list_workers: bool,
|
|
||||||
pub can_get_worker: bool,
|
|
||||||
pub can_spawn_worker: bool,
|
|
||||||
pub can_stop_worker: bool,
|
|
||||||
pub has_workspace_fs: bool,
|
|
||||||
pub has_shell: bool,
|
|
||||||
pub has_git: bool,
|
|
||||||
pub supports_worktrees: bool,
|
|
||||||
pub supports_backend_internal_tools: bool,
|
|
||||||
pub workspace_scope: String,
|
|
||||||
pub max_workers: usize,
|
|
||||||
pub os: String,
|
|
||||||
pub arch: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
@@ -274,7 +256,9 @@ pub struct RuntimeSummary {
|
|||||||
pub source: RuntimeSourceSummary,
|
pub source: RuntimeSourceSummary,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host_ids: Vec<String>,
|
pub host_ids: Vec<String>,
|
||||||
pub capabilities: RuntimeCapabilitySummary,
|
pub worker_creation_available: bool,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::{fs, io};
|
use std::{fs, io};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::hosts::RemoteRuntimeConfig;
|
use crate::hosts::RemoteRuntimeConfig;
|
||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::repositories::ConfiguredRepository;
|
|
||||||
use crate::server::{AuthConfig, ServerConfig};
|
use crate::server::{AuthConfig, ServerConfig};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
|
|
||||||
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||||
include_str!("../../../resources/workspace-backend.default.toml");
|
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
||||||
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
|
|
||||||
const DEFAULT_AUTH_RP_ID: &str = "localhost";
|
|
||||||
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
||||||
const DEFAULT_MAX_RECORDS: usize = 200;
|
const DEFAULT_MAX_RECORDS: usize = 200;
|
||||||
|
|
||||||
fn default_auth_rp_id() -> String {
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
DEFAULT_AUTH_RP_ID.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_origin() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_public_base_url() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_cookie_name() -> String {
|
|
||||||
DEFAULT_AUTH_COOKIE_NAME.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendConfigFile {
|
pub struct ServerHostConfigFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server: WorkspaceBackendServerConfig,
|
pub browser: ServerBrowserConfig,
|
||||||
#[serde(default)]
|
}
|
||||||
pub data: WorkspaceBackendDataConfig,
|
|
||||||
#[serde(default)]
|
impl Default for ServerHostConfigFile {
|
||||||
pub limits: WorkspaceBackendLimitsConfig,
|
fn default() -> Self {
|
||||||
#[serde(default)]
|
Self {
|
||||||
pub auth: WorkspaceBackendAuthConfig,
|
browser: ServerBrowserConfig::default(),
|
||||||
#[serde(default)]
|
}
|
||||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ServerBrowserConfig {
|
||||||
|
#[serde(default = "default_browser_public_url")]
|
||||||
|
pub public_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerBrowserConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
public_url: default_browser_public_url(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_browser_public_url() -> String {
|
||||||
|
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
|
|||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendServerConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub listen: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub frontend_url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub static_assets_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendDataConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub root: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_database_path: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub embedded_runtime_store_root: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendLimitsConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_records: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendAuthConfig {
|
|
||||||
#[serde(default = "default_auth_rp_id")]
|
|
||||||
pub rp_id: String,
|
|
||||||
#[serde(default = "default_auth_origin")]
|
|
||||||
pub origin: String,
|
|
||||||
#[serde(default = "default_auth_public_base_url")]
|
|
||||||
pub public_base_url: String,
|
|
||||||
#[serde(default = "default_auth_cookie_name")]
|
|
||||||
pub cookie_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WorkspaceBackendAuthConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rp_id: default_auth_rp_id(),
|
|
||||||
origin: default_auth_origin(),
|
|
||||||
public_base_url: default_auth_public_base_url(),
|
|
||||||
cookie_name: default_auth_cookie_name(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceRepositoryConfigFile {
|
|
||||||
pub id: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub uri: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub default_selector: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendRuntimesConfig {
|
pub struct WorkspaceBackendRuntimesConfig {
|
||||||
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
|
|||||||
pub token_ref: Option<String>,
|
pub token_ref: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ConfigDiff {
|
|
||||||
pub differs: bool,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigDiff {
|
|
||||||
fn new(default: &str, local: &str) -> Self {
|
|
||||||
if default == local {
|
|
||||||
return Self {
|
|
||||||
differs: false,
|
|
||||||
text: "workspace backend local config matches the packaged default\n".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text = String::from("--- packaged default\n+++ workspace local\n");
|
|
||||||
let default_lines = default.lines().collect::<Vec<_>>();
|
|
||||||
let local_lines = local.lines().collect::<Vec<_>>();
|
|
||||||
let max = default_lines.len().max(local_lines.len());
|
|
||||||
for index in 0..max {
|
|
||||||
match (default_lines.get(index), local_lines.get(index)) {
|
|
||||||
(Some(left), Some(right)) if left == right => {
|
|
||||||
text.push(' ');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), Some(right)) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), None) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, Some(right)) => {
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
differs: true,
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResolvedWorkspaceBackendConfig {
|
pub struct ResolvedWorkspaceBackendConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
|
|||||||
pub database_path: PathBuf,
|
pub database_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ServerHostConfigFile {
|
||||||
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
|
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
manifest::paths::config_dir().map(Self::path_for_config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default() -> Result<Self> {
|
||||||
|
let Some(path) = Self::default_path() else {
|
||||||
|
return Ok(Self::default());
|
||||||
|
};
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(error) => Err(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let raw = fs::read_to_string(path).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to read Server host config `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Self::parse_str(&raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
toml::from_str(raw).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to parse Server host config `{}`: {error}",
|
||||||
|
path.as_ref().display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BackendRuntimesConfigFile {
|
impl BackendRuntimesConfigFile {
|
||||||
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
||||||
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceBackendConfigFile {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
|
pub fn local_dev(
|
||||||
workspace_root
|
|
||||||
.as_ref()
|
|
||||||
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
match fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&path)
|
|
||||||
{
|
|
||||||
Ok(mut file) => {
|
|
||||||
use std::io::Write;
|
|
||||||
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
|
|
||||||
let workspace_root = workspace_root.as_ref();
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
|
|
||||||
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
|
|
||||||
path.display(),
|
|
||||||
workspace_root.display()
|
|
||||||
))),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let raw = toml::to_string_pretty(self).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to serialize workspace backend config: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
fs::write(path, raw)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
toml::from_str(raw).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to parse workspace backend config `{}`: {error}",
|
|
||||||
path.as_ref().display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
|
||||||
identity: WorkspaceIdentity,
|
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
|
||||||
self.resolve_with_runtime_config(
|
|
||||||
workspace_root,
|
|
||||||
identity,
|
|
||||||
&BackendRuntimesConfigFile::default(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_with_runtime_config(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
workspace_root: impl AsRef<Path>,
|
||||||
identity: WorkspaceIdentity,
|
identity: WorkspaceIdentity,
|
||||||
|
host_config: &ServerHostConfigFile,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
runtime_config: &BackendRuntimesConfigFile,
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
) -> Result<Self> {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
let data_root = self
|
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
||||||
.data
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
.root
|
let (browser_public_url, browser_rp_id) =
|
||||||
.as_ref()
|
resolve_browser_public_url(&host_config.browser.public_url)?;
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
|
|
||||||
});
|
|
||||||
let database_path = self
|
|
||||||
.data
|
|
||||||
.workspace_database_path
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(ServerConfig::default_server_database_path);
|
|
||||||
let embedded_runtime_store_root = self
|
|
||||||
.data
|
|
||||||
.embedded_runtime_store_root
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| data_root.join("embedded-runtime"));
|
|
||||||
let listen = self
|
|
||||||
.server
|
|
||||||
.listen
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_LISTEN)
|
|
||||||
.parse::<SocketAddr>()
|
|
||||||
.map_err(|_| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"invalid workspace backend server.listen `{}`",
|
|
||||||
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
||||||
server.database_path = database_path.clone();
|
server.database_path = database_path.clone();
|
||||||
server.frontend_url = self
|
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||||
.server
|
server.max_records = DEFAULT_MAX_RECORDS;
|
||||||
.frontend_url
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
|
|
||||||
server.static_assets_dir = self
|
|
||||||
.server
|
|
||||||
.static_assets_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path));
|
|
||||||
server.embedded_runtime_store_root = embedded_runtime_store_root;
|
|
||||||
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
|
|
||||||
server.repositories = self
|
|
||||||
.repositories
|
|
||||||
.iter()
|
|
||||||
.map(|repository| resolve_repository(workspace_root, repository))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
server.remote_runtime_sources = runtime_config
|
server.remote_runtime_sources = runtime_config
|
||||||
.runtimes
|
.runtimes
|
||||||
.remote
|
.remote
|
||||||
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
|
|||||||
.map(resolve_remote_runtime)
|
.map(resolve_remote_runtime)
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
server.auth = AuthConfig::Passkey {
|
server.auth = AuthConfig::Passkey {
|
||||||
rp_id: self.auth.rp_id.trim().to_string(),
|
rp_id: browser_rp_id,
|
||||||
origin: self.auth.origin.trim().to_string(),
|
origin: browser_public_url.clone(),
|
||||||
public_base_url: self.auth.public_base_url.trim().to_string(),
|
public_base_url: browser_public_url,
|
||||||
cookie_name: self.auth.cookie_name.trim().to_string(),
|
cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
|
||||||
};
|
};
|
||||||
|
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
|
||||||
|
Error::Config(format!("invalid built-in Server listen address: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(ResolvedWorkspaceBackendConfig {
|
Ok(Self {
|
||||||
server,
|
server,
|
||||||
listen,
|
listen,
|
||||||
database_path,
|
database_path,
|
||||||
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedWorkspaceBackendConfig {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
|
|
||||||
let path = path.into();
|
|
||||||
self.database_path = path.clone();
|
|
||||||
self.server.database_path = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
|
|
||||||
self.server.static_assets_dir = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
||||||
self
|
self
|
||||||
@@ -462,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_repository(
|
|
||||||
workspace_root: &Path,
|
|
||||||
config: &WorkspaceRepositoryConfigFile,
|
|
||||||
) -> Result<ConfiguredRepository> {
|
|
||||||
let id = normalize_required_string("repository id", &config.id)?;
|
|
||||||
validate_repository_id(&id)?;
|
|
||||||
let provider =
|
|
||||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
|
||||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
|
||||||
let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?;
|
|
||||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
|
||||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
|
||||||
|
|
||||||
Ok(ConfiguredRepository {
|
|
||||||
id,
|
|
||||||
provider,
|
|
||||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
|
||||||
source,
|
|
||||||
source_revision: 1,
|
|
||||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
|
||||||
observed_at: None,
|
|
||||||
path,
|
|
||||||
display_name,
|
|
||||||
default_selector,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -497,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
|
||||||
value.and_then(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_repository_id(id: &str) -> Result<()> {
|
|
||||||
if id
|
|
||||||
.chars()
|
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(Error::Config(format!(
|
|
||||||
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_repository_source(
|
|
||||||
workspace_root: &Path,
|
|
||||||
id: &str,
|
|
||||||
uri: &str,
|
|
||||||
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
|
|
||||||
match crate::repository_source::parse_repository_source(uri) {
|
|
||||||
Ok(source) => {
|
|
||||||
let path = match source.kind {
|
|
||||||
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
|
|
||||||
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
|
|
||||||
.ok()
|
|
||||||
.and_then(|uri| uri.to_file_path().ok()),
|
|
||||||
workspace_api::RepositorySourceKind::Ssh
|
|
||||||
| workspace_api::RepositorySourceKind::Http
|
|
||||||
| workspace_api::RepositorySourceKind::Https => None,
|
|
||||||
workspace_api::RepositorySourceKind::Invalid => {
|
|
||||||
return Err(Error::Config(format!(
|
|
||||||
"repository `{id}` has an invalid source"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok((source, path))
|
|
||||||
}
|
|
||||||
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
|
|
||||||
let path = resolve_workspace_path(workspace_root, Path::new(uri));
|
|
||||||
let source = workspace_api::RepositorySource {
|
|
||||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
|
||||||
uri: path.to_string_lossy().into_owned(),
|
|
||||||
};
|
|
||||||
Ok((source, Some(path)))
|
|
||||||
}
|
|
||||||
Err(error) => Err(Error::Config(format!(
|
|
||||||
"repository `{id}` has an invalid source: {error}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_remote_runtime(
|
pub(crate) fn resolve_remote_runtime(
|
||||||
config: &RemoteRuntimeConfigFile,
|
config: &RemoteRuntimeConfigFile,
|
||||||
) -> Result<RemoteRuntimeConfig> {
|
) -> Result<RemoteRuntimeConfig> {
|
||||||
if let Some(token_ref) = config.token_ref.as_deref() {
|
if let Some(token_ref) = config.token_ref.as_deref() {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(format!(
|
||||||
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
|
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
|
||||||
config.id
|
config.id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -578,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
|
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||||
if path.is_absolute() {
|
let value = normalize_required_string("browser.public_url", value)?;
|
||||||
path.to_path_buf()
|
let url = Url::parse(&value).map_err(|error| {
|
||||||
} else {
|
Error::Config(format!(
|
||||||
workspace_root.join(path)
|
"browser.public_url must be an absolute http(s) URL: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must use the http or https scheme".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must not contain user information".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must contain only an origin without a path, query, or fragment"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rp_id = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
Ok((url.origin().ascii_serialization(), rp_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -598,14 +314,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn resolved_with_runtimes(
|
||||||
fn missing_config_path_uses_defaults() {
|
runtimes: &BackendRuntimesConfigFile,
|
||||||
|
) -> ResolvedWorkspaceBackendConfig {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
dir.path(),
|
||||||
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
|
runtimes,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_settings_resolve_without_a_repository_file() {
|
||||||
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
|
||||||
|
|
||||||
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
||||||
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
|
let AuthConfig::Passkey {
|
||||||
|
rp_id,
|
||||||
|
origin,
|
||||||
|
public_base_url,
|
||||||
|
..
|
||||||
|
} = &resolved.server.auth;
|
||||||
|
assert_eq!(rp_id, "localhost");
|
||||||
|
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
|
||||||
|
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
|
||||||
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
|
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
assert!(resolved.database_path.ends_with("server.db"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -618,12 +353,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_base_url_is_explicit_and_normalized() {
|
fn backend_base_url_is_explicit_and_normalized() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let listen = "127.0.0.1:48787".parse().unwrap();
|
let listen = "127.0.0.1:48787".parse().unwrap();
|
||||||
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
|
||||||
.unwrap()
|
|
||||||
.resolve(dir.path(), identity())
|
|
||||||
.unwrap()
|
|
||||||
.with_listen(listen)
|
.with_listen(listen)
|
||||||
.with_backend_base_url("http://127.0.0.1:48787/");
|
.with_backend_base_url("http://127.0.0.1:48787/");
|
||||||
|
|
||||||
@@ -635,171 +366,83 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_fields() {
|
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
|
||||||
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
|
let host_config = ServerHostConfigFile::parse_str(
|
||||||
.unwrap_err();
|
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
|
||||||
|
"server.toml",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
|
identity(),
|
||||||
|
&host_config,
|
||||||
|
&BackendRuntimesConfigFile::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let AuthConfig::Passkey {
|
||||||
|
rp_id,
|
||||||
|
origin,
|
||||||
|
public_base_url,
|
||||||
|
..
|
||||||
|
} = &resolved.server.auth;
|
||||||
|
assert_eq!(rp_id, "yoi.example");
|
||||||
|
assert_eq!(origin, "https://yoi.example");
|
||||||
|
assert_eq!(public_base_url, "https://yoi.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_public_url_rejects_non_origin_urls() {
|
||||||
|
for value in [
|
||||||
|
"https://example.test/path",
|
||||||
|
"https://example.test?query=true",
|
||||||
|
"file:///tmp/web",
|
||||||
|
] {
|
||||||
|
let host_config = ServerHostConfigFile {
|
||||||
|
browser: ServerBrowserConfig {
|
||||||
|
public_url: value.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
|
identity(),
|
||||||
|
&host_config,
|
||||||
|
&BackendRuntimesConfigFile::default(),
|
||||||
|
);
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => panic!("expected {value} to be rejected"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("unknown field"),
|
error.to_string().contains("browser.public_url"),
|
||||||
"unexpected error: {error}"
|
"unexpected error for {value}: {error}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_relative_paths_against_workspace_root() {
|
fn server_host_config_loads_only_from_the_explicit_host_path() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
|
||||||
r#"
|
|
||||||
[server]
|
|
||||||
static_assets_dir = "web/build"
|
|
||||||
|
|
||||||
[data]
|
|
||||||
root = ".yoi/backend-data"
|
|
||||||
workspace_database_path = ".yoi/custom.db"
|
|
||||||
embedded_runtime_store_root = ".yoi/runtime-store"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.static_assets_dir,
|
|
||||||
Some(dir.path().join("web/build"))
|
|
||||||
);
|
|
||||||
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".yoi/runtime-store")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absolute_paths_are_preserved() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
workspace_database_path = "/tmp/yoi-workspace.db"
|
|
||||||
embedded_runtime_store_root = "/tmp/yoi-runtime"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.database_path,
|
|
||||||
PathBuf::from("/tmp/yoi-workspace.db")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
PathBuf::from("/tmp/yoi-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn data_root_derives_runtime_store_path_only() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
root = ".local-data"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".local-data/embedded-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn copies_local_config_without_overwriting() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
|
|
||||||
let raw = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
|
|
||||||
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
|
|
||||||
|
|
||||||
fs::write(&path, "# custom local config\n").unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&path).unwrap(),
|
|
||||||
"# custom local config\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_config_diff_reports_match_and_difference() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let matched =
|
|
||||||
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(!matched.differs);
|
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
|
&path,
|
||||||
"[server]\nlisten = \"127.0.0.1:9999\"\n",
|
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(diff.differs);
|
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
|
||||||
assert!(diff.text.contains("+++ workspace local"));
|
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
|
||||||
assert!(diff.text.contains("127.0.0.1:9999"));
|
assert_eq!(path, dir.path().join("server.toml"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_repository_uri_relative_to_workspace_root() {
|
fn explicit_missing_server_host_config_fails_closed() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
assert!(
|
||||||
r#"
|
error
|
||||||
[[repositories]]
|
.to_string()
|
||||||
id = "main"
|
.contains("failed to read Server host config")
|
||||||
provider = "git"
|
|
||||||
uri = "."
|
|
||||||
display_name = "Main"
|
|
||||||
default_selector = "HEAD"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
let repository = resolved.server.repositories.first().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(repository.id, "main");
|
|
||||||
assert_eq!(repository.provider, "git");
|
|
||||||
assert_eq!(repository.path.as_deref(), Some(dir.path()));
|
|
||||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
|
||||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_repository_source_is_preserved_without_a_local_path() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[repositories]]
|
|
||||||
id = "main"
|
|
||||||
provider = "git"
|
|
||||||
uri = "https://example.com/org/repo.git"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
let repository = &resolved.server.repositories[0];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
repository.source.kind,
|
|
||||||
workspace_api::RepositorySourceKind::Https
|
|
||||||
);
|
);
|
||||||
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
|
|
||||||
assert!(repository.path.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -824,28 +467,8 @@ uri = "https://example.com/org/repo.git"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_backend_config_rejects_runtime_entries() {
|
|
||||||
let error = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "arc"
|
|
||||||
endpoint = "http://legacy.example.test"
|
|
||||||
display_name = "legacy arc"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field `runtimes`"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_runtimes_config_is_the_only_runtime_source() {
|
fn backend_runtimes_config_is_the_only_runtime_source() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -856,9 +479,7 @@ display_name = "xdg arc"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = workspace_config
|
let resolved = resolved_with_runtimes(&runtime_config);
|
||||||
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
||||||
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -887,8 +508,6 @@ token = "secret"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -899,9 +518,10 @@ token_ref = "local:remote-token"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let error = match workspace_config.resolve_with_runtime_config(
|
let error = match ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
dir.path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
) {
|
) {
|
||||||
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
||||||
|
|||||||
@@ -177,26 +177,6 @@ impl RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct RuntimeCapabilitySummary {
|
|
||||||
pub can_list_hosts: bool,
|
|
||||||
pub can_list_workers: bool,
|
|
||||||
pub can_get_worker: bool,
|
|
||||||
pub can_spawn_worker: bool,
|
|
||||||
pub can_stop_worker: bool,
|
|
||||||
pub has_workspace_fs: bool,
|
|
||||||
pub has_shell: bool,
|
|
||||||
pub has_git: bool,
|
|
||||||
pub supports_worktrees: bool,
|
|
||||||
pub supports_backend_internal_tools: bool,
|
|
||||||
pub workspace_scope: String,
|
|
||||||
pub max_workers: usize,
|
|
||||||
pub os: String,
|
|
||||||
pub arch: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type HostCapabilitySummary = RuntimeCapabilitySummary;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
@@ -205,7 +185,9 @@ pub struct RuntimeSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub source: RuntimeSourceSummary,
|
pub source: RuntimeSourceSummary,
|
||||||
pub host_ids: Vec<String>,
|
pub host_ids: Vec<String>,
|
||||||
pub capabilities: RuntimeCapabilitySummary,
|
pub worker_creation_available: bool,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +200,8 @@ pub struct HostSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub observed_at: String,
|
pub observed_at: String,
|
||||||
pub last_seen_at: Option<String>,
|
pub last_seen_at: Option<String>,
|
||||||
pub capabilities: HostCapabilitySummary,
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,27 +293,6 @@ impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
|
|
||||||
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
|
|
||||||
Self {
|
|
||||||
can_list_hosts: capabilities.can_list_hosts,
|
|
||||||
can_list_workers: capabilities.can_list_workers,
|
|
||||||
can_get_worker: capabilities.can_get_worker,
|
|
||||||
can_spawn_worker: capabilities.can_spawn_worker,
|
|
||||||
can_stop_worker: capabilities.can_stop_worker,
|
|
||||||
has_workspace_fs: capabilities.has_workspace_fs,
|
|
||||||
has_shell: capabilities.has_shell,
|
|
||||||
has_git: capabilities.has_git,
|
|
||||||
supports_worktrees: capabilities.supports_worktrees,
|
|
||||||
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
|
|
||||||
workspace_scope: capabilities.workspace_scope,
|
|
||||||
max_workers: capabilities.max_workers,
|
|
||||||
os: capabilities.os,
|
|
||||||
arch: capabilities.arch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
||||||
fn from(runtime: RuntimeSummary) -> Self {
|
fn from(runtime: RuntimeSummary) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -340,7 +302,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
|||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
source: runtime.source.into(),
|
source: runtime.source.into(),
|
||||||
host_ids: runtime.host_ids,
|
host_ids: runtime.host_ids,
|
||||||
capabilities: runtime.capabilities.into(),
|
worker_creation_available: runtime.worker_creation_available,
|
||||||
|
os: runtime.os,
|
||||||
|
arch: runtime.arch,
|
||||||
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1890,7 +1854,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
status: "unavailable".to_string(),
|
status: "unavailable".to_string(),
|
||||||
source: RuntimeSourceSummary::embedded_worker_runtime(),
|
source: RuntimeSourceSummary::embedded_worker_runtime(),
|
||||||
host_ids: Vec::new(),
|
host_ids: Vec::new(),
|
||||||
capabilities: embedded_runtime_capabilities(limit, false, false),
|
worker_creation_available: false,
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1876,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
|
worker_creation_available: true,
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1928,7 +1896,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic(
|
||||||
"embedded_runtime_host_boundary",
|
"embedded_runtime_host_boundary",
|
||||||
DiagnosticSeverity::Info,
|
DiagnosticSeverity::Info,
|
||||||
@@ -2576,7 +2545,9 @@ pub struct RemoteRuntimeConfig {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub bearer_token: Option<String>,
|
pub bearer_token: Option<String>,
|
||||||
pub auth: Option<RemoteRuntimeAuthConfig>,
|
pub auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
pub cached_capabilities: RuntimeCapabilitySummary,
|
pub cached_worker_creation_available: bool,
|
||||||
|
pub cached_os: String,
|
||||||
|
pub cached_arch: String,
|
||||||
pub cached_status: String,
|
pub cached_status: String,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
}
|
}
|
||||||
@@ -2598,7 +2569,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
|
|||||||
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
||||||
)
|
)
|
||||||
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
|
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
|
||||||
.field("cached_capabilities", &self.cached_capabilities)
|
.field(
|
||||||
|
"cached_worker_creation_available",
|
||||||
|
&self.cached_worker_creation_available,
|
||||||
|
)
|
||||||
|
.field("cached_os", &self.cached_os)
|
||||||
|
.field("cached_arch", &self.cached_arch)
|
||||||
.field("cached_status", &self.cached_status)
|
.field("cached_status", &self.cached_status)
|
||||||
.field("timeout", &self.timeout)
|
.field("timeout", &self.timeout)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -2619,9 +2595,9 @@ impl RemoteRuntimeConfig {
|
|||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
auth: None,
|
auth: None,
|
||||||
cached_capabilities: remote_runtime_capabilities(
|
cached_worker_creation_available: false,
|
||||||
200, false, false, "unknown", "unknown",
|
cached_os: "unknown".to_string(),
|
||||||
),
|
cached_arch: "unknown".to_string(),
|
||||||
cached_status: "configured".to_string(),
|
cached_status: "configured".to_string(),
|
||||||
timeout: Duration::from_secs(10),
|
timeout: Duration::from_secs(10),
|
||||||
}
|
}
|
||||||
@@ -2632,11 +2608,6 @@ impl RemoteRuntimeConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
|
|
||||||
self.cached_capabilities = capabilities;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
|
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
|
||||||
self.auth = Some(auth);
|
self.auth = Some(auth);
|
||||||
self
|
self
|
||||||
@@ -2708,7 +2679,9 @@ pub struct RemoteWorkerRuntime {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
auth: Option<RemoteRuntimeAuthConfig>,
|
auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
cached_capabilities: RuntimeCapabilitySummary,
|
cached_worker_creation_available: bool,
|
||||||
|
cached_os: String,
|
||||||
|
cached_arch: String,
|
||||||
cached_status: String,
|
cached_status: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
@@ -2768,7 +2741,9 @@ impl RemoteWorkerRuntime {
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
bearer_token: config.bearer_token,
|
bearer_token: config.bearer_token,
|
||||||
auth: config.auth,
|
auth: config.auth,
|
||||||
cached_capabilities: config.cached_capabilities,
|
cached_worker_creation_available: config.cached_worker_creation_available,
|
||||||
|
cached_os: config.cached_os,
|
||||||
|
cached_arch: config.cached_arch,
|
||||||
cached_status: config.cached_status,
|
cached_status: config.cached_status,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
http,
|
http,
|
||||||
@@ -3045,13 +3020,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: remote_runtime_capabilities(
|
worker_creation_available: response.runtime.worker_creation_available,
|
||||||
limit,
|
os: response.runtime.os,
|
||||||
true,
|
arch: response.runtime.arch,
|
||||||
response.runtime.worker_creation_available,
|
|
||||||
response.runtime.os,
|
|
||||||
response.runtime.arch,
|
|
||||||
),
|
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
Err(diagnostic) => RuntimeSummary {
|
Err(diagnostic) => RuntimeSummary {
|
||||||
@@ -3065,7 +3036,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: self.cached_capabilities.clone(),
|
worker_creation_available: self.cached_worker_creation_available,
|
||||||
|
os: self.cached_os.clone(),
|
||||||
|
arch: self.cached_arch.clone(),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![diagnostic],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -3084,7 +3057,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
status: "configured".to_string(),
|
status: "configured".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
|
os: self.cached_os.clone(),
|
||||||
|
arch: self.cached_arch.clone(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -3553,29 +3527,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_runtime_capabilities(
|
|
||||||
limit: usize,
|
|
||||||
available: bool,
|
|
||||||
execution_enabled: bool,
|
|
||||||
) -> RuntimeCapabilitySummary {
|
|
||||||
RuntimeCapabilitySummary {
|
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: available,
|
|
||||||
can_get_worker: available,
|
|
||||||
can_spawn_worker: available,
|
|
||||||
can_stop_worker: available && execution_enabled,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: true,
|
|
||||||
workspace_scope: "backend_internal".to_string(),
|
|
||||||
max_workers: limit,
|
|
||||||
os: std::env::consts::OS.to_string(),
|
|
||||||
arch: std::env::consts::ARCH.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
|
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
RuntimeStatus::Running => "running",
|
RuntimeStatus::Running => "running",
|
||||||
@@ -4122,31 +4073,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_runtime_capabilities(
|
|
||||||
limit: usize,
|
|
||||||
available: bool,
|
|
||||||
worker_creation_available: bool,
|
|
||||||
os: impl Into<String>,
|
|
||||||
arch: impl Into<String>,
|
|
||||||
) -> RuntimeCapabilitySummary {
|
|
||||||
RuntimeCapabilitySummary {
|
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: available,
|
|
||||||
can_get_worker: available,
|
|
||||||
can_spawn_worker: available && worker_creation_available,
|
|
||||||
can_stop_worker: available,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: "remote_runtime_backend_private".to_string(),
|
|
||||||
max_workers: limit,
|
|
||||||
os: os.into(),
|
|
||||||
arch: arch.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
|
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
|
||||||
if err.is_timeout() {
|
if err.is_timeout() {
|
||||||
diagnostic(
|
diagnostic(
|
||||||
@@ -4800,22 +4726,9 @@ mod tests {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
|
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
|
||||||
host_ids: vec![self.host_id.clone()],
|
host_ids: vec![self.host_id.clone()],
|
||||||
capabilities: RuntimeCapabilitySummary {
|
worker_creation_available: false,
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: true,
|
|
||||||
can_get_worker: true,
|
|
||||||
can_spawn_worker: false,
|
|
||||||
can_stop_worker: false,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: "none".to_string(),
|
|
||||||
max_workers: self.workers.len(),
|
|
||||||
os: "test".to_string(),
|
os: "test".to_string(),
|
||||||
arch: "test".to_string(),
|
arch: "test".to_string(),
|
||||||
},
|
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4830,7 +4743,8 @@ mod tests {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
observed_at: "unknown".to_string(),
|
observed_at: "unknown".to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: self.runtime_summary(1).capabilities,
|
os: "test".to_string(),
|
||||||
|
arch: "test".to_string(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -5234,7 +5148,7 @@ mod tests {
|
|||||||
RuntimeSourceKind::EmbeddedWorkerRuntime
|
RuntimeSourceKind::EmbeddedWorkerRuntime
|
||||||
);
|
);
|
||||||
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
||||||
assert!(embedded_summary.capabilities.can_spawn_worker);
|
assert!(embedded_summary.worker_creation_available);
|
||||||
|
|
||||||
let spawned = registry
|
let spawned = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
|
|||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
Err(Error::WorkspaceIdentity(format!(
|
Err(Error::WorkspaceIdentity(format!(
|
||||||
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
|
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
|
||||||
workspace_root.as_ref().display(),
|
|
||||||
workspace_root.as_ref().display()
|
workspace_root.as_ref().display()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -219,7 +218,7 @@ mod tests {
|
|||||||
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("workspace is not initialized"),
|
error.to_string().contains("workspace identity is missing"),
|
||||||
"unexpected error: {error}"
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ pub use authority::{
|
|||||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||||
WorkspaceAuthority,
|
WorkspaceAuthority,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WorkspaceBackendConfigFile,
|
|
||||||
};
|
|
||||||
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::{
|
||||||
|
|||||||
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
|||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
|
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
|
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||||
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Serve(ServeOptions),
|
Serve(ServeOptions),
|
||||||
Init(InitOptions),
|
|
||||||
ConfigDefault,
|
|
||||||
ConfigDiff(WorkspacePathOptions),
|
|
||||||
Identity(Vec<String>),
|
Identity(Vec<String>),
|
||||||
TrustRuntime(Vec<String>),
|
TrustRuntime(Vec<String>),
|
||||||
MigrateDryRun { database: Option<PathBuf> },
|
MigrateDryRun { database: Option<PathBuf> },
|
||||||
@@ -32,16 +28,7 @@ enum Command {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ServeOptions {
|
struct ServeOptions {
|
||||||
listen: Option<SocketAddr>,
|
listen: Option<SocketAddr>,
|
||||||
}
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct InitOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct WorkspacePathOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
match parse_command(&args)? {
|
match parse_command(&args)? {
|
||||||
Command::Serve(options) => run_serve(options).await,
|
Command::Serve(options) => run_serve(options).await,
|
||||||
Command::Init(options) => run_init(options).await,
|
|
||||||
Command::ConfigDefault => run_config_default(),
|
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
|
||||||
Command::Identity(args) => run_identity_command(args),
|
Command::Identity(args) => run_identity_command(args),
|
||||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||||
Command::MigrateDryRun { database } => {
|
Command::MigrateDryRun { database } => {
|
||||||
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"init" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_init_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
|
||||||
}
|
|
||||||
"config" => parse_config_command(rest),
|
|
||||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||||
"migrate" => parse_migrate_command(rest),
|
"migrate" => parse_migrate_command(rest),
|
||||||
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_init_with_database_path(
|
|
||||||
options: InitOptions,
|
|
||||||
database_path: PathBuf,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
|
|
||||||
|
|
||||||
if let Some(parent) = database_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
|
||||||
let service = WorkspaceCatalogService::new(store);
|
|
||||||
service.create_with_workspace_id(
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: format!("cli-init:{}", identity.workspace_id),
|
|
||||||
display_name: identity.display_name.clone(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: options.workspace.display().to_string(),
|
|
||||||
display_name: Some("Main repository".to_string()),
|
|
||||||
default_ref: Some("HEAD".to_string()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
Some(identity.workspace_id.clone()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
|
|
||||||
options.workspace.display(),
|
|
||||||
identity.workspace_id,
|
|
||||||
database_path.display()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
|
|
||||||
print!("{}", diff.text);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct ServerIdentityFile {
|
struct ServerIdentityFile {
|
||||||
identity: RuntimeIdentityMaterial,
|
identity: RuntimeIdentityMaterial,
|
||||||
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
.to_path_buf(),
|
.to_path_buf(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let host_config = match options.config.as_ref() {
|
||||||
|
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||||
|
None => ServerHostConfigFile::load_default()?,
|
||||||
|
};
|
||||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
||||||
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
|
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
identity,
|
identity,
|
||||||
|
&host_config,
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
)?;
|
)?;
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
@@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(resolved.listen).await?;
|
let listener = TcpListener::bind(resolved.listen).await?;
|
||||||
let local_addr = listener.local_addr()?;
|
let local_addr = listener.local_addr()?;
|
||||||
@@ -722,41 +652,6 @@ fn infer_workspace_root_from_repositories(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
};
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"default" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
if !rest.is_empty() {
|
|
||||||
return Err(CliError(
|
|
||||||
"config default does not accept options".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDefault)
|
|
||||||
}
|
|
||||||
"diff" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
|
|
||||||
}
|
|
||||||
"--help" | "-h" => {
|
|
||||||
print_config_help();
|
|
||||||
Ok(Command::Help)
|
|
||||||
}
|
|
||||||
other => Err(CliError(format!(
|
|
||||||
"unknown config subcommand `{other}`; expected `default` or `diff`"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
let mut dry_run = false;
|
let mut dry_run = false;
|
||||||
let mut database = None;
|
let mut database = None;
|
||||||
@@ -840,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
|
|||||||
Ok(SkillWorkspaceOptions { workspace_id })
|
Ok(SkillWorkspaceOptions { workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(WorkspacePathOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown init option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(InitOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
||||||
let mut listen = None;
|
let mut listen = None;
|
||||||
|
let mut config = None;
|
||||||
|
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
while index < args.len() {
|
while index < args.len() {
|
||||||
@@ -906,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
_ if arg.starts_with("--listen=") => {
|
_ if arg.starts_with("--listen=") => {
|
||||||
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
||||||
}
|
}
|
||||||
|
"--config" => {
|
||||||
|
index += 1;
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
|
||||||
|
config = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--config=") => {
|
||||||
|
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
|
||||||
|
}
|
||||||
_ if arg.starts_with('-') => {
|
_ if arg.starts_with('-') => {
|
||||||
return Err(CliError(format!("unknown serve option `{arg}`")));
|
return Err(CliError(format!("unknown serve option `{arg}`")));
|
||||||
}
|
}
|
||||||
@@ -918,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ServeOptions { listen })
|
Ok(ServeOptions { listen, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
||||||
@@ -940,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_init_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_config_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_skills_help() {
|
fn print_skills_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||||
@@ -966,24 +811,23 @@ fn print_skills_help() {
|
|||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
|
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use yoi_workspace_server::{
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WORKSPACE_IDENTITY_RELATIVE_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_init_defaults_workspace_to_cwd_or_flag() {
|
fn removed_repository_local_commands_are_rejected() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
for command in ["init", "config"] {
|
||||||
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
|
let error = parse_command(&[command.to_string()]).unwrap_err();
|
||||||
let options = parse_init_options(&args).unwrap();
|
assert!(
|
||||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
error.to_string().contains("unknown command"),
|
||||||
|
"unexpected error for {command}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1023,10 +867,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_and_host_config() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
let args = vec![
|
||||||
|
"--listen".to_string(),
|
||||||
|
"127.0.0.1:0".to_string(),
|
||||||
|
"--config=/etc/yoi/server.toml".to_string(),
|
||||||
|
];
|
||||||
let options = parse_serve_options(&args).unwrap();
|
let options = parse_serve_options(&args).unwrap();
|
||||||
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
options.config.unwrap(),
|
||||||
|
PathBuf::from("/etc/yoi/server.toml")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1081,49 +933,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn init_creates_identity_local_config_and_server_records() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let database_path = temp.path().join("data").join("server").join("server.db");
|
|
||||||
std::fs::create_dir(temp.path().join(".git")).unwrap();
|
|
||||||
run_init_with_database_path(
|
|
||||||
InitOptions {
|
|
||||||
workspace: temp.path().canonicalize().unwrap(),
|
|
||||||
},
|
|
||||||
database_path.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
|
|
||||||
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
|
|
||||||
assert!(local_config_path.exists());
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(local_config_path).unwrap(),
|
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!temp
|
|
||||||
.path()
|
|
||||||
.join(".yoi/workspace-backend.default.toml")
|
|
||||||
.exists()
|
|
||||||
);
|
|
||||||
assert!(!temp.path().join(".yoi/workspace.db").exists());
|
|
||||||
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
|
|
||||||
assert!(database_path.exists());
|
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
|
||||||
let workspaces = store.list_workspaces().unwrap();
|
|
||||||
assert_eq!(workspaces.len(), 1);
|
|
||||||
let repositories = store
|
|
||||||
.list_repositories(&workspaces[0].workspace_id)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(repositories.len(), 1);
|
|
||||||
assert_eq!(repositories[0].repository_id, "main");
|
|
||||||
assert_eq!(
|
|
||||||
repositories[0].source.uri,
|
|
||||||
temp.path().canonicalize().unwrap().display().to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ pub struct ServerConfig {
|
|||||||
pub workspace_created_at: String,
|
pub workspace_created_at: String,
|
||||||
pub workspace_root: PathBuf,
|
pub workspace_root: PathBuf,
|
||||||
pub database_path: PathBuf,
|
pub database_path: PathBuf,
|
||||||
pub frontend_url: String,
|
|
||||||
pub embedded_runtime_store_root: PathBuf,
|
pub embedded_runtime_store_root: PathBuf,
|
||||||
pub static_assets_dir: Option<PathBuf>,
|
pub static_assets_dir: Option<PathBuf>,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
@@ -173,9 +172,6 @@ pub struct ServerConfig {
|
|||||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||||
pub runtime_config_path: Option<PathBuf>,
|
pub runtime_config_path: Option<PathBuf>,
|
||||||
pub backend_base_url: Option<String>,
|
pub backend_base_url: Option<String>,
|
||||||
/// Allows the first ownerless Workspace to be created without a session.
|
|
||||||
/// This must only be enabled for a loopback-bound local Server.
|
|
||||||
pub allow_local_workspace_bootstrap: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
@@ -190,13 +186,12 @@ impl ServerConfig {
|
|||||||
workspace_created_at: identity.created_at,
|
workspace_created_at: identity.created_at,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
database_path,
|
database_path,
|
||||||
frontend_url: "http://127.0.0.1:5173".to_string(),
|
|
||||||
embedded_runtime_store_root,
|
embedded_runtime_store_root,
|
||||||
static_assets_dir: None,
|
static_assets_dir: None,
|
||||||
auth: AuthConfig::Passkey {
|
auth: AuthConfig::Passkey {
|
||||||
rp_id: "localhost".to_string(),
|
rp_id: "localhost".to_string(),
|
||||||
origin: "http://localhost:8787".to_string(),
|
origin: "http://localhost:5173".to_string(),
|
||||||
public_base_url: "http://localhost:8787".to_string(),
|
public_base_url: "http://localhost:5173".to_string(),
|
||||||
cookie_name: "yoi_workspace_session".to_string(),
|
cookie_name: "yoi_workspace_session".to_string(),
|
||||||
},
|
},
|
||||||
max_records: 200,
|
max_records: 200,
|
||||||
@@ -205,7 +200,6 @@ impl ServerConfig {
|
|||||||
remote_runtime_sources: Vec::new(),
|
remote_runtime_sources: Vec::new(),
|
||||||
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
||||||
backend_base_url: None,
|
backend_base_url: None,
|
||||||
allow_local_workspace_bootstrap: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,11 +256,6 @@ impl ServerConfig {
|
|||||||
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
|
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self {
|
|
||||||
self.allow_local_workspace_bootstrap = enabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
|
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
|
||||||
self.embedded_runtime_store_root = root.into();
|
self.embedded_runtime_store_root = root.into();
|
||||||
self
|
self
|
||||||
@@ -853,9 +842,9 @@ async fn list_server_workspaces(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let owner = match resolve_server_actor(&api, &headers).await {
|
let owner = match resolve_server_actor(&api, &headers).await {
|
||||||
Ok(Some(actor)) => Some(actor.account_id),
|
Ok(Some(actor)) => Some(actor.account_id),
|
||||||
Ok(None) => match api.catalog.list(None, 1) {
|
Ok(None) => match api.catalog.is_empty() {
|
||||||
Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(),
|
Ok(true) => return Json(Vec::<WorkspaceRecord>::new()).into_response(),
|
||||||
Ok(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
Ok(false) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
},
|
},
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
@@ -874,19 +863,14 @@ async fn create_server_workspace(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<WorkspaceCreateRequest>,
|
Json(request): Json<WorkspaceCreateRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await {
|
let owner_account_id = match resolve_server_actor(&api, &headers).await {
|
||||||
Ok(Some(actor)) => (Some(actor.account_id), false),
|
Ok(Some(actor)) => actor.account_id,
|
||||||
Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true),
|
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return forbidden_server_response("Workspace creation requires an authenticated owner");
|
return forbidden_server_response("Workspace creation requires an authenticated owner");
|
||||||
}
|
}
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
};
|
};
|
||||||
let created = match if local_bootstrap {
|
let created = match api.catalog.create(request, owner_account_id) {
|
||||||
api.catalog.create_first_ownerless(request)
|
|
||||||
} else {
|
|
||||||
api.catalog.create(request, owner_account_id)
|
|
||||||
} {
|
|
||||||
Ok(created) => created,
|
Ok(created) => created,
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
};
|
};
|
||||||
@@ -1225,6 +1209,33 @@ pub async fn build_workspace_server_router(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn seed_test_registered_workspace(
|
||||||
|
store: &dyn ControlPlaneStore,
|
||||||
|
config: &ServerConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
let account_id = format!("account-{}", config.workspace_id);
|
||||||
|
store.upsert_account(&AccountRecord {
|
||||||
|
account_id: account_id.clone(),
|
||||||
|
kind: "user".to_owned(),
|
||||||
|
handle: format!("owner-{}", config.workspace_id),
|
||||||
|
display_name: "Workspace Owner".to_owned(),
|
||||||
|
created_at: config.workspace_created_at.clone(),
|
||||||
|
updated_at: config.workspace_created_at.clone(),
|
||||||
|
})?;
|
||||||
|
store
|
||||||
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
|
workspace_id: config.workspace_id.clone(),
|
||||||
|
owner_account_id: Some(account_id),
|
||||||
|
display_name: config.workspace_display_name.clone(),
|
||||||
|
state: "active".to_owned(),
|
||||||
|
created_at: config.workspace_created_at.clone(),
|
||||||
|
updated_at: config.workspace_created_at.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkspaceApi {
|
impl WorkspaceApi {
|
||||||
pub fn with_config_schema_provider(
|
pub fn with_config_schema_provider(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -1288,6 +1299,7 @@ impl WorkspaceApi {
|
|||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
seed_test_registered_workspace(store.as_ref(), &config).await?;
|
||||||
Self::new_with_execution_backend_and_broker(
|
Self::new_with_execution_backend_and_broker(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -1307,16 +1319,12 @@ impl WorkspaceApi {
|
|||||||
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
||||||
>,
|
>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
store
|
if store.get_workspace(&config.workspace_id).await?.is_none() {
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
return Err(crate::Error::Config(format!(
|
||||||
workspace_id: config.workspace_id.clone(),
|
"Workspace {} is not registered in the Server DB",
|
||||||
owner_account_id: None,
|
config.workspace_id
|
||||||
display_name: config.workspace_display_name.clone(),
|
)));
|
||||||
state: "active".to_string(),
|
}
|
||||||
created_at: config.workspace_created_at.clone(),
|
|
||||||
updated_at: config.workspace_created_at.clone(),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
import_configured_repositories(store.as_ref(), &config)?;
|
import_configured_repositories(store.as_ref(), &config)?;
|
||||||
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
|
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
|
||||||
let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
|
let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
|
||||||
@@ -2764,7 +2772,7 @@ pub struct RuntimeConnectionSummary {
|
|||||||
pub built_in: bool,
|
pub built_in: bool,
|
||||||
pub config_managed: bool,
|
pub config_managed: bool,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub can_spawn_worker: bool,
|
pub worker_creation_available: bool,
|
||||||
pub restart_required: bool,
|
pub restart_required: bool,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
@@ -2824,7 +2832,7 @@ pub struct WorkerLaunchRuntimeOption {
|
|||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub built_in: bool,
|
pub built_in: bool,
|
||||||
pub can_spawn_worker: bool,
|
pub worker_creation_available: bool,
|
||||||
pub working_directory_required: bool,
|
pub working_directory_required: bool,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
@@ -4517,9 +4525,9 @@ async fn scoped_queue_ticket(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
Json(_request): Json<BrowserQueueTicketRequest>,
|
Json(_request): Json<BrowserQueueTicketRequest>,
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
) -> ApiResult<Json<ticket::TicketQueueOutcome>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let _ = execute_ticket_rest_operation(
|
let result = execute_ticket_rest_operation(
|
||||||
&api,
|
&api,
|
||||||
&path.workspace_id,
|
&path.workspace_id,
|
||||||
HeaderMap::new(),
|
HeaderMap::new(),
|
||||||
@@ -4529,8 +4537,10 @@ async fn scoped_queue_ticket(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let Json(ticket) = browser_ticket_detail(&api, &path.id)?;
|
ticket_rest_result(result, |result| match result {
|
||||||
Ok(Json(ticket))
|
TicketBackendOperationResult::QueueOutcome(outcome) => Some(outcome),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_close_ticket(
|
async fn scoped_close_ticket(
|
||||||
@@ -7227,14 +7237,13 @@ fn select_memory_consolidation_runtime(api: &WorkspaceApi) -> ApiResult<String>
|
|||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| {
|
.find(|runtime| {
|
||||||
runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID
|
runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && runtime.worker_creation_available
|
||||||
&& runtime.capabilities.can_spawn_worker
|
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
runtimes
|
runtimes
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| runtime.capabilities.can_spawn_worker)
|
.find(|runtime| runtime.worker_creation_available)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
return Ok(runtime.runtime_id.clone());
|
return Ok(runtime.runtime_id.clone());
|
||||||
@@ -8573,29 +8582,6 @@ async fn create_workspace_working_directory(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !runtime.capabilities.supports_worktrees {
|
|
||||||
api.config_store.finish_workdir_create_operation(
|
|
||||||
workspace_id,
|
|
||||||
&operation_id,
|
|
||||||
&request_fingerprint,
|
|
||||||
false,
|
|
||||||
Some("runtime_workdir_unsupported"),
|
|
||||||
&now_registry_timestamp(),
|
|
||||||
)?;
|
|
||||||
return Err(ApiError::with_diagnostics(
|
|
||||||
Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: reserved.resolved_runtime_id,
|
|
||||||
code: "runtime_workdir_unsupported".to_string(),
|
|
||||||
message: "Selected Runtime does not support Workdir creation".to_string(),
|
|
||||||
},
|
|
||||||
vec![RuntimeDiagnostic {
|
|
||||||
code: "runtime_workdir_unsupported".to_string(),
|
|
||||||
severity: DiagnosticSeverity::Error,
|
|
||||||
message: "Selected Runtime does not support Workdir creation".to_string(),
|
|
||||||
}],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
working_directory_request.backend_workdir_id = Some(reserved.working_directory_id.clone());
|
working_directory_request.backend_workdir_id = Some(reserved.working_directory_id.clone());
|
||||||
let existing = match api.runtime.working_directory(
|
let existing = match api.runtime.working_directory(
|
||||||
&reserved.resolved_runtime_id,
|
&reserved.resolved_runtime_id,
|
||||||
@@ -12719,7 +12705,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
|
|||||||
built_in: true,
|
built_in: true,
|
||||||
config_managed: false,
|
config_managed: false,
|
||||||
active: runtime.status == "active",
|
active: runtime.status == "active",
|
||||||
can_spawn_worker: runtime.capabilities.can_spawn_worker,
|
worker_creation_available: runtime.worker_creation_available,
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
diagnostics: runtime.diagnostics,
|
diagnostics: runtime.diagnostics,
|
||||||
@@ -12731,7 +12717,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
|
|||||||
built_in: true,
|
built_in: true,
|
||||||
config_managed: false,
|
config_managed: false,
|
||||||
active: false,
|
active: false,
|
||||||
can_spawn_worker: false,
|
worker_creation_available: false,
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
status: "unavailable".to_string(),
|
status: "unavailable".to_string(),
|
||||||
diagnostics: vec![settings_diagnostic(
|
diagnostics: vec![settings_diagnostic(
|
||||||
@@ -12760,12 +12746,12 @@ fn remote_runtime_connection_summaries(
|
|||||||
let live = live_runtimes
|
let live = live_runtimes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| runtime.runtime_id == remote.id);
|
.find(|runtime| runtime.runtime_id == remote.id);
|
||||||
let (display_name, kind, active, can_spawn_worker, status, diagnostics) = match live {
|
let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live {
|
||||||
Some(runtime) => (
|
Some(runtime) => (
|
||||||
runtime.label.clone(),
|
runtime.label.clone(),
|
||||||
runtime.kind.clone(),
|
runtime.kind.clone(),
|
||||||
runtime.status == "active",
|
runtime.status == "active",
|
||||||
runtime.capabilities.can_spawn_worker,
|
runtime.worker_creation_available,
|
||||||
runtime.status.clone(),
|
runtime.status.clone(),
|
||||||
runtime.diagnostics.clone(),
|
runtime.diagnostics.clone(),
|
||||||
),
|
),
|
||||||
@@ -12797,7 +12783,7 @@ fn remote_runtime_connection_summaries(
|
|||||||
built_in: false,
|
built_in: false,
|
||||||
config_managed: true,
|
config_managed: true,
|
||||||
active,
|
active,
|
||||||
can_spawn_worker,
|
worker_creation_available,
|
||||||
restart_required,
|
restart_required,
|
||||||
status,
|
status,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
@@ -13312,7 +13298,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchO
|
|||||||
runtime_id: runtime.runtime_id,
|
runtime_id: runtime.runtime_id,
|
||||||
display_name: runtime.label,
|
display_name: runtime.label,
|
||||||
built_in,
|
built_in,
|
||||||
can_spawn_worker: runtime.capabilities.can_spawn_worker,
|
worker_creation_available: runtime.worker_creation_available,
|
||||||
working_directory_required: !built_in,
|
working_directory_required: !built_in,
|
||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
diagnostics: runtime.diagnostics,
|
diagnostics: runtime.diagnostics,
|
||||||
@@ -13701,13 +13687,11 @@ fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagn
|
|||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||||
for runtime in runtimes.items {
|
for runtime in runtimes.items {
|
||||||
if runtime.capabilities.supports_worktrees {
|
|
||||||
match sync_runtime_workdir_observations(api, runtime.runtime_id.as_str()) {
|
match sync_runtime_workdir_observations(api, runtime.runtime_id.as_str()) {
|
||||||
Ok(mut runtime_diagnostics) => diagnostics.append(&mut runtime_diagnostics),
|
Ok(mut runtime_diagnostics) => diagnostics.append(&mut runtime_diagnostics),
|
||||||
Err(err) => diagnostics.extend(err.diagnostics),
|
Err(err) => diagnostics.extend(err.diagnostics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
diagnostics
|
diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14484,8 +14468,8 @@ mod tests {
|
|||||||
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
|
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
|
||||||
|
|
||||||
use crate::hosts::{
|
use crate::hosts::{
|
||||||
RemoteRuntimeAuthConfig, RuntimeCapabilitySummary, TicketWorkerRole, WorkerInputKind,
|
RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState,
|
||||||
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
|
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
|
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
|
||||||
@@ -14547,22 +14531,9 @@ mod tests {
|
|||||||
server_id: "server-test".to_owned(),
|
server_id: "server-test".to_owned(),
|
||||||
server_private_key: "unused".to_owned(),
|
server_private_key: "unused".to_owned(),
|
||||||
}),
|
}),
|
||||||
cached_capabilities: RuntimeCapabilitySummary {
|
cached_worker_creation_available: true,
|
||||||
can_list_hosts: true,
|
cached_os: "test".to_owned(),
|
||||||
can_list_workers: true,
|
cached_arch: "test".to_owned(),
|
||||||
can_get_worker: true,
|
|
||||||
can_spawn_worker: true,
|
|
||||||
can_stop_worker: true,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: api.workspace_id().to_owned(),
|
|
||||||
max_workers: 1,
|
|
||||||
os: "test".to_owned(),
|
|
||||||
arch: "test".to_owned(),
|
|
||||||
},
|
|
||||||
cached_status: "connected".to_owned(),
|
cached_status: "connected".to_owned(),
|
||||||
timeout: std::time::Duration::from_secs(1),
|
timeout: std::time::Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
@@ -15422,13 +15393,34 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_api_does_not_register_a_missing_workspace() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = test_server_config(dir.path());
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||||
|
|
||||||
|
let error = match WorkspaceApi::new(config, store.clone()).await {
|
||||||
|
Ok(_) => panic!("missing Workspace registration must fail closed"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("is not registered in the Server DB")
|
||||||
|
);
|
||||||
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
|
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
init_clean_git_workspace(workspace.path());
|
||||||
let config = test_server_config(workspace.path());
|
let config = test_server_config(workspace.path());
|
||||||
let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap();
|
let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap());
|
||||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
seed_test_registered_workspace(store.as_ref(), &config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let api = WorkspaceApi::new(config, store).await.unwrap();
|
||||||
let workspace_id = api.config.workspace_id.clone();
|
let workspace_id = api.config.workspace_id.clone();
|
||||||
|
|
||||||
let result = scoped_start_workspace_orchestrator(
|
let result = scoped_start_workspace_orchestrator(
|
||||||
@@ -15980,13 +15972,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_errors_preserve_operation_details() {
|
fn backend_errors_preserve_operation_details() {
|
||||||
let sanitized = sanitize_backend_error(
|
let sanitized = sanitize_backend_error("failed to open server database");
|
||||||
"failed to open /home/example/.yoi/workspace-backend.local.toml",
|
assert_eq!(sanitized, "failed to open server database");
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sanitized,
|
|
||||||
"failed to open /home/example/.yoi/workspace-backend.local.toml"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -16425,6 +16412,16 @@ mod tests {
|
|||||||
} = &config.auth;
|
} = &config.auth;
|
||||||
let expected_origin = expected_origin.clone();
|
let expected_origin = expected_origin.clone();
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||||
|
store
|
||||||
|
.upsert_account(&AccountRecord {
|
||||||
|
account_id: "account-auth".to_owned(),
|
||||||
|
kind: "user".to_owned(),
|
||||||
|
handle: "auth-user".to_owned(),
|
||||||
|
display_name: "Auth User".to_owned(),
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||||
let repository = temp.path().join("repository");
|
let repository = temp.path().join("repository");
|
||||||
std::fs::create_dir_all(&repository).unwrap();
|
std::fs::create_dir_all(&repository).unwrap();
|
||||||
@@ -16447,19 +16444,9 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-auth".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
|
||||||
.upsert_account(&AccountRecord {
|
|
||||||
account_id: "account-auth".to_owned(),
|
|
||||||
kind: "user".to_owned(),
|
|
||||||
handle: "auth-user".to_owned(),
|
|
||||||
display_name: "Auth User".to_owned(),
|
|
||||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
|
||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
store
|
store
|
||||||
.upsert_user(&UserRecord {
|
.upsert_user(&UserRecord {
|
||||||
user_id: "user-auth".to_owned(),
|
user_id: "user-auth".to_owned(),
|
||||||
@@ -16811,6 +16798,7 @@ mod tests {
|
|||||||
let mut template = test_server_config(dir.path());
|
let mut template = test_server_config(dir.path());
|
||||||
template.static_assets_dir = Some(static_dir);
|
template.static_assets_dir = Some(static_dir);
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||||
|
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
||||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||||
let workspace_a = catalog
|
let workspace_a = catalog
|
||||||
.create(
|
.create(
|
||||||
@@ -16823,7 +16811,7 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-two-workspaces".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let workspace_b = catalog
|
let workspace_b = catalog
|
||||||
@@ -16837,10 +16825,9 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-two-workspaces".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
|
||||||
let app = build_workspace_server_router(template, store)
|
let app = build_workspace_server_router(template, store)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -16941,13 +16928,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_bootstrap_create_activates_workspace_without_server_restart() {
|
async fn local_workspace_creation_requires_an_authenticated_owner() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let repository = dir.path().join("repository");
|
let repository = dir.path().join("repository");
|
||||||
std::fs::create_dir_all(repository.join(".git")).unwrap();
|
std::fs::create_dir_all(repository.join(".git")).unwrap();
|
||||||
let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true);
|
let template = test_server_config(dir.path());
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||||
let token = seed_test_api_token(store.as_ref(), "bootstrap");
|
|
||||||
let app = build_workspace_server_router(template, store)
|
let app = build_workspace_server_router(template, store)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -16961,8 +16947,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let created = app
|
let response = app
|
||||||
.clone()
|
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
@@ -16973,54 +16958,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(created.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let body = to_bytes(created.into_body(), usize::MAX).await.unwrap();
|
|
||||||
let body: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap();
|
|
||||||
|
|
||||||
let workspace = get_json_authenticated(
|
|
||||||
app.clone(),
|
|
||||||
&format!("/api/w/{workspace_id}/workspace"),
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(workspace["display_name"], "Created Workspace");
|
|
||||||
|
|
||||||
let replayed = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/workspaces")
|
|
||||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(payload.to_string()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replayed.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let second_payload = json!({
|
|
||||||
"operation_key": "bootstrap-2",
|
|
||||||
"display_name": "Second Ownerless Workspace",
|
|
||||||
"repository": {
|
|
||||||
"uri": repository,
|
|
||||||
"display_name": "Repository",
|
|
||||||
"default_ref": "HEAD"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let second = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/workspaces")
|
|
||||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(second_payload.to_string()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -19254,13 +19192,20 @@ mod tests {
|
|||||||
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
||||||
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
||||||
|
|
||||||
let Json(queued) = scoped_queue_ticket(
|
let Json(queue_outcome) = scoped_queue_ticket(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
AxumPath(path()),
|
AxumPath(path()),
|
||||||
Json(BrowserQueueTicketRequest {}),
|
Json(BrowserQueueTicketRequest {}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(queue_outcome.requested_ticket, ticket_id);
|
||||||
|
assert_eq!(queue_outcome.queued_tickets.len(), 2);
|
||||||
|
assert!(queue_outcome.queued_tickets.contains(&related_ticket_id));
|
||||||
|
assert!(queue_outcome.queued_tickets.contains(&ticket_id));
|
||||||
|
let Json(queued) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(queued.state, "queued");
|
assert_eq!(queued.state, "queued");
|
||||||
assert_eq!(queued.queued_by.as_deref(), Some("workspace-web"));
|
assert_eq!(queued.queued_by.as_deref(), Some("workspace-web"));
|
||||||
assert_eq!(queued.relations.blockers.len(), 1);
|
assert_eq!(queued.relations.blockers.len(), 1);
|
||||||
@@ -19916,22 +19861,9 @@ mod tests {
|
|||||||
server_id: "server-main".to_string(),
|
server_id: "server-main".to_string(),
|
||||||
server_private_key: identity.private_key.clone(),
|
server_private_key: identity.private_key.clone(),
|
||||||
}),
|
}),
|
||||||
cached_capabilities: RuntimeCapabilitySummary {
|
cached_worker_creation_available: true,
|
||||||
can_list_hosts: true,
|
cached_os: "test".to_string(),
|
||||||
can_list_workers: true,
|
cached_arch: "test".to_string(),
|
||||||
can_get_worker: true,
|
|
||||||
can_spawn_worker: true,
|
|
||||||
can_stop_worker: true,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: TEST_WORKSPACE_ID.to_string(),
|
|
||||||
max_workers: 1,
|
|
||||||
os: "test".to_string(),
|
|
||||||
arch: "test".to_string(),
|
|
||||||
},
|
|
||||||
cached_status: "connected".to_string(),
|
cached_status: "connected".to_string(),
|
||||||
timeout: std::time::Duration::from_secs(1),
|
timeout: std::time::Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
@@ -20765,6 +20697,81 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_request_proof_accepts_active_worker_create_reservation() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
let mut api = test_api(workspace.path()).await;
|
||||||
|
let identity =
|
||||||
|
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||||
|
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||||
|
let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
|
||||||
|
let memory_settings = store
|
||||||
|
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap();
|
||||||
|
let reserved = store
|
||||||
|
.reserve_worker_create(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
"runtime-test",
|
||||||
|
"spawn-flow-race",
|
||||||
|
&"f".repeat(64),
|
||||||
|
&memory_settings,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let worker_id = reserved.worker_id.to_string();
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/flows/resolve");
|
||||||
|
let signer = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity);
|
||||||
|
let issue = || {
|
||||||
|
signer
|
||||||
|
.issue(
|
||||||
|
"server-test",
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
Some(worker_id.as_str()),
|
||||||
|
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
&path,
|
||||||
|
b"{}",
|
||||||
|
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let proof = issue();
|
||||||
|
let source = crate::worker_source::verify_runtime_request_source_proof_with_store(
|
||||||
|
api.store.as_ref(),
|
||||||
|
&api.config,
|
||||||
|
&proof,
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
&path,
|
||||||
|
&worker_runtime::auth::request_body_digest(b"{}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("active create reservation should establish provisional Worker membership");
|
||||||
|
assert_eq!(source.worker_id, Some(worker_id.clone()));
|
||||||
|
|
||||||
|
store
|
||||||
|
.complete_worker_create_reservation(TEST_WORKSPACE_ID, reserved.worker_id)
|
||||||
|
.unwrap();
|
||||||
|
let proof = issue();
|
||||||
|
let result = crate::worker_source::verify_runtime_request_source_proof_with_store(
|
||||||
|
api.store.as_ref(),
|
||||||
|
&api.config,
|
||||||
|
&proof,
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
&path,
|
||||||
|
&worker_runtime::auth::request_body_digest(b"{}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_request_proof_verifies_path_and_query_for_ticket_search() {
|
async fn runtime_request_proof_verifies_path_and_query_for_ticket_search() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -21232,7 +21239,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_workspace_workdir_create_records_failed_default_resolution() {
|
async fn browser_workspace_workdir_create_delegates_and_records_default_runtime_failure() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(dir.path());
|
init_clean_git_workspace(dir.path());
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
@@ -21257,7 +21264,7 @@ mod tests {
|
|||||||
assert_eq!(response["error"], "Bad Request");
|
assert_eq!(response["error"], "Bad Request");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response["diagnostics"][0]["code"],
|
response["diagnostics"][0]["code"],
|
||||||
"runtime_workdir_unsupported"
|
"embedded_worker_workdir_unsupported"
|
||||||
);
|
);
|
||||||
set_test_default_runtime(&api, "not-a-registered-runtime");
|
set_test_default_runtime(&api, "not-a-registered-runtime");
|
||||||
request_json_authenticated(
|
request_json_authenticated(
|
||||||
@@ -21283,7 +21290,7 @@ mod tests {
|
|||||||
assert_eq!(operation.state, "failed");
|
assert_eq!(operation.state, "failed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
operation.failure.as_deref(),
|
operation.failure.as_deref(),
|
||||||
Some("runtime_workdir_unsupported")
|
Some("embedded_worker_workdir_unsupported")
|
||||||
);
|
);
|
||||||
assert_eq!(operation.config_revision, 2);
|
assert_eq!(operation.config_revision, 2);
|
||||||
assert!(!operation.config_projection_digest.is_empty());
|
assert!(!operation.config_projection_digest.is_empty());
|
||||||
@@ -22331,10 +22338,8 @@ mod tests {
|
|||||||
assert_eq!(hosts["items"][0]["runtime_id"], "embedded-worker-runtime");
|
assert_eq!(hosts["items"][0]["runtime_id"], "embedded-worker-runtime");
|
||||||
let host_id = hosts["items"][0]["host_id"].as_str().unwrap().to_string();
|
let host_id = hosts["items"][0]["host_id"].as_str().unwrap().to_string();
|
||||||
assert_eq!(hosts["items"][0]["kind"], "embedded-worker-runtime-host");
|
assert_eq!(hosts["items"][0]["kind"], "embedded-worker-runtime-host");
|
||||||
assert_eq!(
|
assert_eq!(hosts["items"][0]["os"], std::env::consts::OS);
|
||||||
hosts["items"][0]["capabilities"]["workspace_scope"],
|
assert!(hosts["items"][0].get("capabilities").is_none());
|
||||||
"backend_internal"
|
|
||||||
);
|
|
||||||
assert!(!hosts.to_string().contains("metadata.json"));
|
assert!(!hosts.to_string().contains("metadata.json"));
|
||||||
|
|
||||||
let runtimes = get_json(app.clone(), "/api/runtimes").await;
|
let runtimes = get_json(app.clone(), "/api/runtimes").await;
|
||||||
@@ -22807,11 +22812,8 @@ mod tests {
|
|||||||
"embedded_worker_runtime"
|
"embedded_worker_runtime"
|
||||||
);
|
);
|
||||||
assert_eq!(embedded_summary["source"]["status"], "active");
|
assert_eq!(embedded_summary["source"]["status"], "active");
|
||||||
assert_eq!(
|
assert_eq!(embedded_summary["worker_creation_available"], true);
|
||||||
embedded_summary["capabilities"]["workspace_scope"],
|
assert!(embedded_summary.get("capabilities").is_none());
|
||||||
"backend_internal"
|
|
||||||
);
|
|
||||||
assert_eq!(embedded_summary["capabilities"]["has_workspace_fs"], false);
|
|
||||||
|
|
||||||
let spawned = post_json(
|
let spawned = post_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
|||||||
@@ -1007,6 +1007,11 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
) -> Result<Option<WorkerRegistryRecord>>;
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
|
fn has_active_worker_create_reservation(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
) -> Result<bool>;
|
||||||
fn list_worker_registry(
|
fn list_worker_registry(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -3273,6 +3278,28 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_active_worker_create_reservation(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
) -> Result<bool> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
r#"SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM worker_create_reservations
|
||||||
|
WHERE workspace_id = ?1
|
||||||
|
AND runtime_id = ?2
|
||||||
|
AND worker_id = ?3
|
||||||
|
AND state = 'reserved'
|
||||||
|
)"#,
|
||||||
|
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||||
|
|row| row.get::<_, bool>(0),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn list_worker_registry(
|
fn list_worker_registry(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -10585,6 +10612,12 @@ INSERT INTO workdir_registry (
|
|||||||
);
|
);
|
||||||
assert_eq!(reserved.memory_settings.settings_revision, 1);
|
assert_eq!(reserved.memory_settings.settings_revision, 1);
|
||||||
assert_eq!(reserved.memory_settings.language, "English");
|
assert_eq!(reserved.memory_settings.language, "English");
|
||||||
|
let reserved_worker = RuntimeWorkerRef::new("arcadia", reserved.worker_id.to_string());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.has_active_worker_create_reservation("workspace-a", &reserved_worker)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
let unchanged_memory_settings = store
|
let unchanged_memory_settings = store
|
||||||
.update_workspace_memory_settings("workspace-a", 1, " English ")
|
.update_workspace_memory_settings("workspace-a", 1, " English ")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -10658,6 +10691,11 @@ INSERT INTO workdir_registry (
|
|||||||
store
|
store
|
||||||
.complete_worker_create_reservation("workspace-a", reserved.worker_id)
|
.complete_worker_create_reservation("workspace-a", reserved.worker_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.has_active_worker_create_reservation("workspace-a", &reserved_worker)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
let state: String = store
|
let state: String = store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
|
|||||||
@@ -106,7 +106,10 @@ pub async fn verify_runtime_request_source_proof_with_store(
|
|||||||
let member = store
|
let member = store
|
||||||
.get_worker_registry(workspace_id, &worker)
|
.get_worker_registry(workspace_id, &worker)
|
||||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||||
if member.is_none() {
|
let reserved = store
|
||||||
|
.has_active_worker_create_reservation(workspace_id, &worker)
|
||||||
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||||
|
if member.is_none() && !reserved {
|
||||||
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
|
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ impl WorkspaceCatalogService {
|
|||||||
Self { store }
|
Self { store }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> Result<bool> {
|
||||||
|
Ok(self.store.list_workspaces()?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list(
|
pub fn list(
|
||||||
&self,
|
&self,
|
||||||
owner_account_id: Option<&str>,
|
owner_account_id: Option<&str>,
|
||||||
@@ -76,33 +80,16 @@ impl WorkspaceCatalogService {
|
|||||||
pub fn create(
|
pub fn create(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceCreateRequest,
|
request: WorkspaceCreateRequest,
|
||||||
owner_account_id: Option<String>,
|
owner_account_id: String,
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
self.create_internal(request, owner_account_id, None, false)
|
self.create_internal(request, owner_account_id, None)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_first_ownerless(
|
|
||||||
&self,
|
|
||||||
request: WorkspaceCreateRequest,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
|
||||||
self.create_internal(request, None, None, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_with_workspace_id(
|
|
||||||
&self,
|
|
||||||
request: WorkspaceCreateRequest,
|
|
||||||
owner_account_id: Option<String>,
|
|
||||||
requested_workspace_id: Option<String>,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
|
||||||
self.create_internal(request, owner_account_id, requested_workspace_id, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_internal(
|
fn create_internal(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceCreateRequest,
|
request: WorkspaceCreateRequest,
|
||||||
owner_account_id: Option<String>,
|
owner_account_id: String,
|
||||||
requested_workspace_id: Option<String>,
|
requested_workspace_id: Option<String>,
|
||||||
require_empty_catalog: bool,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
let operation_key = normalize_required(
|
let operation_key = normalize_required(
|
||||||
"operation_key",
|
"operation_key",
|
||||||
@@ -142,7 +129,7 @@ impl WorkspaceCatalogService {
|
|||||||
let fingerprint = workspace_create_fingerprint(
|
let fingerprint = workspace_create_fingerprint(
|
||||||
requested_workspace_id.as_deref(),
|
requested_workspace_id.as_deref(),
|
||||||
&display_name,
|
&display_name,
|
||||||
owner_account_id.as_deref(),
|
Some(&owner_account_id),
|
||||||
&repository_uri,
|
&repository_uri,
|
||||||
&repository_name,
|
&repository_name,
|
||||||
&default_ref,
|
&default_ref,
|
||||||
@@ -153,10 +140,10 @@ 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,
|
require_empty_catalog: false,
|
||||||
workspace: WorkspaceRecord {
|
workspace: WorkspaceRecord {
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
owner_account_id,
|
owner_account_id: Some(owner_account_id),
|
||||||
display_name,
|
display_name,
|
||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
@@ -235,7 +222,7 @@ fn workspace_create_fingerprint(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::SqliteWorkspaceStore;
|
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
||||||
use workspace_api::RepositorySourceKind;
|
use workspace_api::RepositorySourceKind;
|
||||||
|
|
||||||
fn git_repository() -> tempfile::TempDir {
|
fn git_repository() -> tempfile::TempDir {
|
||||||
@@ -244,6 +231,22 @@ mod tests {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn owner_account(store: &SqliteWorkspaceStore) -> String {
|
||||||
|
let account_id = Uuid::now_v7().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
|
store
|
||||||
|
.upsert_account(&AccountRecord {
|
||||||
|
account_id: account_id.clone(),
|
||||||
|
kind: "user".to_string(),
|
||||||
|
handle: format!("owner-{}", &account_id[..8]),
|
||||||
|
display_name: "Workspace Owner".to_string(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
account_id
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_is_atomic_and_exact_retries_converge() {
|
async fn create_is_atomic_and_exact_retries_converge() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
@@ -259,8 +262,11 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let created = service.create(request.clone(), None).unwrap();
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let replayed = service.create(request, None).unwrap();
|
let created = service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service.create(request, owner_account_id).unwrap();
|
||||||
|
|
||||||
assert!(!created.replayed);
|
assert!(!created.replayed);
|
||||||
assert!(replayed.replayed);
|
assert!(replayed.replayed);
|
||||||
@@ -284,64 +290,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
|
||||||
let service = WorkspaceCatalogService::new(store.clone());
|
|
||||||
let repository_a = git_repository();
|
|
||||||
let repository_b = git_repository();
|
|
||||||
let requests = [
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: "bootstrap-a".to_string(),
|
|
||||||
display_name: "Workspace A".to_string(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: repository_a.path().display().to_string(),
|
|
||||||
display_name: None,
|
|
||||||
default_ref: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: "bootstrap-b".to_string(),
|
|
||||||
display_name: "Workspace B".to_string(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: repository_b.path().display().to_string(),
|
|
||||||
display_name: None,
|
|
||||||
default_ref: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
|
||||||
let results = std::thread::scope(|scope| {
|
|
||||||
requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|request| {
|
|
||||||
let service = service.clone();
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
scope.spawn(move || {
|
|
||||||
barrier.wait();
|
|
||||||
service.create_first_ownerless(request)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle| handle.join().unwrap())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
|
||||||
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
|
|
||||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
|
||||||
let error = results
|
|
||||||
.into_iter()
|
|
||||||
.find_map(Result::err)
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
assert!(error.contains("catalog is empty"), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(store);
|
let service = WorkspaceCatalogService::new(store);
|
||||||
let repository = git_repository();
|
let repository = git_repository();
|
||||||
let mut request = WorkspaceCreateRequest {
|
let mut request = WorkspaceCreateRequest {
|
||||||
@@ -353,10 +305,15 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
service.create(request.clone(), None).unwrap();
|
service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.unwrap();
|
||||||
request.display_name = "Workspace B".to_string();
|
request.display_name = "Workspace B".to_string();
|
||||||
|
|
||||||
let error = service.create(request, None).unwrap_err().to_string();
|
let error = service
|
||||||
|
.create(request, owner_account_id)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(error.contains("different input"), "{error}");
|
assert!(error.contains("different input"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +335,11 @@ mod tests {
|
|||||||
#[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());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(store.clone());
|
let service = WorkspaceCatalogService::new(store.clone());
|
||||||
let result = service
|
let result = service
|
||||||
.create_first_ownerless(WorkspaceCreateRequest {
|
.create(
|
||||||
|
WorkspaceCreateRequest {
|
||||||
operation_key: "remote-create".to_string(),
|
operation_key: "remote-create".to_string(),
|
||||||
display_name: "Remote Workspace".to_string(),
|
display_name: "Remote Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: InitialRepositoryIntent {
|
||||||
@@ -388,7 +347,9 @@ mod tests {
|
|||||||
display_name: Some("Remote Repository".to_string()),
|
display_name: Some("Remote Repository".to_string()),
|
||||||
default_ref: Some("main".to_string()),
|
default_ref: Some("main".to_string()),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
owner_account_id,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let persisted = store
|
let persisted = store
|
||||||
|
|||||||
+3
-1
@@ -264,6 +264,8 @@ in
|
|||||||
"serve"
|
"serve"
|
||||||
"--listen"
|
"--listen"
|
||||||
"0.0.0.0:8787"
|
"0.0.0.0:8787"
|
||||||
|
"--config"
|
||||||
|
"/server-config/server.toml"
|
||||||
];
|
];
|
||||||
Env = [
|
Env = [
|
||||||
"PATH=/bin"
|
"PATH=/bin"
|
||||||
@@ -274,7 +276,7 @@ in
|
|||||||
};
|
};
|
||||||
Volumes = {
|
Volumes = {
|
||||||
"/server-data" = { };
|
"/server-data" = { };
|
||||||
"/workspace" = { };
|
"/server-config" = { };
|
||||||
};
|
};
|
||||||
WorkingDir = "/server-data";
|
WorkingDir = "/server-data";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,12 +58,19 @@ The Compose files live at:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
compose.yaml
|
compose.yaml
|
||||||
docker/workspace/.yoi/workspace.toml
|
|
||||||
docker/workspace/.yoi/workspace-backend.local.toml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
|
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
|
||||||
|
|
||||||
|
The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[browser]
|
||||||
|
public_url = "https://yoi.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration.
|
||||||
|
|
||||||
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
|
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
|
||||||
|
|
||||||
## Worker launch path
|
## Worker launch path
|
||||||
|
|||||||
@@ -153,11 +153,7 @@ For repository builds:
|
|||||||
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
||||||
```
|
```
|
||||||
|
|
||||||
If the Server DB has no workspace record yet, initialize it first:
|
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
|
||||||
|
|
||||||
```bash
|
|
||||||
yoi-server init --workspace <WORKSPACE_ROOT>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke checks
|
## Smoke checks
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# Workspace Backend local config template.
|
|
||||||
#
|
|
||||||
# `yoi-server init` copies this packaged template to
|
|
||||||
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
|
|
||||||
# The `.local` file is intentionally git-ignored.
|
|
||||||
#
|
|
||||||
# Print the latest packaged template with:
|
|
||||||
# yoi-server config default
|
|
||||||
#
|
|
||||||
# Compare the local config with the latest packaged template with:
|
|
||||||
# yoi-server config diff
|
|
||||||
#
|
|
||||||
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
|
|
||||||
# settings are represented by leaving the key commented out.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# Backend HTTP/WebSocket listen address.
|
|
||||||
listen = "127.0.0.1:8787"
|
|
||||||
|
|
||||||
# Browser-facing frontend URL used by local tooling/display.
|
|
||||||
frontend_url = "http://127.0.0.1:5173"
|
|
||||||
|
|
||||||
# Static SPA build directory override. Leave commented for dev/API-only mode.
|
|
||||||
# Relative paths are resolved from the workspace root.
|
|
||||||
# static_assets_dir = "web/workspace/dist"
|
|
||||||
|
|
||||||
[data]
|
|
||||||
# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
|
|
||||||
# <data_dir>/server/workspaces/<workspace_id>/
|
|
||||||
# Relative paths are resolved from the workspace root.
|
|
||||||
# root = ".yoi/workspace-backend.data"
|
|
||||||
|
|
||||||
# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
|
|
||||||
# server DB at `<data_dir>/server/server.db`; keep this commented unless a
|
|
||||||
# local test needs a custom DB path.
|
|
||||||
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
|
|
||||||
|
|
||||||
# Explicit embedded Runtime fs-store root override.
|
|
||||||
# If omitted, this falls back to `<data.root>/embedded-runtime`.
|
|
||||||
# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime"
|
|
||||||
|
|
||||||
[limits]
|
|
||||||
max_records = 200
|
|
||||||
|
|
||||||
[auth]
|
|
||||||
# WebAuthn / Passkey relying-party settings. For local development keep rp_id
|
|
||||||
# aligned with the browser host in public_base_url/origin.
|
|
||||||
rp_id = "localhost"
|
|
||||||
origin = "http://localhost:8787"
|
|
||||||
public_base_url = "http://localhost:8787"
|
|
||||||
cookie_name = "yoi_workspace_session"
|
|
||||||
|
|
||||||
# Repository registry. Browser/API repository projection reads only configured
|
|
||||||
# entries and never falls back to the backend process cwd. Relative URI values
|
|
||||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
|
||||||
#
|
|
||||||
# [[repositories]]
|
|
||||||
# id = "main"
|
|
||||||
# provider = "git"
|
|
||||||
# uri = "."
|
|
||||||
# display_name = "Main repository"
|
|
||||||
# default_selector = "HEAD"
|
|
||||||
|
|
||||||
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
|
|
||||||
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
|
|
||||||
@@ -115,14 +115,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
"top workspace page should not own the Worker list",
|
"top workspace page should not own the Worker list",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
workersPage.includes("workerHref") &&
|
||||||
workersPage.includes('<table class="workers-table">') &&
|
workersPage.includes("workers-table") &&
|
||||||
workersPage.includes(
|
workersPage.includes("workerDisplayName") &&
|
||||||
"workerDisplayName = worker.display_name || worker.label",
|
workersPage.includes("worker.resource_key") &&
|
||||||
) &&
|
|
||||||
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
|
|
||||||
workersPage.includes("Delete ${workerDisplayName}"),
|
workersPage.includes("Delete ${workerDisplayName}"),
|
||||||
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
|
"dedicated Workers page should expose a table, canonical Worker link target, and icon actions per Worker",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||||
@@ -218,14 +216,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
"Tickets and Objectives should each be a single sidebar link",
|
"Tickets and Objectives should each be a single sidebar link",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketsLoad.includes("?limit=1000") &&
|
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
|
||||||
|
ticketsLoad.includes('limit: "30"') &&
|
||||||
|
ticketsLoad.includes('states: states.join(",")') &&
|
||||||
|
ticketsLoad.includes("/tickets?${search}") &&
|
||||||
!ticketsLoad.includes("/tickets/query") &&
|
!ticketsLoad.includes("/tickets/query") &&
|
||||||
ticketsPage.includes('class="ticket-kanban"') &&
|
ticketsPage.includes('class="ticket-kanban"') &&
|
||||||
ticketsPage.includes('class="ticket-lane-cards"') &&
|
ticketsPage.includes("laneState") &&
|
||||||
ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
|
ticketsPage.includes("loadMore(lane.id)") &&
|
||||||
ticketsPage.includes("handleLaneScroll") &&
|
ticketsPage.includes("handleLaneScroll(event, lane.id)"),
|
||||||
ticketsPage.includes("revealNextTickets"),
|
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
|
||||||
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||||
@@ -250,16 +250,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
assert(
|
assert(
|
||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
ticketDetailPage.includes("async function queueTicket") &&
|
||||||
|
ticketDetailPage.includes("`${ticketPath}/queue`") &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
ticketDetailPage.includes("currentReview?.kind") &&
|
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||||
ticketDetailPage.includes("mergeEvent?.kind") &&
|
ticketDetailPage.includes("mergeRequestPagePath") &&
|
||||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
|
||||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
ticketDetailPage.includes("mutateAssignment") &&
|
||||||
|
ticketDetailPage.includes("can_start_manual_coder") &&
|
||||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||||
"Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch",
|
"Ticket detail should expose typed lifecycle actions, relations, target selection, assignments, and Merge Request navigation",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,17 +320,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
|
Deno.test("root layout keeps Workspace selection explicit", async () => {
|
||||||
const layoutLoad = await Deno.readTextFile(
|
const layoutLoad = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
layoutLoad.includes("export const load") &&
|
||||||
|
layoutLoad.includes("() => ({})") &&
|
||||||
!layoutLoad.includes("scopedCompatibilityRoute") &&
|
!layoutLoad.includes("scopedCompatibilityRoute") &&
|
||||||
!layoutLoad.includes('pathname === "/runtimes"') &&
|
!layoutLoad.includes("/api/workspace") &&
|
||||||
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
|
!layoutLoad.includes("workspaceRoute") &&
|
||||||
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
|
!layoutLoad.includes("redirect("),
|
||||||
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
|
"root layout should not infer, bootstrap, or redirect through a singleton Workspace",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -811,9 +814,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
rootLayoutLoad.includes('"/account"') &&
|
rootLayoutLoad.includes("export const load") &&
|
||||||
rootLayoutLoad.includes('"/login/device"'),
|
rootLayoutLoad.includes("() => ({})") &&
|
||||||
"Root layout should not redirect account and device-login public routes to a workspace",
|
!rootLayoutLoad.includes("workspaceRoute") &&
|
||||||
|
!rootLayoutLoad.includes("redirect("),
|
||||||
|
"Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export type RuntimeConnectionSummary = {
|
|||||||
built_in: boolean;
|
built_in: boolean;
|
||||||
config_managed: boolean;
|
config_managed: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
can_spawn_worker: boolean;
|
worker_creation_available: boolean;
|
||||||
restart_required: boolean;
|
restart_required: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
|
|||||||
@@ -29,30 +29,15 @@ export type Diagnostic = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeCapabilities = {
|
|
||||||
can_list_hosts: boolean;
|
|
||||||
can_list_workers: boolean;
|
|
||||||
can_get_worker: boolean;
|
|
||||||
can_spawn_worker: boolean;
|
|
||||||
can_stop_worker: boolean;
|
|
||||||
has_workspace_fs: boolean;
|
|
||||||
has_shell: boolean;
|
|
||||||
has_git: boolean;
|
|
||||||
supports_worktrees: boolean;
|
|
||||||
supports_backend_internal_tools: boolean;
|
|
||||||
workspace_scope: string;
|
|
||||||
os: string;
|
|
||||||
arch: string;
|
|
||||||
max_workers: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Runtime = {
|
export type Runtime = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
label: string;
|
label: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
status: string;
|
status: string;
|
||||||
host_ids: string[];
|
host_ids: string[];
|
||||||
capabilities: RuntimeCapabilities;
|
worker_creation_available: boolean;
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +49,8 @@ export type Host = {
|
|||||||
status: string;
|
status: string;
|
||||||
observed_at: string;
|
observed_at: string;
|
||||||
last_seen_at: string | null;
|
last_seen_at: string | null;
|
||||||
capabilities: RuntimeCapabilities;
|
os: string;
|
||||||
|
arch: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,7 +86,7 @@ export type WorkerLaunchRuntimeOption = {
|
|||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
built_in: boolean;
|
built_in: boolean;
|
||||||
can_spawn_worker: boolean;
|
worker_creation_available: boolean;
|
||||||
working_directory_required: boolean;
|
working_directory_required: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const options: WorkerLaunchOptionsResponse = {
|
|||||||
runtime_id: "remote",
|
runtime_id: "remote",
|
||||||
display_name: "Remote",
|
display_name: "Remote",
|
||||||
status: "active",
|
status: "active",
|
||||||
can_spawn_worker: true,
|
worker_creation_available: true,
|
||||||
built_in: false,
|
built_in: false,
|
||||||
working_directory_required: true,
|
working_directory_required: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -32,7 +32,7 @@ const options: WorkerLaunchOptionsResponse = {
|
|||||||
runtime_id: "embedded",
|
runtime_id: "embedded",
|
||||||
display_name: "Embedded",
|
display_name: "Embedded",
|
||||||
status: "active",
|
status: "active",
|
||||||
can_spawn_worker: true,
|
worker_creation_available: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
working_directory_required: false,
|
working_directory_required: false,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export function defaultWorkerLaunchForm(
|
|||||||
): WorkerLaunchFormState {
|
): WorkerLaunchFormState {
|
||||||
const preferredRuntime =
|
const preferredRuntime =
|
||||||
options?.runtimes.find((runtime) =>
|
options?.runtimes.find((runtime) =>
|
||||||
runtime.can_spawn_worker && runtime.status === "active"
|
runtime.worker_creation_available && runtime.status === "active"
|
||||||
) ??
|
) ??
|
||||||
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
|
options?.runtimes.find((runtime) => runtime.worker_creation_available) ??
|
||||||
options?.runtimes[0];
|
options?.runtimes[0];
|
||||||
const preferredProfile = options?.profiles.find((candidate) =>
|
const preferredProfile = options?.profiles.find((candidate) =>
|
||||||
candidate.id === options.default_profile
|
candidate.id === options.default_profile
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Platform</dt>
|
<dt>Platform</dt>
|
||||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
<dd>{host.os} / {host.arch}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
built_in: true,
|
built_in: true,
|
||||||
config_managed: false,
|
config_managed: false,
|
||||||
active: false,
|
active: false,
|
||||||
can_spawn_worker: false,
|
worker_creation_available: false,
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
diagnostics: []
|
diagnostics: []
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
function runtimePlatform(runtime: Runtime): string {
|
function runtimePlatform(runtime: Runtime): string {
|
||||||
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
|
return `${runtime.os} / ${runtime.arch}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
<th>Kind</th>
|
<th>Kind</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Platform</th>
|
<th>Platform</th>
|
||||||
<th>Capacity</th>
|
|
||||||
<th>Workdirs</th>
|
<th>Workdirs</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>{runtime.status}</td>
|
<td>{runtime.status}</td>
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{runtime.capabilities.max_workers} workers</td>
|
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||||
Open workdirs
|
Open workdirs
|
||||||
|
|||||||
@@ -313,7 +313,7 @@
|
|||||||
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||||
{#if options?.runtimes.length}
|
{#if options?.runtimes.length}
|
||||||
{#each options.runtimes as runtime}
|
{#each options.runtimes as runtime}
|
||||||
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
|
<option value={runtime.runtime_id} disabled={!runtime.worker_creation_available}>
|
||||||
{runtime.display_name}
|
{runtime.display_name}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export default defineConfig({
|
|||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
|
host: "localhost",
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
allowedHosts: ["develop.hareworks.net"],
|
allowedHosts: ["develop.hareworks.net"],
|
||||||
watch: {
|
watch: {
|
||||||
ignored: [
|
ignored: [
|
||||||
|
|||||||
Reference in New Issue
Block a user