Compare commits
9
Commits
6f4efb36bb
...
0ab15aa227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab15aa227 | ||
|
|
4e935c6203 | ||
|
|
eac07cf5a8 | ||
|
|
260259d461 | ||
|
|
9d572d18bc | ||
|
|
f7852e8034 | ||
|
|
8c075de147 | ||
|
|
864367f4f5 | ||
|
|
33db2ea7f4 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,6 +5,9 @@ export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts: ["develop.hareworks.net"],
|
||||
watch: {
|
||||
ignored: [
|
||||
|
||||
Reference in New Issue
Block a user