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
Generated
+1
View File
@@ -6176,6 +6176,7 @@ dependencies = [
"tokio",
"toml",
"tui",
"uuid",
"worker",
"workspace-api",
]
+47 -20
View File
@@ -1,9 +1,9 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceSummary,
};
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget {
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
}
}
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
}
pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget,
workspace_id: &str,
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty()
|| workspace_id.len() > 200
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(BackendWorkspaceClientError::InvalidTarget(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
.send()?;
client.check_status(response.status())?;
let response = response.json::<RepositoryListResponse>()?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
));
}
Ok(response.items)
}
pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -149,8 +176,8 @@ mod tests {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
+3 -1
View File
@@ -34,7 +34,9 @@ pub use backend_runtime::{
pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
CreateBackendWorkspaceResponse, create_backend_workspace,
list_backend_workspace_repositories_blocking, list_backend_workspaces,
list_backend_workspaces_blocking,
};
pub use client::{Client, ClientError};
pub use target::{
+8 -1
View File
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1");
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["repository_key"], "main");
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("display_name").is_none());
assert!(json["repository"].get("source").is_none());
}
#[test]
+5 -5
View File
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
println!("Repository path/URI is required.");
return Ok(None);
}
let repository_name = prompt_line("Repository display name [Main]: ")?;
let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!(
"tui-workspace-create-{}-{}",
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name,
repository: CreateBackendWorkspaceRepository {
uri,
display_name: Some(if repository_name.is_empty() {
"Main".to_string()
repository_key: if repository_key.is_empty() {
"main".to_string()
} else {
repository_name
}),
repository_key
},
default_ref: (!default_ref.is_empty()).then_some(default_ref),
},
}))
+49 -1
View File
@@ -520,6 +520,25 @@ pub struct WorkspaceRepositoryRecord {
pub updated_at: String,
}
/// Initial Repository registration intent for Workspace creation.
#[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>,
}
/// Request for atomically creating a Workspace and its initial Repository.
#[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,
}
/// Response returned after atomically creating a Workspace and its first Repository.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -590,7 +609,7 @@ pub struct WorkspaceResponse {
pub extension_points: WorkspaceExtensionPoints,
}
/// Workspace identity metadata exposed by the current settings resource.
/// Workspace display metadata exposed from the Server DB settings authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -3899,6 +3918,35 @@ mod tests {
);
}
#[test]
fn workspace_create_request_has_one_closed_shared_wire_shape() {
let request = WorkspaceCreateRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Workspace".to_string(),
repository: InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: "/srv/repositories/main".to_string(),
default_ref: Some("develop".to_string()),
},
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("source").is_none());
assert!(
serde_json::from_value::<WorkspaceCreateRequest>(serde_json::json!({
"operation_id": "workspace-create-1",
"display_name": "Workspace",
"repository": {
"repository_key": "main",
"source": "/srv/repositories/main"
}
}))
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
+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,
+1
View File
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
serde_yaml = "0.9.34"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
toml = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+23
View File
@@ -1,3 +1,5 @@
use std::path::Path;
use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
use serde::Deserialize;
@@ -103,6 +105,16 @@ pub(crate) trait CliConnectionResolver {
command: CliCommand,
input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError>;
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Err(ParseError(
"repository-based Workspace selection is unavailable".to_string(),
))
}
}
#[derive(Debug, Default, Clone, Copy)]
@@ -177,6 +189,17 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
},
}
}
fn select_workspace_for_repository(
&self,
explicit_backend_url: Option<&str>,
repository_path: &Path,
) -> Result<(String, String), ParseError> {
let base_url = resolve_backend_url(explicit_backend_url.map(str::to_string), None)?;
let workspace_id =
super::select_backend_workspace_for_repository(&base_url, repository_path)?;
Ok((base_url, workspace_id))
}
}
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
+252 -56
View File
@@ -6,6 +6,7 @@ mod plugin_cli;
mod session_cli;
mod ticket_cli;
mod worker_cleanup_cli;
mod workspace_bootstrap;
use std::collections::BTreeMap;
use std::fmt;
@@ -24,6 +25,9 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
use memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize;
use tui::{LaunchMode, LaunchOptions};
use workspace_bootstrap::{
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
};
#[derive(Debug)]
enum Mode {
@@ -48,6 +52,7 @@ enum Mode {
backend_url: String,
no_wait: bool,
},
Init(InitOptions),
WorkerRuntime(Vec<String>),
Keys,
SetupModel,
@@ -107,6 +112,19 @@ async fn main() -> ExitCode {
ExitCode::FAILURE
}
},
Mode::Init(options) => match run_init(options).await {
Ok(workspace) => {
println!(
"Initialized Workspace '{}' for repository '{}'",
workspace.workspace.display_name, workspace.repository.repository_key
);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi init: {error}");
ExitCode::FAILURE
}
},
Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE,
@@ -232,24 +250,37 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
);
}
if selection.backend_url.is_none()
&& let Ok(target) =
resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None)
&& target.kind() == TargetKind::Standalone
{
return Ok(target);
if selection.workspace_id.is_none() {
if selection.backend_url.is_none()
&& let Ok(target) = resolve_connection_aware_cli_connection(
connection_resolver,
command,
false,
None,
None,
)
&& target.kind() == TargetKind::Standalone
{
return Ok(target);
}
let (base_url, workspace_id) = connection_resolver
.select_workspace_for_repository(selection.backend_url.as_deref(), workspace_root)?;
return resolve_connection_aware_cli_connection(
connection_resolver,
command,
false,
Some(base_url),
Some(&workspace_id),
);
}
let workspace_id = match selection.workspace_id.clone() {
Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(workspace_root)?,
};
resolve_connection_aware_cli_connection(
connection_resolver,
command,
selection.explicit_local,
selection.backend_url.clone(),
workspace_id.as_deref(),
selection.workspace_id.as_deref(),
)
}
@@ -439,6 +470,22 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Ticket { cli, target });
}
"init" => {
if target_selection.explicit_local {
return Err(ParseError(
"yoi init requires a Backend target and cannot use --local".to_string(),
));
}
if target_selection.workspace_id.is_some() {
return Err(ParseError(
"yoi init creates a Workspace and does not accept --workspace-id".to_string(),
));
}
return Ok(Mode::Init(parse_init_args(
&args[1..],
target_selection.backend_url,
)?));
}
"plugin" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
let plugin_cli = parse_plugin_args(&args[1..])?;
@@ -556,6 +603,68 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
parse_console_options(args, &target_selection, connection_resolver)
}
fn parse_init_args(
args: &[String],
explicit_backend_url: Option<String>,
) -> Result<InitOptions, ParseError> {
let mut display_name = None;
let mut repository_key = None;
let mut repository_root = current_dir()?;
let mut default_ref = None;
let mut index = 0;
while index < args.len() {
let option = args[index].as_str();
let (slot, label) = match option {
"--display-name" => (&mut display_name, "--display-name"),
"--repository-key" => (&mut repository_key, "--repository-key"),
"--repository" => {
let value = required_option_value(args, index, "--repository")?;
repository_root = PathBuf::from(value);
index += 2;
continue;
}
"--default-ref" => (&mut default_ref, "--default-ref"),
"--help" | "-h" => {
return Err(ParseError(
"usage: yoi [--backend URL] init --display-name NAME --repository-key KEY [--repository PATH] [--default-ref REF]"
.to_string(),
));
}
unknown => {
return Err(ParseError(format!("unknown yoi init option `{unknown}`")));
}
};
if slot.is_some() {
return Err(ParseError(format!("{label} may only be provided once")));
}
*slot = Some(required_option_value(args, index, label)?.to_string());
index += 2;
}
let repository_root = discover_repository_root(&repository_root)?;
Ok(InitOptions {
backend_url: resolve_backend_url(explicit_backend_url, None)?,
display_name: display_name
.ok_or_else(|| ParseError("yoi init requires --display-name NAME".to_string()))?,
repository_key: repository_key
.ok_or_else(|| ParseError("yoi init requires --repository-key KEY".to_string()))?,
repository_root,
default_ref,
})
}
fn required_option_value<'a>(
args: &'a [String],
index: usize,
option: &str,
) -> Result<&'a str, ParseError> {
let value = args
.get(index + 1)
.filter(|value| !value.is_empty() && !value.starts_with('-'))
.ok_or_else(|| ParseError(format!("{option} requires a value")))?;
Ok(value)
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
@@ -1050,13 +1159,7 @@ fn current_dir() -> Result<PathBuf, ParseError> {
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
}
#[derive(Debug, Deserialize)]
struct WorkspaceIdentityFile {
#[serde(alias = "workspace_id")]
id: String,
}
#[derive(Debug, Default)]
#[derive(Debug, Default, PartialEq, Eq)]
struct ClientConfigFile {
default_backend: Option<String>,
default_connection: ClientDefaultConnection,
@@ -1074,7 +1177,7 @@ struct ClientConfigOverlay {
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
}
#[derive(Debug, Default)]
#[derive(Debug, Default, PartialEq, Eq)]
struct ClientBackendConfig {
url: Option<String>,
}
@@ -1084,7 +1187,7 @@ struct ClientBackendConfigOverlay {
url: Option<String>,
}
#[derive(Debug, Default)]
#[derive(Debug, Default, PartialEq, Eq)]
struct ClientWorkspaceConfig {
backend: Option<String>,
}
@@ -1117,34 +1220,6 @@ impl ClientConfigFile {
}
}
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
let mut current = if workspace_root.is_absolute() {
workspace_root.to_path_buf()
} else {
current_dir()?.join(workspace_root)
};
loop {
let path = current.join(".yoi").join("workspace.toml");
if path.is_file() {
let contents = fs::read_to_string(&path)
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
let identity: WorkspaceIdentityFile = toml::from_str(&contents)
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))?;
let id = identity.id.trim();
if id.is_empty() {
return Err(ParseError(format!(
"{} must contain a non-empty workspace id",
path.display()
)));
}
return Ok(Some(id.to_string()));
}
if !current.pop() {
return Ok(None);
}
}
}
fn resolve_backend_url(
explicit_backend_url: Option<String>,
workspace_id: Option<&str>,
@@ -1219,13 +1294,13 @@ fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>
}
fn client_global_config_path() -> Option<PathBuf> {
manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml"))
manifest::paths::config_dir().map(|dir| dir.join("client.toml"))
}
fn client_config_location_message() -> String {
match client_global_config_path() {
Some(path) => path.display().to_string(),
None => "<data_dir>/client/config.toml".to_string(),
None => "<config_dir>/client.toml".to_string(),
}
}
@@ -1649,6 +1724,7 @@ Usage:
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait]
yoi <HOST_COMMAND> [OPTIONS]
@@ -1674,6 +1750,7 @@ Console options:
--worker-id <ID> Backend Worker id; requires --runtime-id
Host commands:
yoi init Register the current Git repository as a new Backend Workspace.
keys Manage local model/API keys
setup-model Configure a local model provider
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
@@ -1708,7 +1785,7 @@ Authority:
Options:
--backend <URL> Use this Workspace Backend
--workspace-id <ID> Scope Backend routes to a Workspace id
--workspace <PATH> Resolve Backend Workspace identity from this repository root
--workspace <PATH> Match this Git repository against Server DB Repository records
-r, --stopped List stopped Backend Workers
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
-h, --help Print help
@@ -1750,6 +1827,7 @@ mod tests {
use super::*;
use crate::cli_connection::CliConnectionInput;
use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
use std::process::Command;
struct FixedCliConnectionResolver {
backend_url: &'static str,
@@ -1798,6 +1876,42 @@ mod tests {
workspace_id.map(str::to_string),
)))
}
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Ok((
self.backend_url.to_string(),
"workspace-from-backend".to_string(),
))
}
}
struct OfflineBackendCliConnectionResolver;
impl CliConnectionResolver for OfflineBackendCliConnectionResolver {
fn resolve_connection(
&self,
_command: CliCommand,
_input: cli_connection::CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> {
Ok(Box::new(BackendTarget::new(
"http://offline.example",
None::<String>,
)))
}
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Err(ParseError(
"failed to query Backend Workspace catalog: Backend is offline".to_string(),
))
}
}
#[test]
@@ -1982,6 +2096,8 @@ backend = "shared"
match parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"--runtime-id",
"runtime-a",
"--worker-id",
@@ -2044,7 +2160,15 @@ backend = "shared"
#[test]
fn parse_backend_runtime_picker_target_mode() {
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap()
match parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"--runtime-id",
"r",
])
.unwrap()
{
Mode::Tui {
target,
@@ -2401,7 +2525,71 @@ backend = "shared"
}
#[test]
fn default_backend_target_inherits_workspace_identity_from_workspace_root() {
fn backend_workspace_selection_reports_offline_without_local_fallback() {
let workspace = tempfile::tempdir().unwrap();
fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
fs::write(
workspace.path().join(".yoi/workspace.toml"),
"workspace_id = \"stale-local-workspace\"\n",
)
.unwrap();
let error = resolve_tui_target(
&OfflineBackendCliConnectionResolver,
CliCommand::Ticket,
&TargetSelection::default(),
workspace.path(),
)
.unwrap_err()
.to_string();
assert!(error.contains("Backend is offline"));
assert!(!error.contains("stale-local-workspace"));
}
#[test]
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
let repository = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repository.path())
.status()
.unwrap();
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
let args = vec![
"--backend".to_string(),
"http://backend.example".to_string(),
"init".to_string(),
"--display-name".to_string(),
"Workspace A".to_string(),
"--repository-key".to_string(),
"main".to_string(),
"--repository".to_string(),
repository.path().display().to_string(),
"--default-ref".to_string(),
"develop".to_string(),
];
let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap();
let Mode::Init(options) = mode else {
panic!("expected init mode");
};
assert_eq!(options.backend_url, "http://backend.example");
assert_eq!(options.display_name, "Workspace A");
assert_eq!(options.repository_key, "main");
assert_eq!(options.default_ref.as_deref(), Some("develop"));
assert_eq!(
options.repository_root,
fs::canonicalize(repository.path()).unwrap()
);
assert!(!repository.path().join(".yoi/workspace.toml").exists());
}
#[test]
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
let workspace = tempfile::tempdir().unwrap();
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
std::fs::write(
@@ -2421,11 +2609,12 @@ backend = "shared"
)
.unwrap();
assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!(
target.resolve().unwrap(),
client::ResolvedTarget::Backend {
base_url: "http://default-backend.example".to_string(),
workspace_id: "workspace-from-root".to_string(),
workspace_id: "workspace-from-backend".to_string(),
}
);
}
@@ -2867,8 +3056,15 @@ backend = "shared"
#[test]
fn parse_panel_rejects_removed_host_local_restore_path() {
let err =
parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err();
let err = parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"panel",
"-r",
])
.unwrap_err();
assert!(err.to_string().contains("removed host-local Worker path"));
}
}
+409
View File
@@ -0,0 +1,409 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use client::{
BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
create_backend_workspace, list_backend_workspace_repositories_blocking,
list_backend_workspaces_blocking,
};
use super::{ParseError, client_global_config_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitOptions {
pub(crate) backend_url: String,
pub(crate) display_name: String,
pub(crate) repository_key: String,
pub(crate) repository_root: PathBuf,
pub(crate) default_ref: Option<String>,
}
pub(crate) async fn run_init(
options: InitOptions,
) -> Result<workspace_api::WorkspaceCreateResponse, ParseError> {
let repository_uri = options
.repository_root
.to_str()
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
let target = BackendWorkspaceCatalogTarget {
base_url: options.backend_url.clone(),
};
let response = create_backend_workspace(
&target,
&CreateBackendWorkspaceRequest {
operation_key: uuid::Uuid::now_v7().to_string(),
display_name: options.display_name,
repository: CreateBackendWorkspaceRepository {
repository_key: options.repository_key,
uri: repository_uri.to_string(),
default_ref: options.default_ref,
},
},
)
.await
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
Ok(response)
}
pub(crate) fn select_backend_workspace_for_repository(
base_url: &str,
repository_path: &Path,
) -> Result<String, ParseError> {
let repository = discover_open_repository(repository_path)?;
let target = BackendWorkspaceCatalogTarget {
base_url: base_url.to_string(),
};
let workspaces = list_backend_workspaces_blocking(&target).map_err(|error| {
ParseError(format!(
"failed to query Backend Workspace catalog: {error}"
))
})?;
let mut catalog = Vec::with_capacity(workspaces.len());
for workspace in workspaces {
let repositories =
list_backend_workspace_repositories_blocking(&target, &workspace.workspace_id)
.map_err(|error| {
ParseError(format!(
"failed to query Repository catalog for Workspace '{}': {error}",
workspace.display_name
))
})?;
catalog.push((workspace, repositories));
}
select_workspace_from_repository_catalog(&repository, &catalog)
}
pub(crate) fn discover_repository_root(path: &Path) -> Result<PathBuf, ParseError> {
Ok(discover_open_repository(path)?.root)
}
fn select_workspace_from_repository_catalog(
repository: &OpenRepositoryIdentity,
catalog: &[(
workspace_api::WorkspaceSummary,
Vec<workspace_api::RepositorySummary>,
)],
) -> Result<String, ParseError> {
let matches = catalog
.iter()
.filter(|(_, repositories)| {
repositories
.iter()
.any(|candidate| repository.matches(&candidate.source))
})
.map(|(workspace, _)| workspace)
.collect::<Vec<_>>();
match matches.as_slice() {
[workspace] => Ok(workspace.workspace_id.clone()),
[] => Err(ParseError(
"the current Git repository is not registered in any accessible Workspace; run `yoi init` or pass `--workspace-id` explicitly"
.to_string(),
)),
matches => {
let names = matches
.iter()
.map(|workspace| workspace.display_name.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(ParseError(format!(
"the current Git repository matches multiple accessible Workspaces ({names}); pass `--workspace-id` explicitly"
)))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OpenRepositoryIdentity {
root: PathBuf,
remote_uris: Vec<String>,
}
impl OpenRepositoryIdentity {
fn matches(&self, source: &workspace_api::RepositorySource) -> bool {
match source.kind {
workspace_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri)
.ok()
.is_some_and(|path| path == self.root),
workspace_api::RepositorySourceKind::File => source
.uri
.strip_prefix("file://")
.and_then(|path| fs::canonicalize(path).ok())
.is_some_and(|path| path == self.root),
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Https => self
.remote_uris
.iter()
.any(|uri| normalize_git_uri(uri) == normalize_git_uri(&source.uri)),
workspace_api::RepositorySourceKind::Invalid => false,
}
}
}
fn discover_open_repository(path: &Path) -> Result<OpenRepositoryIdentity, ParseError> {
let root = git_stdout(path, ["rev-parse", "--show-toplevel"])?;
let root = fs::canonicalize(root.trim()).map_err(|error| {
ParseError(format!(
"failed to resolve current Git repository root: {error}"
))
})?;
let remote_uris = Command::new("git")
.arg("-C")
.arg(&root)
.args(["remote", "get-url", "--all", "origin"])
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Ok(OpenRepositoryIdentity { root, remote_uris })
}
fn git_stdout<const N: usize>(path: &Path, args: [&str; N]) -> Result<String, ParseError> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.map_err(|error| ParseError(format!("failed to execute Git: {error}")))?;
if !output.status.success() {
return Err(ParseError(
"the current directory is not inside a readable Git repository".to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|_| ParseError("Git returned a non-UTF-8 repository path".to_string()))
}
fn normalize_git_uri(uri: &str) -> String {
let uri = uri.trim().trim_end_matches('/');
uri.strip_suffix(".git").unwrap_or(uri).to_string()
}
fn record_workspace_backend_routing(
workspace_id: &str,
backend_url: &str,
) -> Result<(), ParseError> {
let path = client_global_config_path().ok_or_else(|| {
ParseError("unable to resolve the global client configuration directory".to_string())
})?;
record_workspace_backend_routing_at(&path, workspace_id, backend_url)
}
fn record_workspace_backend_routing_at(
path: &Path,
workspace_id: &str,
backend_url: &str,
) -> Result<(), ParseError> {
let mut config = match fs::read_to_string(path) {
Ok(raw) => toml::from_str::<toml::Value>(&raw).map_err(|error| {
ParseError(format!(
"failed to parse global client config {}: {error}",
path.display()
))
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
toml::Value::Table(toml::map::Map::new())
}
Err(error) => {
return Err(ParseError(format!(
"failed to read global client config {}: {error}",
path.display()
)));
}
};
let table = config
.as_table_mut()
.ok_or_else(|| ParseError("global client config must be a TOML table".to_string()))?;
let backends = table
.entry("backends")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| ParseError("global client config `backends` must be a table".to_string()))?;
let existing_name = backends.iter().find_map(|(name, value)| {
value
.get("url")
.and_then(toml::Value::as_str)
.filter(|url| *url == backend_url)
.map(|_| name.clone())
});
let backend_name = existing_name.unwrap_or_else(|| {
let mut candidate = "init".to_string();
let mut suffix = 2_u32;
while backends.contains_key(&candidate) {
candidate = format!("init-{suffix}");
suffix += 1;
}
backends.insert(
candidate.clone(),
toml::Value::Table(toml::map::Map::from_iter([(
"url".to_string(),
toml::Value::String(backend_url.to_string()),
)])),
);
candidate
});
table
.entry("default_connection")
.or_insert_with(|| toml::Value::String("backend".to_string()));
table
.entry("default_backend")
.or_insert_with(|| toml::Value::String(backend_name.clone()));
let workspaces = table
.entry("workspaces")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| {
ParseError("global client config `workspaces` must be a table".to_string())
})?;
workspaces.insert(
workspace_id.to_string(),
toml::Value::Table(toml::map::Map::from_iter([(
"backend".to_string(),
toml::Value::String(backend_name),
)])),
);
let parent = path.parent().ok_or_else(|| {
ParseError("global client config path has no parent directory".to_string())
})?;
fs::create_dir_all(parent).map_err(|error| {
ParseError(format!(
"failed to create global client config directory {}: {error}",
parent.display()
))
})?;
let encoded = toml::to_string_pretty(&config)
.map_err(|error| ParseError(format!("failed to encode global client config: {error}")))?;
let temporary = path.with_extension(format!("toml.tmp-{}", uuid::Uuid::now_v7()));
fs::write(&temporary, encoded).map_err(|error| {
ParseError(format!(
"failed to write global client config {}: {error}",
temporary.display()
))
})?;
fs::rename(&temporary, path).map_err(|error| {
let _ = fs::remove_file(&temporary);
ParseError(format!(
"failed to publish global client config {}: {error}",
path.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ClientDefaultConnection, read_client_config_from_global_path};
fn workspace_summary(id: &str, name: &str) -> workspace_api::WorkspaceSummary {
workspace_api::WorkspaceSummary {
workspace_id: id.to_string(),
owner_account_id: "owner-account".to_string(),
display_name: name.to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn repository_summary(
source: workspace_api::RepositorySource,
) -> workspace_api::RepositorySummary {
workspace_api::RepositorySummary {
repository_key: "main".to_string(),
kind: "git".to_string(),
provider: "builtin:git".to_string(),
source,
source_revision: 1,
source_fingerprint: "fingerprint".to_string(),
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
default_selector: Some("develop".to_string()),
record_authority: "server_db".to_string(),
git: None,
diagnostics: None,
}
}
#[test]
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
let repository = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repository.path())
.status()
.unwrap();
let identity = discover_open_repository(repository.path()).unwrap();
let source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: identity.root.display().to_string(),
};
let matching_repository = repository_summary(source);
let workspace_a = workspace_summary("workspace-a", "Workspace A");
let workspace_b = workspace_summary("workspace-b", "Workspace B");
assert_eq!(
select_workspace_from_repository_catalog(
&identity,
&[(workspace_a.clone(), vec![matching_repository.clone()])],
)
.unwrap(),
"workspace-a"
);
assert!(
select_workspace_from_repository_catalog(&identity, &[])
.unwrap_err()
.to_string()
.contains("not registered in any accessible Workspace")
);
let multiple = select_workspace_from_repository_catalog(
&identity,
&[
(workspace_a, vec![matching_repository.clone()]),
(workspace_b, vec![matching_repository]),
],
)
.unwrap_err()
.to_string();
assert!(multiple.contains("matches multiple accessible Workspaces"));
assert!(multiple.contains("--workspace-id"));
}
#[test]
fn init_routing_config_survives_reload_without_repository_local_state() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("client.toml");
record_workspace_backend_routing_at(&path, "workspace-a", "http://backend.example")
.unwrap();
let first = read_client_config_from_global_path(Some(&path))
.unwrap()
.unwrap();
let second = read_client_config_from_global_path(Some(&path))
.unwrap()
.unwrap();
assert_eq!(first.default_connection, ClientDefaultConnection::Backend);
assert_eq!(first.default_backend.as_deref(), Some("init"));
assert_eq!(
first
.workspaces
.get("workspace-a")
.and_then(|entry| entry.backend.as_deref()),
Some("init")
);
assert_eq!(second, first);
assert!(!temp.path().join(".yoi/workspace.toml").exists());
}
}
+3 -1
View File
@@ -31,7 +31,9 @@ worker
## CLI / TUI routing
- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` と client config の `default_connection = "local"` は Standalone を選ぶ入力であり、旧 LocalBackend を有効化しない。 Client config は repository `.yoi/client.config.toml` を読まず、repository `.yoi/workspace.toml` は Backend Workspace identity が必要な場合だけ参照する
- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` は Standalone を、明示的な CLI selector、または `$XDG_CONFIG_HOME/yoi/client.toml``default_connection = "backend"``default_backend` は Backend を選ぶ。repository-local `.yoi/workspace.toml` / `.yoi/client.config.toml` は connection や Workspace selection の authority ではない
- `yoi init --display-name <NAME> --repository-key <KEY>` は現在の Git repository root を Server DB に登録し、`$XDG_CONFIG_HOME/yoi/client.toml` に Backend routing のみを保存する。repository-local `.yoi/workspace.toml` は作成しない。
- Backend workflow で Workspace selector が省略された場合、client は現在の Git repository の canonical local source または `origin` URI を Server DB の Repository catalog と照合する。exactly one の Workspace だけを選択し、zero/multiple match や Backend offline は明示的に失敗して repository-local file へ fallback しない。
- Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。
- `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。
- `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。
+7 -106
View File
@@ -108,62 +108,9 @@ The first version intentionally does not implement roadmap scheduling, milestone
## Ticket configuration
Workspace Ticket policy is configured by the tracked workspace settings file `.yoi/workspace.toml` under the `[ticket]` table. The old `.yoi/ticket.config.toml` file is obsolete: current code only reads it as a narrow read-only migration fallback when `.yoi/workspace.toml` has no `[ticket]` table. Workspace settings take precedence as soon as `[ticket]` exists.
Workspace Ticket data and workflow authority live in the Workspace Server's SQLite control-plane store. Repository-local `.yoi/workspace.toml` and `.yoi/ticket.config.toml` are not Ticket, Workspace identity, Backend connection, or role-launch authority. `yoi init --display-name <NAME> --repository-key <KEY>` registers the current Git repository through the Backend API and writes only global client routing under `$XDG_CONFIG_HOME/yoi/client.toml`.
MVP shape:
```toml
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "ticket.intake.launch"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "ticket.orchestrator.launch"
[ticket.roles.coder]
profile = "project:coder"
launch_prompt = "ticket.coder.launch"
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "ticket.reviewer.launch"
```
Fixed roles are:
- `intake`
- `orchestrator`
- `coder`
- `reviewer`
This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration.
Stale `[ticket.roles.investigator]` config is rejected as an unsupported fixed role; remove it and,
when a spike is useful, let the Orchestrator create an ordinary task-specific read-only helper Worker.
`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. Workspace Ticket settings do not have a role-level `system_instruction` field.
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
Role launch prompts are plain history input. State and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context.
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exists, defaults are:
- backend provider: `builtin:yoi_local`
- backend root: `<workspace>/.yoi/tickets`
- all role profiles: `inherit`
- no launch prompt refs
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
Fixed Ticket workflow roles are `intake`, `orchestrator`, `coder`, and `reviewer`. The Server resolves the selected Profile and launch material from the active Workspace configuration authority, and Runtime receives the resulting immutable launch snapshot. A repository checkout may still contain ordinary project files, but neither the client nor Runtime may infer Workspace identity, Backend routing, role Profile, or Ticket storage from repository-local `.yoi` files.
## Ticket lifecycle
@@ -267,7 +214,7 @@ Close with a resolution that summarizes what changed, key commits, validation, r
`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
Role actions map to the same fixed roles configured in `.yoi/workspace.toml` under `[ticket.roles]`:
Role actions map to the fixed Workspace Ticket roles:
- intake launches the intake role without an existing Ticket and requires freeform context.
- route launches the orchestrator role for an existing Ticket.
@@ -278,60 +225,14 @@ All actions are explicit and user-triggered. They are not a scheduler, queue, sp
### Dashboard execution path
The role-launch path is:
The Dashboard sends the selected action and Ticket context to the Workspace Server. The Server validates Workspace access, resolves the current Server DB Workspace/Ticket authority and active Profile projection, launches or restores the role Worker through the shared Worker path, commits the typed initial input, and returns durable acceptance evidence. The client does not inspect repository-local `.yoi` files, choose a Ticket storage directory, or construct Runtime launch authority.
```text
User triggers a Ticket action in yoi panel
-> Dashboard builds a TicketRoleLaunchContext
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
-> launcher selects the role Profile
-> launcher spawns the role Worker
-> launcher sends Method::Submit with Text segments
-> launcher waits for run-acceptance evidence
-> Dashboard reports success/failure
```
The launched Worker receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt segments by hand.
The first run input contains:
- the selected fixed role;
- Ticket id when the action targets an existing Ticket;
- freeform user instruction/context from the action;
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
The selected Profile supplies durable system/role behavior. Workspace Ticket settings do not override system instruction.
### Dashboard setup
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
```toml
# .yoi/workspace.toml
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket.roles.intake]
profile = "project:intake"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
[ticket.roles.coder]
profile = "project:coder"
[ticket.roles.reviewer]
profile = "project:reviewer"
```
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
The launched Worker receives dynamic Ticket/action context as its first committed run input. The selected Profile supplies durable system/role behavior. Workspace Ticket metadata does not override system instruction.
### Dashboard troubleshooting
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`.
- malformed workspace Ticket settings: fix the `[ticket]` table in `.yoi/workspace.toml` and retry.
- unresolved Workspace selection: select an accessible Workspace from the Server catalog or pass an explicit Workspace selector.
- unavailable role Profile: update the active Workspace configuration and retry after the Server projects the new revision.
- missing Ticket id for route, implement, or review actions: provide the target Ticket.
- launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.