9 Commits
32 changed files with 700 additions and 1419 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- "8787"
volumes:
- server-data:/server-data
- ./docker/workspace:/workspace:ro
- /etc/yoi/server.toml:/server-config/server.toml:ro
webui:
image: yoi-webui:latest
+3 -3
View File
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
#[derive(Debug, Deserialize)]
struct BackendWorkerLaunchRuntime {
runtime_id: String,
can_spawn_worker: bool,
worker_creation_available: bool,
working_directory_required: bool,
}
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
let runtime = options
.runtimes
.iter()
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
.ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget(
"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![
(
"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",
+1 -1
View File
@@ -1405,7 +1405,7 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions");
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.
let inbound =
+8 -3
View File
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
assert!(
msg.contains("outside allowed read scope")
|| msg.contains("outside allowed write scope")
|| msg.contains("outside allowed scope")
|| msg.contains("has not been read"),
"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!(
msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}"
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
)
.await
.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]
async fn directory_target_is_rejected_for_read() {
let (dir, _spill, reg) = setup();
let (_dir, _spill, reg) = setup();
let read = reg.get("Read");
let err = read
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
+6 -3
View File
@@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test]
async fn glob_finds_written_files() {
let (dir, _spill, reg) = setup();
let (_dir, _spill, reg) = setup();
let write = reg.get("Write");
let glob = reg.get("Glob");
@@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() {
.await;
// Absolute paths are rejected at the logical WorkdirSession boundary.
let msg = format!("{err}");
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
assert!(
msg.contains("invalid logical filesystem path"),
"unexpected: {msg}"
);
}
#[tokio::test]
@@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
));
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();
// Read `a` — should appear in recency.
+8
View File
@@ -983,6 +983,14 @@ where
if feature_config.sub_worker.enabled {
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();
+4 -1
View File
@@ -2494,7 +2494,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.map(ToOwned::to_owned)
})
.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)
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
@@ -587,6 +587,9 @@ model_id = "test-model"
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[compaction]
@@ -749,6 +752,9 @@ model_id = "test-model"
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[[scope.allow]]
+3 -19
View File
@@ -247,24 +247,6 @@ pub struct RuntimeSourceSummary {
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)]
pub struct RuntimeSummary {
pub runtime_id: String,
@@ -274,7 +256,9 @@ pub struct RuntimeSummary {
pub source: RuntimeSourceSummary,
#[serde(default)]
pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary,
pub worker_creation_available: bool,
pub os: String,
pub arch: String,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
+207 -587
View File
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
use std::{fs, io};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig};
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 WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String {
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)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
pub struct ServerHostConfigFile {
#[serde(default)]
pub server: WorkspaceBackendServerConfig,
#[serde(default)]
pub data: WorkspaceBackendDataConfig,
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
pub browser: ServerBrowserConfig,
}
impl Default for ServerHostConfigFile {
fn default() -> Self {
Self {
browser: ServerBrowserConfig::default(),
}
}
}
#[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)]
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
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)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
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)]
pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig,
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
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 {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
}
}
impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
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,
impl ResolvedWorkspaceBackendConfig {
pub fn local_dev(
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> {
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = self
.data
.root
.as_ref()
.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 data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
let database_path = ServerConfig::default_server_database_path();
let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&host_config.browser.public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.frontend_url = self
.server
.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.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS;
server.remote_runtime_sources = runtime_config
.runtimes
.remote
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(),
origin: self.auth.origin.trim().to_string(),
public_base_url: self.auth.public_base_url.trim().to_string(),
cookie_name: self.auth.cookie_name.trim().to_string(),
rp_id: browser_rp_id,
origin: browser_public_url.clone(),
public_base_url: browser_public_url,
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,
listen,
database_path,
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
}
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 {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
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> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -497,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<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(
config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
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
)));
}
@@ -578,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
))
}
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
let value = normalize_required_string("browser.public_url", value)?;
let url = Url::parse(&value).map_err(|error| {
Error::Config(format!(
"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)]
@@ -598,14 +314,33 @@ mod tests {
}
}
#[test]
fn missing_config_path_uses_defaults() {
fn resolved_with_runtimes(
runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
ResolvedWorkspaceBackendConfig::local_dev(
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.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!(resolved.database_path.ends_with("server.db"));
assert!(
@@ -618,12 +353,8 @@ mod tests {
#[test]
fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
.with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/");
@@ -635,171 +366,83 @@ mod tests {
}
#[test]
fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn resolves_relative_paths_against_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
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",
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
let host_config = ServerHostConfigFile::parse_str(
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
"server.toml",
)
.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",
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
)
.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")
);
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 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")
);
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!(
error.to_string().contains("browser.public_url"),
"unexpected error for {value}: {error}"
);
}
}
#[test]
fn copies_local_config_without_overwriting() {
fn server_host_config_loads_only_from_the_explicit_host_path() {
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);
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
"[server]\nlisten = \"127.0.0.1:9999\"\n",
&path,
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
)
.unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs);
assert!(diff.text.contains("+++ workspace local"));
assert!(diff.text.contains("127.0.0.1:9999"));
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert_eq!(path, dir.path().join("server.toml"));
}
#[test]
fn resolves_repository_uri_relative_to_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
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
fn explicit_missing_server_host_config_fails_closed() {
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
assert!(
error
.to_string()
.contains("failed to read Server host config")
);
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
assert!(repository.path.is_none());
}
#[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]
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(
r#"
[[runtimes.remote]]
@@ -856,9 +479,7 @@ display_name = "xdg arc"
"runtimes.toml",
)
.unwrap();
let resolved = workspace_config
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
let resolved = resolved_with_runtimes(&runtime_config);
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
@@ -887,8 +508,6 @@ token = "secret"
#[test]
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(
r#"
[[runtimes.remote]]
@@ -899,9 +518,10 @@ token_ref = "local:remote-token"
"runtimes.toml",
)
.unwrap();
let error = match workspace_config.resolve_with_runtime_config(
dir.path(),
let error = match ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&ServerHostConfigFile::default(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
+48 -134
View File
@@ -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)]
pub struct RuntimeSummary {
pub runtime_id: String,
@@ -205,7 +185,9 @@ pub struct RuntimeSummary {
pub status: String,
pub source: RuntimeSourceSummary,
pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary,
pub worker_creation_available: bool,
pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -218,7 +200,8 @@ pub struct HostSummary {
pub status: String,
pub observed_at: String,
pub last_seen_at: Option<String>,
pub capabilities: HostCapabilitySummary,
pub os: String,
pub arch: String,
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 {
fn from(runtime: RuntimeSummary) -> Self {
Self {
@@ -340,7 +302,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
status: runtime.status,
source: runtime.source.into(),
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(),
}
}
@@ -1890,7 +1854,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "unavailable".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime(),
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,
};
}
@@ -1910,7 +1876,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} else {
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,
}
}
@@ -1928,7 +1896,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "available".to_string(),
observed_at: Utc::now().to_rfc3339(),
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(
"embedded_runtime_host_boundary",
DiagnosticSeverity::Info,
@@ -2576,7 +2545,9 @@ pub struct RemoteRuntimeConfig {
pub base_url: String,
pub bearer_token: Option<String>,
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 timeout: Duration,
}
@@ -2598,7 +2569,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
&self.bearer_token.as_ref().map(|_| "<redacted>"),
)
.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("timeout", &self.timeout)
.finish()
@@ -2619,9 +2595,9 @@ impl RemoteRuntimeConfig {
base_url: base_url.into(),
bearer_token,
auth: None,
cached_capabilities: remote_runtime_capabilities(
200, false, false, "unknown", "unknown",
),
cached_worker_creation_available: false,
cached_os: "unknown".to_string(),
cached_arch: "unknown".to_string(),
cached_status: "configured".to_string(),
timeout: Duration::from_secs(10),
}
@@ -2632,11 +2608,6 @@ impl RemoteRuntimeConfig {
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 {
self.auth = Some(auth);
self
@@ -2708,7 +2679,9 @@ pub struct RemoteWorkerRuntime {
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
cached_capabilities: RuntimeCapabilitySummary,
cached_worker_creation_available: bool,
cached_os: String,
cached_arch: String,
cached_status: String,
host_id: String,
resource_broker: BackendResourceBroker,
@@ -2768,7 +2741,9 @@ impl RemoteWorkerRuntime {
workspace_id,
bearer_token: config.bearer_token,
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,
resource_broker: BackendResourceBroker::default(),
http,
@@ -3045,13 +3020,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: remote_runtime_capabilities(
limit,
true,
response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
),
worker_creation_available: response.runtime.worker_creation_available,
os: response.runtime.os,
arch: response.runtime.arch,
diagnostics: Vec::new(),
},
Err(diagnostic) => RuntimeSummary {
@@ -3065,7 +3036,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else {
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],
},
}
@@ -3084,7 +3057,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(),
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(),
}],
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 {
match status {
RuntimeStatus::Running => "running",
@@ -4122,31 +4073,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
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 {
if err.is_timeout() {
diagnostic(
@@ -4800,22 +4726,9 @@ mod tests {
status: "available".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
host_ids: vec![self.host_id.clone()],
capabilities: RuntimeCapabilitySummary {
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(),
arch: "test".to_string(),
},
worker_creation_available: false,
os: "test".to_string(),
arch: "test".to_string(),
diagnostics: Vec::new(),
}
}
@@ -4830,7 +4743,8 @@ mod tests {
status: "available".to_string(),
observed_at: "unknown".to_string(),
last_seen_at: None,
capabilities: self.runtime_summary(1).capabilities,
os: "test".to_string(),
arch: "test".to_string(),
diagnostics: Vec::new(),
}],
Vec::new(),
@@ -5234,7 +5148,7 @@ mod tests {
RuntimeSourceKind::EmbeddedWorkerRuntime
);
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker);
assert!(embedded_summary.worker_creation_available);
let spawned = registry
.spawn_worker(
+2 -3
View File
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
workspace_root.as_ref().display(),
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display()
)))
}
@@ -219,7 +218,7 @@ mod tests {
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace is not initialized"),
error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
+1 -5
View File
@@ -39,11 +39,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
};
pub use config::{
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{
+42 -235
View File
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
@@ -32,16 +28,7 @@ enum Command {
#[derive(Debug)]
struct ServeOptions {
listen: Option<SocketAddr>,
}
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
config: Option<PathBuf>,
}
#[derive(Debug)]
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
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::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
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())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
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)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
.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 mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root,
identity,
&host_config,
&runtime_config,
)?;
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 {
resolved = resolved.with_listen(listen);
}
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?;
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> {
let mut dry_run = false;
let mut database = None;
@@ -840,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
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> {
let mut listen = None;
let mut config = None;
let mut index = 0;
while index < args.len() {
@@ -906,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
_ if arg.starts_with("--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('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
}
@@ -918,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1;
}
Ok(ServeOptions { listen })
Ok(ServeOptions { listen, config })
}
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() {
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"
);
}
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() {
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"
@@ -966,24 +811,23 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"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)]
mod tests {
use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() {
let temp = tempfile::tempdir().unwrap();
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let options = parse_init_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
fn removed_repository_local_commands_are_rejected() {
for command in ["init", "config"] {
let error = parse_command(&[command.to_string()]).unwrap_err();
assert!(
error.to_string().contains("unknown command"),
"unexpected error for {command}: {error}"
);
}
}
#[test]
@@ -1023,10 +867,18 @@ mod tests {
}
#[test]
fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
fn parse_serve_accepts_listen_and_host_config() {
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();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
assert_eq!(
options.config.unwrap(),
PathBuf::from("/etc/yoi/server.toml")
);
}
#[test]
@@ -1081,49 +933,4 @@ mod tests {
);
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()
);
}
}
+202 -200
View File
@@ -163,7 +163,6 @@ pub struct ServerConfig {
pub workspace_created_at: String,
pub workspace_root: PathBuf,
pub database_path: PathBuf,
pub frontend_url: String,
pub embedded_runtime_store_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig,
@@ -173,9 +172,6 @@ pub struct ServerConfig {
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
pub runtime_config_path: Option<PathBuf>,
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 {
@@ -190,13 +186,12 @@ impl ServerConfig {
workspace_created_at: identity.created_at,
workspace_root,
database_path,
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
auth: AuthConfig::Passkey {
rp_id: "localhost".to_string(),
origin: "http://localhost:8787".to_string(),
public_base_url: "http://localhost:8787".to_string(),
origin: "http://localhost:5173".to_string(),
public_base_url: "http://localhost:5173".to_string(),
cookie_name: "yoi_workspace_session".to_string(),
},
max_records: 200,
@@ -205,7 +200,6 @@ impl ServerConfig {
remote_runtime_sources: Vec::new(),
runtime_config_path: BackendRuntimesConfigFile::default_path(),
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")
}
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 {
self.embedded_runtime_store_root = root.into();
self
@@ -853,9 +842,9 @@ async fn list_server_workspaces(
) -> Response {
let owner = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => Some(actor.account_id),
Ok(None) => match api.catalog.list(None, 1) {
Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(),
Ok(_) => return StatusCode::UNAUTHORIZED.into_response(),
Ok(None) => match api.catalog.is_empty() {
Ok(true) => return Json(Vec::<WorkspaceRecord>::new()).into_response(),
Ok(false) => return StatusCode::UNAUTHORIZED.into_response(),
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,
Json(request): Json<WorkspaceCreateRequest>,
) -> Response {
let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => (Some(actor.account_id), false),
Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true),
let owner_account_id = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => actor.account_id,
Ok(None) => {
return forbidden_server_response("Workspace creation requires an authenticated owner");
}
Err(error) => return server_error_response(error),
};
let created = match if local_bootstrap {
api.catalog.create_first_ownerless(request)
} else {
api.catalog.create(request, owner_account_id)
} {
let created = match api.catalog.create(request, owner_account_id) {
Ok(created) => created,
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 {
pub fn with_config_schema_provider(
mut self,
@@ -1288,6 +1299,7 @@ impl WorkspaceApi {
store: Arc<dyn ControlPlaneStore>,
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self> {
seed_test_registered_workspace(store.as_ref(), &config).await?;
Self::new_with_execution_backend_and_broker(
config,
store,
@@ -1307,16 +1319,12 @@ impl WorkspaceApi {
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
>,
) -> Result<Self> {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
owner_account_id: None,
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?;
if store.get_workspace(&config.workspace_id).await?.is_none() {
return Err(crate::Error::Config(format!(
"Workspace {} is not registered in the Server DB",
config.workspace_id
)));
}
import_configured_repositories(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(
@@ -2764,7 +2772,7 @@ pub struct RuntimeConnectionSummary {
pub built_in: bool,
pub config_managed: bool,
pub active: bool,
pub can_spawn_worker: bool,
pub worker_creation_available: bool,
pub restart_required: bool,
pub status: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
@@ -2824,7 +2832,7 @@ pub struct WorkerLaunchRuntimeOption {
pub runtime_id: String,
pub display_name: String,
pub built_in: bool,
pub can_spawn_worker: bool,
pub worker_creation_available: bool,
pub working_directory_required: bool,
pub status: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
@@ -4517,9 +4525,9 @@ async fn scoped_queue_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(_request): Json<BrowserQueueTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
) -> ApiResult<Json<ticket::TicketQueueOutcome>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let _ = execute_ticket_rest_operation(
let result = execute_ticket_rest_operation(
&api,
&path.workspace_id,
HeaderMap::new(),
@@ -4529,8 +4537,10 @@ async fn scoped_queue_ticket(
},
)
.await?;
let Json(ticket) = browser_ticket_detail(&api, &path.id)?;
Ok(Json(ticket))
ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::QueueOutcome(outcome) => Some(outcome),
_ => None,
})
}
async fn scoped_close_ticket(
@@ -7227,14 +7237,13 @@ fn select_memory_consolidation_runtime(api: &WorkspaceApi) -> ApiResult<String>
.items
.iter()
.find(|runtime| {
runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID
&& runtime.capabilities.can_spawn_worker
runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && runtime.worker_creation_available
})
.or_else(|| {
runtimes
.items
.iter()
.find(|runtime| runtime.capabilities.can_spawn_worker)
.find(|runtime| runtime.worker_creation_available)
})
{
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());
let existing = match api.runtime.working_directory(
&reserved.resolved_runtime_id,
@@ -12719,7 +12705,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
built_in: true,
config_managed: false,
active: runtime.status == "active",
can_spawn_worker: runtime.capabilities.can_spawn_worker,
worker_creation_available: runtime.worker_creation_available,
restart_required: false,
status: runtime.status,
diagnostics: runtime.diagnostics,
@@ -12731,7 +12717,7 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS
built_in: true,
config_managed: false,
active: false,
can_spawn_worker: false,
worker_creation_available: false,
restart_required: false,
status: "unavailable".to_string(),
diagnostics: vec![settings_diagnostic(
@@ -12760,12 +12746,12 @@ fn remote_runtime_connection_summaries(
let live = live_runtimes
.iter()
.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) => (
runtime.label.clone(),
runtime.kind.clone(),
runtime.status == "active",
runtime.capabilities.can_spawn_worker,
runtime.worker_creation_available,
runtime.status.clone(),
runtime.diagnostics.clone(),
),
@@ -12797,7 +12783,7 @@ fn remote_runtime_connection_summaries(
built_in: false,
config_managed: true,
active,
can_spawn_worker,
worker_creation_available,
restart_required,
status,
diagnostics,
@@ -13312,7 +13298,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchO
runtime_id: runtime.runtime_id,
display_name: runtime.label,
built_in,
can_spawn_worker: runtime.capabilities.can_spawn_worker,
worker_creation_available: runtime.worker_creation_available,
working_directory_required: !built_in,
status: runtime.status,
diagnostics: runtime.diagnostics,
@@ -13701,11 +13687,9 @@ fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagn
let mut diagnostics = Vec::new();
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
for runtime in runtimes.items {
if runtime.capabilities.supports_worktrees {
match sync_runtime_workdir_observations(api, runtime.runtime_id.as_str()) {
Ok(mut runtime_diagnostics) => diagnostics.append(&mut runtime_diagnostics),
Err(err) => diagnostics.extend(err.diagnostics),
}
match sync_runtime_workdir_observations(api, runtime.runtime_id.as_str()) {
Ok(mut runtime_diagnostics) => diagnostics.append(&mut runtime_diagnostics),
Err(err) => diagnostics.extend(err.diagnostics),
}
}
diagnostics
@@ -14484,8 +14468,8 @@ mod tests {
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
use crate::hosts::{
RemoteRuntimeAuthConfig, RuntimeCapabilitySummary, TicketWorkerRole, WorkerInputKind,
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
};
use crate::store::{
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
@@ -14547,22 +14531,9 @@ mod tests {
server_id: "server-test".to_owned(),
server_private_key: "unused".to_owned(),
}),
cached_capabilities: RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: true,
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_worker_creation_available: true,
cached_os: "test".to_owned(),
cached_arch: "test".to_owned(),
cached_status: "connected".to_owned(),
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]
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let config = test_server_config(workspace.path());
let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap();
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).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 result = scoped_start_workspace_orchestrator(
@@ -15980,13 +15972,8 @@ mod tests {
#[test]
fn backend_errors_preserve_operation_details() {
let sanitized = sanitize_backend_error(
"failed to open /home/example/.yoi/workspace-backend.local.toml",
);
assert_eq!(
sanitized,
"failed to open /home/example/.yoi/workspace-backend.local.toml"
);
let sanitized = sanitize_backend_error("failed to open server database");
assert_eq!(sanitized, "failed to open server database");
}
#[test]
@@ -16425,6 +16412,16 @@ mod tests {
} = &config.auth;
let expected_origin = expected_origin.clone();
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 repository = temp.path().join("repository");
std::fs::create_dir_all(&repository).unwrap();
@@ -16447,19 +16444,9 @@ mod tests {
default_ref: None,
},
},
None,
"account-auth".to_owned(),
)
.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
.upsert_user(&UserRecord {
user_id: "user-auth".to_owned(),
@@ -16811,6 +16798,7 @@ mod tests {
let mut template = test_server_config(dir.path());
template.static_assets_dir = Some(static_dir);
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 workspace_a = catalog
.create(
@@ -16823,7 +16811,7 @@ mod tests {
default_ref: None,
},
},
None,
"account-two-workspaces".to_owned(),
)
.unwrap();
let workspace_b = catalog
@@ -16837,10 +16825,9 @@ mod tests {
default_ref: None,
},
},
None,
"account-two-workspaces".to_owned(),
)
.unwrap();
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
let app = build_workspace_server_router(template, store)
.await
.unwrap();
@@ -16941,13 +16928,12 @@ mod tests {
}
#[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 repository = dir.path().join("repository");
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 token = seed_test_api_token(store.as_ref(), "bootstrap");
let app = build_workspace_server_router(template, store)
.await
.unwrap();
@@ -16961,8 +16947,7 @@ mod tests {
}
});
let created = app
.clone()
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
@@ -16973,54 +16958,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
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);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
@@ -19254,13 +19192,20 @@ mod tests {
assert_eq!(ready_detail.relations.blockers.len(), 1);
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()),
AxumPath(path()),
Json(BrowserQueueTicketRequest {}),
)
.await
.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.queued_by.as_deref(), Some("workspace-web"));
assert_eq!(queued.relations.blockers.len(), 1);
@@ -19916,22 +19861,9 @@ mod tests {
server_id: "server-main".to_string(),
server_private_key: identity.private_key.clone(),
}),
cached_capabilities: RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: true,
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_worker_creation_available: true,
cached_os: "test".to_string(),
cached_arch: "test".to_string(),
cached_status: "connected".to_string(),
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]
async fn runtime_request_proof_verifies_path_and_query_for_ticket_search() {
let workspace = tempfile::tempdir().unwrap();
@@ -21232,7 +21239,7 @@ mod tests {
}
#[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();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
@@ -21257,7 +21264,7 @@ mod tests {
assert_eq!(response["error"], "Bad Request");
assert_eq!(
response["diagnostics"][0]["code"],
"runtime_workdir_unsupported"
"embedded_worker_workdir_unsupported"
);
set_test_default_runtime(&api, "not-a-registered-runtime");
request_json_authenticated(
@@ -21283,7 +21290,7 @@ mod tests {
assert_eq!(operation.state, "failed");
assert_eq!(
operation.failure.as_deref(),
Some("runtime_workdir_unsupported")
Some("embedded_worker_workdir_unsupported")
);
assert_eq!(operation.config_revision, 2);
assert!(!operation.config_projection_digest.is_empty());
@@ -22331,10 +22338,8 @@ mod tests {
assert_eq!(hosts["items"][0]["runtime_id"], "embedded-worker-runtime");
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]["capabilities"]["workspace_scope"],
"backend_internal"
);
assert_eq!(hosts["items"][0]["os"], std::env::consts::OS);
assert!(hosts["items"][0].get("capabilities").is_none());
assert!(!hosts.to_string().contains("metadata.json"));
let runtimes = get_json(app.clone(), "/api/runtimes").await;
@@ -22807,11 +22812,8 @@ mod tests {
"embedded_worker_runtime"
);
assert_eq!(embedded_summary["source"]["status"], "active");
assert_eq!(
embedded_summary["capabilities"]["workspace_scope"],
"backend_internal"
);
assert_eq!(embedded_summary["capabilities"]["has_workspace_fs"], false);
assert_eq!(embedded_summary["worker_creation_available"], true);
assert!(embedded_summary.get("capabilities").is_none());
let spawned = post_json(
app.clone(),
+38
View File
@@ -1007,6 +1007,11 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<Option<WorkerRegistryRecord>>;
fn has_active_worker_create_reservation(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<bool>;
fn list_worker_registry(
&self,
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(
&self,
workspace_id: &str,
@@ -10585,6 +10612,12 @@ INSERT INTO workdir_registry (
);
assert_eq!(reserved.memory_settings.settings_revision, 1);
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
.update_workspace_memory_settings("workspace-a", 1, " English ")
.unwrap();
@@ -10658,6 +10691,11 @@ INSERT INTO workdir_registry (
store
.complete_worker_create_reservation("workspace-a", reserved.worker_id)
.unwrap();
assert!(
!store
.has_active_worker_create_reservation("workspace-a", &reserved_worker)
.unwrap()
);
let state: String = store
.with_conn(|conn| {
conn.query_row(
+4 -1
View File
@@ -106,7 +106,10 @@ pub async fn verify_runtime_request_source_proof_with_store(
let member = store
.get_worker_registry(workspace_id, &worker)
.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);
}
}
@@ -54,6 +54,10 @@ impl WorkspaceCatalogService {
Self { store }
}
pub fn is_empty(&self) -> Result<bool> {
Ok(self.store.list_workspaces()?.is_empty())
}
pub fn list(
&self,
owner_account_id: Option<&str>,
@@ -76,33 +80,16 @@ impl WorkspaceCatalogService {
pub fn create(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, None, false)
}
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)
self.create_internal(request, owner_account_id, None)
}
fn create_internal(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
requested_workspace_id: Option<String>,
require_empty_catalog: bool,
) -> Result<WorkspaceCreateResponse> {
let operation_key = normalize_required(
"operation_key",
@@ -142,7 +129,7 @@ impl WorkspaceCatalogService {
let fingerprint = workspace_create_fingerprint(
requested_workspace_id.as_deref(),
&display_name,
owner_account_id.as_deref(),
Some(&owner_account_id),
&repository_uri,
&repository_name,
&default_ref,
@@ -153,10 +140,10 @@ impl WorkspaceCatalogService {
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key,
request_fingerprint: fingerprint.clone(),
require_empty_catalog,
require_empty_catalog: false,
workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(),
owner_account_id,
owner_account_id: Some(owner_account_id),
display_name,
state: "active".to_string(),
created_at: now.clone(),
@@ -235,7 +222,7 @@ fn workspace_create_fingerprint(
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SqliteWorkspaceStore;
use crate::store::{AccountRecord, SqliteWorkspaceStore};
use workspace_api::RepositorySourceKind;
fn git_repository() -> tempfile::TempDir {
@@ -244,6 +231,22 @@ mod tests {
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]
async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
@@ -259,8 +262,11 @@ mod tests {
},
};
let created = service.create(request.clone(), None).unwrap();
let replayed = service.create(request, None).unwrap();
let owner_account_id = owner_account(store.as_ref());
let created = service
.create(request.clone(), owner_account_id.clone())
.unwrap();
let replayed = service.create(request, owner_account_id).unwrap();
assert!(!created.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]
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store);
let repository = git_repository();
let mut request = WorkspaceCreateRequest {
@@ -353,10 +305,15 @@ mod tests {
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();
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}");
}
@@ -378,17 +335,21 @@ mod tests {
#[test]
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone());
let result = service
.create_first_ownerless(WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
.create(
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
},
},
})
owner_account_id,
)
.unwrap();
let persisted = store
+3 -1
View File
@@ -264,6 +264,8 @@ in
"serve"
"--listen"
"0.0.0.0:8787"
"--config"
"/server-config/server.toml"
];
Env = [
"PATH=/bin"
@@ -274,7 +276,7 @@ in
};
Volumes = {
"/server-data" = { };
"/workspace" = { };
"/server-config" = { };
};
WorkingDir = "/server-data";
};
+9 -2
View File
@@ -58,12 +58,19 @@ The Compose files live at:
```text
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 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.
## Worker launch path
+1 -5
View File
@@ -153,11 +153,7 @@ For repository builds:
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:
```bash
yoi-server init --workspace <WORKSPACE_ROOT>
```
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.
## Smoke checks
-65
View File
@@ -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",
);
assert(
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes('<table class="workers-table">') &&
workersPage.includes(
"workerDisplayName = worker.display_name || worker.label",
) &&
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
workersPage.includes("workerHref") &&
workersPage.includes("workers-table") &&
workersPage.includes("workerDisplayName") &&
workersPage.includes("worker.resource_key") &&
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(
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",
);
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") &&
ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes('class="ticket-lane-cards"') &&
ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
ticketsPage.includes("handleLaneScroll") &&
ticketsPage.includes("revealNextTickets"),
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
ticketsPage.includes("laneState") &&
ticketsPage.includes("loadMore(lane.id)") &&
ticketsPage.includes("handleLaneScroll(event, lane.id)"),
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
);
assert(
ticketPanelModel.includes('label: "Ready + Planning"') &&
@@ -250,16 +250,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketDetailLoad.includes("/repositories") &&
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("mergeRequest.selector_from") &&
ticketDetailPage.includes("currentReview?.kind") &&
ticketDetailPage.includes("mergeEvent?.kind") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes("mergeRequest.review_status") &&
ticketDetailPage.includes("mergeRequestPagePath") &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_coder") &&
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(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes('pathname === "/runtimes"') &&
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
layoutLoad.includes("export const load") &&
layoutLoad.includes("() => ({})") &&
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes("/api/workspace") &&
!layoutLoad.includes("workspaceRoute") &&
!layoutLoad.includes("redirect("),
"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",
);
assert(
rootLayoutLoad.includes('"/account"') &&
rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace",
rootLayoutLoad.includes("export const load") &&
rootLayoutLoad.includes("() => ({})") &&
!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;
config_managed: boolean;
active: boolean;
can_spawn_worker: boolean;
worker_creation_available: boolean;
restart_required: boolean;
status: string;
diagnostics: Diagnostic[];
@@ -29,30 +29,15 @@ export type Diagnostic = {
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 = {
runtime_id: string;
label: string;
kind: string;
status: string;
host_ids: string[];
capabilities: RuntimeCapabilities;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Diagnostic[];
};
@@ -64,7 +49,8 @@ export type Host = {
status: string;
observed_at: string;
last_seen_at: string | null;
capabilities: RuntimeCapabilities;
os: string;
arch: string;
diagnostics: Diagnostic[];
};
@@ -100,7 +86,7 @@ export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
can_spawn_worker: boolean;
worker_creation_available: boolean;
working_directory_required: boolean;
status: string;
diagnostics: Diagnostic[];
@@ -23,7 +23,7 @@ const options: WorkerLaunchOptionsResponse = {
runtime_id: "remote",
display_name: "Remote",
status: "active",
can_spawn_worker: true,
worker_creation_available: true,
built_in: false,
working_directory_required: true,
diagnostics: [],
@@ -32,7 +32,7 @@ const options: WorkerLaunchOptionsResponse = {
runtime_id: "embedded",
display_name: "Embedded",
status: "active",
can_spawn_worker: true,
worker_creation_available: true,
built_in: true,
working_directory_required: false,
diagnostics: [],
@@ -30,9 +30,9 @@ export function defaultWorkerLaunchForm(
): WorkerLaunchFormState {
const preferredRuntime =
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];
const preferredProfile = options?.profiles.find((candidate) =>
candidate.id === options.default_profile
@@ -88,7 +88,7 @@
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
<dd>{host.os} / {host.arch}</dd>
</div>
</dl>
</article>
@@ -183,7 +183,7 @@
built_in: true,
config_managed: false,
active: false,
can_spawn_worker: false,
worker_creation_available: false,
restart_required: false,
status: 'unknown',
diagnostics: []
@@ -5,7 +5,7 @@
let { data }: PageProps = $props();
function runtimePlatform(runtime: Runtime): string {
return `${runtime.capabilities.os} / ${runtime.capabilities.arch}`;
return `${runtime.os} / ${runtime.arch}`;
}
</script>
@@ -37,7 +37,6 @@
<th>Kind</th>
<th>Status</th>
<th>Platform</th>
<th>Capacity</th>
<th>Workdirs</th>
</tr>
</thead>
@@ -51,7 +50,6 @@
<td>{runtime.kind}</td>
<td>{runtime.status}</td>
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
@@ -313,7 +313,7 @@
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
{#if options?.runtimes.length}
{#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}
</option>
{/each}
+3
View File
@@ -5,6 +5,9 @@ export default defineConfig({
plugins: [sveltekit()],
server: {
host: "localhost",
port: 5173,
strictPort: true,
allowedHosts: ["develop.hareworks.net"],
watch: {
ignored: [