Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy

This commit is contained in:
2026-09-14 20:05:34 +09:00
20 changed files with 1137 additions and 776 deletions
+12 -9
View File
@@ -5,8 +5,8 @@ use std::{fs, io};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::identity::WorkspaceIdentity;
use crate::server::{AuthConfig, ServerConfig};
use crate::store::WorkspaceRecord;
use crate::{Error, Result};
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
@@ -100,15 +100,15 @@ impl ServerHostConfigFile {
impl ResolvedWorkspaceBackendConfig {
pub fn local_dev(
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
workspace: WorkspaceRecord,
host_config: &ServerHostConfigFile,
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.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);
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace);
server.database_path = database_path.clone();
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS;
@@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
mod tests {
use super::*;
fn identity() -> WorkspaceIdentity {
WorkspaceIdentity {
fn workspace() -> WorkspaceRecord {
WorkspaceRecord {
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
owner_account_id: "018f6a2c-1111-7000-8000-000000000002".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
display_name: "Workspace".to_string(),
state: "active".to_string(),
}
}
@@ -197,7 +200,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
ResolvedWorkspaceBackendConfig::local_dev(
dir.path(),
identity(),
workspace(),
&ServerHostConfigFile::default(),
)
.unwrap()
@@ -250,7 +253,7 @@ mod tests {
.unwrap();
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
workspace(),
&host_config,
)
.unwrap();
@@ -280,7 +283,7 @@ mod tests {
};
let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
workspace(),
&host_config,
);
let error = match result {
-355
View File
@@ -1,355 +0,0 @@
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Error, Result};
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
/// Stable local Workspace identity persisted as a tracked, safe project record.
///
/// The v0 TOML schema contains identity metadata plus optional tracked project
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
/// here because this loader cannot safely round-trip future local runtime settings
/// without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity {
pub workspace_id: String,
pub created_at: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ticket: Option<toml::Value>,
}
impl WorkspaceIdentity {
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
})
}
pub fn load_required(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path(workspace_root.as_ref());
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display()
)))
}
Err(error) => Err(Error::Io(error)),
}
}
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
})?;
Self::from_file(parsed, path)
}
fn load_or_init_with_clock(
workspace_root: &Path,
now_utc_rfc3339: impl FnOnce() -> String,
) -> Result<Self> {
let path = Self::path(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Self::init(workspace_root, &path, now_utc_rfc3339())
}
Err(error) => Err(Error::Io(error)),
}
}
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
validate_created_at(&created_at, path)?;
let display_name = workspace_display_name_from_root(workspace_root, path)?;
let workspace_id = Uuid::now_v7().to_string();
let identity = Self {
workspace_id,
created_at,
display_name,
};
identity.write_new_or_read_existing(path)
}
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
validate_created_at(&parsed.created_at, path)?;
validate_display_name(&parsed.display_name, path)?;
Ok(Self {
workspace_id,
created_at: parsed.created_at,
display_name: parsed.display_name,
})
}
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(),
display_name: self.display_name.clone(),
ticket: None,
})
.map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
})?;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(raw.as_bytes())?;
file.sync_all()?;
Ok(self.clone())
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let raw = fs::read_to_string(path)?;
Self::parse_str(&raw, path)
}
Err(error) => Err(Error::Io(error)),
}
}
}
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
let uuid = Uuid::parse_str(value).map_err(|error| {
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
})?;
if uuid.get_version_num() != 7 {
return Err(workspace_identity_error(
path,
"workspace_id must be a UUIDv7 canonical string".to_string(),
));
}
let canonical = uuid.to_string();
if value != canonical {
return Err(workspace_identity_error(
path,
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
));
}
Ok(canonical)
}
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
})?;
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
return Err(workspace_identity_error(
path,
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
));
}
Ok(())
}
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
if value.trim().is_empty() {
return Err(workspace_identity_error(
path,
"display_name must not be empty".to_string(),
));
}
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
return Err(workspace_identity_error(
path,
"display_name must not contain control characters".to_string(),
));
}
Ok(())
}
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
let display_name = workspace_root
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
workspace_identity_error(
path,
"workspace root must have a UTF-8 final path component".to_string(),
)
})?
.to_string();
validate_display_name(&display_name, path)?;
Ok(display_name)
}
fn workspace_identity_error(path: &Path, message: String) -> Error {
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[test]
fn load_required_rejects_uninitialized_workspace_without_creating_identity() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("uninitialized-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
}
#[test]
fn missing_identity_file_is_created_with_safe_fields() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("example-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
assert_eq!(identity.display_name, "example-workspace");
assert_eq!(identity.created_at, FIXED_CREATED_AT);
validate_workspace_id(
&identity.workspace_id,
&WorkspaceIdentity::path(&workspace_root),
)
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("workspace_id"));
assert!(raw.contains("display_name"));
assert!(raw.contains("created_at"));
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(reloaded, identity);
}
#[test]
fn existing_identity_file_is_stable() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("moved-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
);
fs::write(&path, &raw).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(identity.created_at, FIXED_CREATED_AT);
assert_eq!(identity.display_name, "Stable Project");
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn create_new_race_returns_existing_persisted_identity() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".yoi/workspace.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let persisted_raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
);
fs::write(&path, &persisted_raw).unwrap();
let generated = WorkspaceIdentity {
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
created_at: "2026-06-24T00:00:00Z".to_string(),
display_name: "Generated Project".to_string(),
};
let returned = generated.write_new_or_read_existing(&path).unwrap();
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(returned.created_at, FIXED_CREATED_AT);
assert_eq!(returned.display_name, "Persisted Project");
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
}
#[test]
fn invalid_identity_file_fails_closed_without_rewriting() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("invalid-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
fs::write(&path, raw).unwrap();
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap_err();
assert!(error.to_string().contains("workspace_id is not a UUID"));
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn generated_identity_does_not_leak_parent_paths() {
let temp = tempfile::tempdir().unwrap();
let secret_parent = temp.path().join("user-secret-parent");
let workspace_root = secret_parent.join("public-project-name");
fs::create_dir_all(&workspace_root).unwrap();
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("public-project-name"));
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
assert!(!raw.contains("user-secret-parent"));
assert!(!raw.contains("/"));
}
#[test]
fn unknown_fields_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
);
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
}
-4
View File
@@ -10,7 +10,6 @@ pub mod companion;
pub mod config;
pub mod config_source;
pub mod hosts;
pub mod identity;
pub mod memory_backend;
pub mod memory_staging;
pub mod observation;
@@ -43,7 +42,6 @@ pub use authority::{
WorkspaceAuthority,
};
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
pub use server::{
@@ -137,8 +135,6 @@ pub enum Error {
RegistryInconsistency(String),
#[error("Worker source identity is invalid: {0}")]
WorkerSourceIdentity(String),
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("store error: {0}")]
+18 -13
View File
@@ -14,7 +14,7 @@ use yoi_workspace_server::store::{
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
WorkspaceRecord, serve_workspace_catalog,
};
#[derive(Debug)]
@@ -235,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let workspaces = store.list_workspaces()?;
let (identity, workspace_root) = if let Some(workspace) = workspaces.first() {
let (workspace, workspace_execution_root) = if let Some(workspace) = workspaces.first() {
(
WorkspaceIdentity {
workspace_id: workspace.workspace_id.clone(),
created_at: workspace.created_at.clone(),
display_name: workspace.display_name.clone(),
},
workspace_root_from_server_data(workspace)?,
workspace.clone(),
workspace_execution_root_from_server_data(workspace)?,
)
} else {
let now = Utc::now().to_rfc3339();
(
WorkspaceIdentity {
WorkspaceRecord {
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
created_at: Utc::now().to_rfc3339(),
owner_account_id: "00000000-0000-0000-0000-000000000000".to_string(),
created_at: now.clone(),
updated_at: now,
display_name: "Server bootstrap".to_string(),
state: "bootstrap".to_string(),
},
database_path
.parent()
@@ -261,8 +261,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let mut resolved =
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_execution_root,
workspace,
&host_config,
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
@@ -322,7 +325,9 @@ fn append_workspace_runtime_sources(
Ok(())
}
fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBuf, CliError> {
fn workspace_execution_root_from_server_data(
workspace: &WorkspaceRecord,
) -> Result<PathBuf, CliError> {
Ok(ServerConfig::default_workspace_backend_data_root(
&workspace.workspace_id,
))
+47 -141
View File
@@ -1,11 +1,9 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use worker::EffectivePromptCatalog;
use worker_runtime::config_bundle::{
@@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{
};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use workspace_api::{
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
};
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
use crate::store::WorkspaceRecord;
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
@@ -467,103 +465,29 @@ fn build_virtual_profile_archive(
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
}
pub fn workspace_metadata_settings(
workspace_root: &Path,
fallback_workspace_id: &str,
fallback_created_at: &str,
fallback_display_name: &str,
workspace: &WorkspaceRecord,
) -> WorkspaceMetadataSettingsResponse {
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
let mut diagnostics = Vec::new();
let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) {
Ok(raw) => match toml::from_str::<WorkspaceIdentityFile>(&raw) {
Ok(file) => (file.workspace_id, file.created_at, file.display_name),
Err(err) => {
diagnostics.push(diagnostic(
"workspace_identity_parse_failed",
DiagnosticSeverity::Error,
format!("Workspace identity could not be parsed: {err}"),
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
diagnostics.push(diagnostic(
"workspace_identity_missing",
DiagnosticSeverity::Warning,
"Workspace identity record is missing; showing active backend metadata.",
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
Err(err) => {
diagnostics.push(diagnostic(
"workspace_identity_read_failed",
DiagnosticSeverity::Error,
format!(
"Workspace identity could not be read: {}",
sanitize_error(&err.to_string())
),
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
};
WorkspaceMetadataSettingsResponse {
workspace_id,
display_name,
created_at,
revision: file_revision(&path),
source: "workspace_identity".to_string(),
diagnostics,
workspace_id: workspace.workspace_id.clone(),
display_name: workspace.display_name.clone(),
created_at: workspace.created_at.clone(),
revision: workspace.updated_at.clone(),
source: "server_db".to_string(),
diagnostics: Vec::new(),
}
}
pub fn update_workspace_metadata(
workspace_root: &Path,
request: UpdateWorkspaceMetadataRequest,
) -> Result<WorkspaceMetadataSettingsResponse> {
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
let current_revision = file_revision(&path);
if request.revision != current_revision {
pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "workspace_metadata_revision_conflict".to_string(),
message: "Workspace metadata changed before this update was applied".to_string(),
code: "workspace_display_name_invalid".to_string(),
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
});
}
let raw = fs::read_to_string(&path)?;
let mut file: WorkspaceIdentityFile = toml::from_str(&raw)
.map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?;
let display_name = sanitize_display_name(&request.display_name)?;
file.display_name = display_name;
let encoded = toml::to_string_pretty(&file)
.map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?;
fs::write(&path, encoded)?;
Ok(workspace_metadata_settings(
workspace_root,
&file.workspace_id,
&file.created_at,
&file.display_name,
))
Ok(trimmed.to_string())
}
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
@@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
specifiers
}
fn sanitize_display_name(value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "workspace_display_name_invalid".to_string(),
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
});
}
Ok(trimmed.to_string())
}
pub fn selector_for_builtin_candidate(
id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> {
@@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate(
_ => None,
}
}
fn file_revision(path: &Path) -> String {
let Ok(metadata) = fs::metadata(path) else {
return "missing".to_string();
};
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default();
format!("rev:{modified}:{}", metadata.len())
}
fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> Diagnostic {
Diagnostic {
code: code.into(),
severity,
message: message.into(),
}
}
fn sanitize_error(value: &str) -> String {
value
.split_whitespace()
.map(|token| {
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
{
"<redacted-path>"
} else {
token
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
let workspace = WorkspaceRecord {
workspace_id: "workspace-a".to_string(),
owner_account_id: "owner-account".to_string(),
display_name: "Workspace A".to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-02T00:00:00Z".to_string(),
};
let settings = workspace_metadata_settings(&workspace);
assert_eq!(settings.workspace_id, workspace.workspace_id);
assert_eq!(settings.display_name, workspace.display_name);
assert_eq!(settings.created_at, workspace.created_at);
assert_eq!(settings.revision, workspace.updated_at);
assert_eq!(settings.source, "server_db");
assert!(settings.diagnostics.is_empty());
}
#[test]
fn workspace_display_name_validation_is_bounded() {
assert_eq!(
sanitize_workspace_display_name(" Workspace A ").unwrap(),
"Workspace A"
);
assert!(sanitize_workspace_display_name("\n").is_err());
assert!(sanitize_workspace_display_name(&"a".repeat(121)).is_err());
}
fn valid_decodal(slug: &str) -> String {
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
}
+131 -45
View File
@@ -142,7 +142,6 @@ use crate::hosts::{
WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
};
use crate::identity::WorkspaceIdentity;
use crate::memory_backend::execute_memory_backend_operation_with_authority;
use crate::memory_staging::{
list_memory_staging_from_authority, memory_staging_backlog_from_authority,
@@ -211,7 +210,7 @@ pub struct ServerConfig {
pub workspace_id: String,
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf,
pub workspace_execution_root: PathBuf,
pub database_path: PathBuf,
pub embedded_runtime_store_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
@@ -224,16 +223,18 @@ pub struct ServerConfig {
}
impl ServerConfig {
pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
let workspace_root = workspace_root.into();
let workspace_id = identity.workspace_id;
pub fn local_dev(
workspace_execution_root: impl Into<PathBuf>,
workspace: WorkspaceRecord,
) -> Self {
let workspace_id = workspace.workspace_id.clone();
let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id);
let database_path = Self::default_server_database_path();
Self {
workspace_id,
workspace_display_name: identity.display_name,
workspace_created_at: identity.created_at,
workspace_root,
workspace_id: workspace.workspace_id,
workspace_display_name: workspace.display_name,
workspace_created_at: workspace.created_at,
workspace_execution_root: workspace_execution_root.into(),
database_path,
embedded_runtime_store_root,
static_assets_dir: None,
@@ -320,9 +321,8 @@ impl ServerConfig {
workspace.workspace_id
)));
}
let workspace_data_root =
let workspace_execution_root =
Self::default_workspace_backend_data_root(&workspace.workspace_id);
let workspace_root = workspace_data_root.clone();
let repositories = repositories
.into_iter()
.map(|repository| ConfiguredRepository {
@@ -346,7 +346,7 @@ impl ServerConfig {
scoped
.workspace_created_at
.clone_from(&workspace.created_at);
scoped.workspace_root = workspace_root;
scoped.workspace_execution_root = workspace_execution_root;
scoped.embedded_runtime_store_root =
Self::default_embedded_runtime_store_root(&workspace.workspace_id);
scoped.repositories = repositories;
@@ -2138,7 +2138,7 @@ impl WorkspaceApi {
),
);
let execution_backend = WorkerRuntimeExecutionBackend::new(
ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
ProfileRuntimeWorkerFactory::new(config.workspace_execution_root.clone())
.with_embedded_worker_mutation_dispatcher(
EMBEDDED_RUNTIME_ID,
worker_remove_dispatcher.clone(),
@@ -2985,14 +2985,11 @@ fn load_configured_repositories_from_store(
store
.list_repositories(&config.workspace_id)?
.into_iter()
.map(|record| configured_repository_from_record(&config.workspace_root, record))
.map(configured_repository_from_record)
.collect()
}
fn configured_repository_from_record(
_workspace_root: &Path,
record: RepositoryRecord,
) -> Result<ConfiguredRepository> {
fn configured_repository_from_record(record: RepositoryRecord) -> Result<ConfiguredRepository> {
let provider = record.provider.unwrap_or_else(|| record.kind.clone());
let path = repository_local_path(&record.source);
Ok(ConfiguredRepository {
@@ -3983,7 +3980,7 @@ struct TranscriptQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
struct ScopedWorkspacePath {
workspace_id: String,
}
@@ -4212,11 +4209,13 @@ async fn scoped_get_workspace_settings(
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<WorkspaceMetadataSettingsResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let workspace = api
.store
.get_workspace(&path.workspace_id)
.await?
.ok_or_else(|| Error::InvalidRecordId(path.workspace_id))?;
Ok(Json(crate::profile_settings::workspace_metadata_settings(
&api.config.workspace_root,
&api.config.workspace_id,
&api.config.workspace_created_at,
&api.config.workspace_display_name,
&workspace,
)))
}
@@ -4226,8 +4225,31 @@ async fn scoped_update_workspace_settings(
Json(request): Json<UpdateWorkspaceMetadataRequest>,
) -> ApiResult<Json<WorkspaceMetadataMutationResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let workspace =
crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
let display_name =
crate::profile_settings::sanitize_workspace_display_name(&request.display_name)?;
let current = api
.store
.get_workspace(&path.workspace_id)
.await?
.ok_or_else(|| Error::InvalidRecordId(path.workspace_id.clone()))?;
if request.revision != current.updated_at {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "workspace_metadata_revision_conflict".to_string(),
message: "Workspace metadata changed before this update was applied".to_string(),
}
.into());
}
let workspace = api
.store
.update_workspace_display_name(&path.workspace_id, &current.updated_at, &display_name)
.await?
.ok_or_else(|| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "workspace_metadata_revision_conflict".to_string(),
message: "Workspace metadata changed before this update was applied".to_string(),
})?;
let workspace = crate::profile_settings::workspace_metadata_settings(&workspace);
Ok(Json(WorkspaceMetadataMutationResponse {
workspace,
diagnostics: vec![workspace_api::Diagnostic {
@@ -13541,22 +13563,20 @@ async fn get_workspace(
let cookie_name = auth_public_config(&api.config).cookie_name;
let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?;
let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?;
let is_owner = actor.as_ref().is_some_and(|actor| {
stored
.as_ref()
.is_some_and(|workspace| workspace.owner_account_id == actor.account_id)
});
let display_name = stored
let stored = api
.store
.get_workspace(api.workspace_id())
.await?
.ok_or_else(|| Error::InvalidRecordId(api.workspace_id().to_string()))?;
let is_owner = actor
.as_ref()
.map(|record| record.display_name.clone())
.unwrap_or_else(|| api.config.workspace_display_name.clone());
.is_some_and(|actor| stored.owner_account_id == actor.account_id);
let companion_status = api.companion.status();
let companion_console = companion_console_extension_point(&companion_status);
Ok(Json(WorkspaceResponse {
workspace_id: api.config.workspace_id.clone(),
display_name,
record_authority: "local_yoi_project_records".to_string(),
workspace_id: stored.workspace_id,
display_name: stored.display_name,
record_authority: "server_db".to_string(),
schema_version,
auth: api.config.auth.clone(),
permissions: WorkspacePermissionSummary {
@@ -21955,11 +21975,14 @@ mod tests {
}
}
fn test_identity() -> WorkspaceIdentity {
WorkspaceIdentity {
fn test_workspace() -> WorkspaceRecord {
WorkspaceRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
owner_account_id: "owner-account".to_string(),
display_name: "Test Workspace".to_string(),
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
state: "active".to_string(),
}
}
@@ -22003,7 +22026,7 @@ mod tests {
workspace_api::RepositorySourceKind::Https
);
assert_eq!(
scoped.workspace_root,
scoped.workspace_execution_root,
ServerConfig::default_workspace_backend_data_root("remote-workspace")
);
}
@@ -22058,7 +22081,7 @@ mod tests {
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
let workspace_root = workspace_root.into();
let store_root = workspace_root.join(".test-embedded-runtime-store");
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_workspace())
.with_embedded_runtime_store_root(store_root);
config.database_path = workspace_root.join(".test-yoi-server.db");
config.backend_base_url = Some("http://127.0.0.1:8787".to_string());
@@ -25949,6 +25972,69 @@ mod tests {
assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn workspace_metadata_settings_ignore_repository_identity_file_and_update_server_db() {
let temp = tempfile::tempdir().unwrap();
let local_identity_path = temp.path().join(".yoi/workspace.toml");
fs::create_dir_all(local_identity_path.parent().unwrap()).unwrap();
fs::write(&local_identity_path, "not valid toml = [").unwrap();
let api = test_api(temp.path()).await;
let path = ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
};
let current = scoped_get_workspace_settings(State(api.clone()), AxumPath(path.clone()))
.await
.unwrap()
.0;
assert_eq!(current.workspace_id, TEST_WORKSPACE_ID);
assert_eq!(current.display_name, "Test Workspace");
assert_eq!(current.source, "server_db");
assert!(current.diagnostics.is_empty());
let updated = scoped_update_workspace_settings(
State(api.clone()),
AxumPath(path),
Json(UpdateWorkspaceMetadataRequest {
display_name: " Renamed Workspace ".to_string(),
revision: current.revision.clone(),
}),
)
.await
.unwrap()
.0
.workspace;
assert_eq!(updated.display_name, "Renamed Workspace");
assert_ne!(updated.revision, current.revision);
assert_eq!(
fs::read_to_string(&local_identity_path).unwrap(),
"not valid toml = ["
);
assert_eq!(
api.store
.get_workspace(TEST_WORKSPACE_ID)
.await
.unwrap()
.unwrap()
.display_name,
"Renamed Workspace"
);
let stale = scoped_update_workspace_settings(
State(api),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(UpdateWorkspaceMetadataRequest {
display_name: "Stale Workspace".to_string(),
revision: current.revision,
}),
)
.await
.unwrap_err();
assert_eq!(stale.into_response().status(), StatusCode::CONFLICT);
}
async fn test_api_with_recording_backend(
workspace_root: impl Into<PathBuf>,
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
@@ -26918,7 +27004,7 @@ mod tests {
provider: Some("git".to_string()),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: api.config.workspace_root.display().to_string(),
uri: api.config.workspace_execution_root.display().to_string(),
},
default_ref: Some("HEAD".to_string()),
source_revision: 1,
@@ -30168,7 +30254,7 @@ mod tests {
let typed_workspace: workspace_api::WorkspaceResponse =
serde_json::from_value(workspace.clone()).unwrap();
assert!(!typed_workspace.permissions.manage_repositories);
assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!(workspace["record_authority"], "server_db");
assert_eq!(
workspace["extension_points"]["host_worker_bridge"]["status"],
"runtime_registry"
@@ -30784,7 +30870,7 @@ mod tests {
);
assert!(!default_root.starts_with(workspace_root.join(".yoi")));
let mut config = ServerConfig::local_dev(workspace_root, test_identity())
let mut config = ServerConfig::local_dev(workspace_root, test_workspace())
.with_embedded_runtime_store_root(default_root.clone());
config.database_path = ServerConfig::server_database_path_for_data_dir(&data_dir);
let store = test_control_store(&config);
@@ -31506,7 +31592,7 @@ mod tests {
async fn scoped_flow_source_route_persists_compiled_dcdl() {
let temp = tempfile::tempdir().unwrap();
let app = test_app(temp.path()).await;
let workspace_id = test_identity().workspace_id;
let workspace_id = test_workspace().workspace_id;
let source = r#"{
schema_version = 1;
name = "route-flow";
+120
View File
@@ -840,6 +840,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
) -> Result<Option<String>>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
async fn update_workspace_display_name(
&self,
workspace_id: &str,
expected_updated_at: &str,
display_name: &str,
) -> Result<Option<WorkspaceRecord>>;
fn create_workspace_bootstrap(
&self,
record: &WorkspaceBootstrapRecord,
@@ -3305,6 +3311,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
async fn update_workspace_display_name(
&self,
workspace_id: &str,
expected_updated_at: &str,
display_name: &str,
) -> Result<Option<WorkspaceRecord>> {
validate_identifier("workspace_id", workspace_id)?;
validate_non_empty("expected_updated_at", expected_updated_at)?;
validate_non_empty("display_name", display_name)?;
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current = tx
.query_row(
r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at
FROM workspaces WHERE workspace_id = ?1"#,
params![workspace_id],
read_workspace_record,
)
.optional()?;
let Some(current) = current else {
tx.commit()?;
return Ok(None);
};
if current.updated_at != expected_updated_at {
tx.commit()?;
return Ok(None);
}
if current.display_name == display_name {
tx.commit()?;
return Ok(Some(current));
}
let now = chrono::Utc::now();
let updated_at = chrono::DateTime::parse_from_rfc3339(&current.updated_at)
.ok()
.map(|previous| previous.with_timezone(&chrono::Utc))
.filter(|previous| *previous >= now)
.map(|previous| previous + chrono::Duration::nanoseconds(1))
.unwrap_or(now)
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let changed = tx.execute(
r#"UPDATE workspaces
SET display_name = ?3, updated_at = ?4
WHERE workspace_id = ?1 AND updated_at = ?2"#,
params![workspace_id, expected_updated_at, display_name, updated_at],
)?;
if changed != 1 {
tx.commit()?;
return Ok(None);
}
let updated = WorkspaceRecord {
display_name: display_name.to_string(),
updated_at,
..current
};
tx.commit()?;
Ok(Some(updated))
})
}
fn create_workspace_bootstrap(
&self,
record: &WorkspaceBootstrapRecord,
@@ -12336,6 +12402,60 @@ mod tests {
);
}
#[tokio::test]
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
let dir = tempfile::tempdir().unwrap();
let database_path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let record = WorkspaceRecord {
workspace_id: "workspace-a".to_string(),
owner_account_id: "owner-account".to_string(),
display_name: "Before".to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
};
store.upsert_workspace(&record).await.unwrap();
let updated = store
.update_workspace_display_name(&record.workspace_id, &record.updated_at, "After")
.await
.unwrap()
.unwrap();
assert_eq!(updated.workspace_id, record.workspace_id);
assert_eq!(updated.owner_account_id, record.owner_account_id);
assert_eq!(updated.created_at, record.created_at);
assert_eq!(updated.state, record.state);
assert_eq!(updated.display_name, "After");
assert_ne!(updated.updated_at, record.updated_at);
assert!(
store
.update_workspace_display_name(&record.workspace_id, &record.updated_at, "Stale",)
.await
.unwrap()
.is_none()
);
assert_eq!(
store
.get_workspace(&record.workspace_id)
.await
.unwrap()
.unwrap(),
updated
);
drop(store);
let reopened = SqliteWorkspaceStore::open(database_path).unwrap();
assert_eq!(
reopened
.get_workspace(&record.workspace_id)
.await
.unwrap()
.unwrap(),
updated
);
}
#[tokio::test]
async fn objective_creation_allocates_and_resolves_workspace_resource_key() {
let dir = tempfile::tempdir().unwrap();
@@ -1,10 +1,10 @@
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub use workspace_api::{InitialRepositoryIntent, WorkspaceCreateRequest};
use workspace_api::{RepositoryObservedStatus, RepositorySource};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
@@ -19,23 +19,6 @@ use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200;
const MAX_OPERATION_KEY_BYTES: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InitialRepositoryIntent {
pub repository_key: String,
pub uri: String,
#[serde(default)]
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceCreateRequest {
pub operation_key: String,
pub display_name: String,
pub repository: InitialRepositoryIntent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord,