merge: workspace identity persistence

This commit is contained in:
Keisuke Hirata 2026-06-23 16:35:17 +09:00
commit 2745f3d516
No known key found for this signature in database
9 changed files with 438 additions and 45 deletions

3
.yoi/workspace.toml Normal file
View File

@ -0,0 +1,3 @@
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z"
display_name = "yoi"

3
Cargo.lock generated
View File

@ -6050,6 +6050,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono",
"manifest", "manifest",
"pod-store", "pod-store",
"project-record", "project-record",
@ -6061,8 +6062,10 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"toml",
"tower", "tower",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]

View File

@ -8,6 +8,7 @@ publish = false
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum.workspace = true
chrono = { version = "0.4", default-features = false, features = ["clock"] }
manifest = { workspace = true } manifest = { workspace = true }
pod-store = { workspace = true } pod-store = { workspace = true }
project-record.workspace = true project-record.workspace = true
@ -18,7 +19,9 @@ serde_yaml.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
toml.workspace = true
tracing.workspace = true tracing.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View File

@ -0,0 +1,323 @@
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 intentionally contains identity metadata only:
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected
/// instead of preserved 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,
}
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 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(),
})
.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 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"));
}
}

View File

@ -5,11 +5,13 @@
//! remain the canonical project records and are read through bounded bridge APIs. //! remain the canonical project records and are read through bounded bridge APIs.
pub mod hosts; pub mod hosts;
pub mod identity;
pub mod records; pub mod records;
pub mod repositories; pub mod repositories;
pub mod server; pub mod server;
pub mod store; pub mod store;
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
}; };
@ -40,6 +42,8 @@ pub enum Error {
UnknownHost(String), UnknownHost(String),
#[error("unknown local repository `{0}`")] #[error("unknown local repository `{0}`")]
UnknownRepository(String), UnknownRepository(String),
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("store error: {0}")] #[error("store error: {0}")]
Store(String), Store(String),
} }

View File

@ -4,7 +4,7 @@ use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve}; use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
#[derive(Debug)] #[derive(Debug)]
struct ServeOptions { struct ServeOptions {
@ -64,6 +64,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
} }
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> { async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
let db = options let db = options
.db .db
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db")); .unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
@ -72,7 +73,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
} }
let store = Arc::new(SqliteWorkspaceStore::open(&db)?); let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
let mut config = ServerConfig::local_dev(&options.workspace); let mut config = ServerConfig::local_dev(&options.workspace, identity);
config.static_assets_dir = options.frontend; config.static_assets_dir = options.frontend;
let listener = TcpListener::bind(options.listen).await?; let listener = TcpListener::bind(options.listen).await?;
eprintln!( eprintln!(

View File

@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
use crate::hosts::RuntimeDiagnostic; use crate::hosts::RuntimeDiagnostic;
const LOCAL_REPOSITORY_ID: &str = "local"; const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
const MAX_COMMAND_OUTPUT: usize = 4096; const MAX_COMMAND_OUTPUT: usize = 4096;
const DEFAULT_LOG_LIMIT: usize = 10; const DEFAULT_LOG_LIMIT: usize = 10;
const MAX_LOG_LIMIT: usize = 50; const MAX_LOG_LIMIT: usize = 50;
@ -14,12 +15,14 @@ const MAX_FIELD_LEN: usize = 240;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalRepositoryReader { pub struct LocalRepositoryReader {
workspace_root: PathBuf, workspace_root: PathBuf,
workspace_id: String,
} }
impl LocalRepositoryReader { impl LocalRepositoryReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self { pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self { Self {
workspace_root: workspace_root.into(), workspace_root: workspace_root.into(),
workspace_id: workspace_id.into(),
} }
} }
@ -30,7 +33,7 @@ impl LocalRepositoryReader {
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary { pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
let git = inspect_git(&self.workspace_root); let git = inspect_git(&self.workspace_root);
RepositorySummary { RepositorySummary {
id: LOCAL_REPOSITORY_ID.to_string(), id: Self::repository_id_for_workspace(&self.workspace_id),
display_name: workspace_display_name.to_string(), display_name: workspace_display_name.to_string(),
kind: "local".to_string(), kind: "local".to_string(),
workspace_root: self.workspace_root.clone(), workspace_root: self.workspace_root.clone(),
@ -46,8 +49,42 @@ impl LocalRepositoryReader {
git_log(&self.workspace_root, limit) git_log(&self.workspace_root, limit)
} }
pub fn is_local_repository_id(id: &str) -> bool { pub fn repository_id_for_workspace(workspace_id: &str) -> String {
id == LOCAL_REPOSITORY_ID format!(
"{LOCAL_REPOSITORY_PREFIX}{}",
sanitize_identifier_fragment(workspace_id)
)
}
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
}
}
fn sanitize_identifier_fragment(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut previous_dash = false;
for ch in value.chars() {
let mapped = if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
};
if mapped == '-' {
if !previous_dash {
output.push(mapped);
}
previous_dash = true;
} else {
output.push(mapped);
previous_dash = false;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
"workspace".to_string()
} else {
output
} }
} }

View File

@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::identity::WorkspaceIdentity;
use crate::records::{ use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
}; };
@ -28,6 +29,8 @@ pub enum AuthConfig {
#[derive(Clone)] #[derive(Clone)]
pub struct ServerConfig { pub struct ServerConfig {
pub workspace_id: String, pub workspace_id: String,
pub workspace_display_name: String,
pub workspace_created_at: String,
pub workspace_root: PathBuf, pub workspace_root: PathBuf,
pub static_assets_dir: Option<PathBuf>, pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig, pub auth: AuthConfig,
@ -36,11 +39,12 @@ pub struct ServerConfig {
} }
impl ServerConfig { impl ServerConfig {
pub fn local_dev(workspace_root: impl Into<PathBuf>) -> Self { pub fn local_dev(workspace_root: impl Into<PathBuf>, identity: WorkspaceIdentity) -> Self {
let workspace_root = workspace_root.into(); let workspace_root = workspace_root.into();
let display = workspace_display_name_from_root(&workspace_root);
Self { Self {
workspace_id: format!("local:{display}"), workspace_id: identity.workspace_id,
workspace_display_name: identity.display_name,
workspace_created_at: identity.created_at,
workspace_root, workspace_root,
static_assets_dir: None, static_assets_dir: None,
auth: AuthConfig::LocalDevToken { auth: AuthConfig::LocalDevToken {
@ -61,14 +65,13 @@ pub struct WorkspaceApi {
impl WorkspaceApi { impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> { pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let display_name = workspace_display_name_from_root(&config.workspace_root);
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(), workspace_id: config.workspace_id.clone(),
display_name, display_name: config.workspace_display_name.clone(),
state: "active".to_string(), state: "active".to_string(),
created_at: "1970-01-01T00:00:00Z".to_string(), created_at: config.workspace_created_at.clone(),
updated_at: "1970-01-01T00:00:00Z".to_string(), updated_at: config.workspace_created_at.clone(),
}) })
.await?; .await?;
Ok(Self { Ok(Self {
@ -91,20 +94,19 @@ impl WorkspaceApi {
} }
fn local_repository_reader(&self) -> LocalRepositoryReader { fn local_repository_reader(&self) -> LocalRepositoryReader {
LocalRepositoryReader::new(self.config.workspace_root.clone()) LocalRepositoryReader::new(
self.config.workspace_root.clone(),
self.config.workspace_id.clone(),
)
} }
fn workspace_display_name(&self) -> String { fn local_repository_id(&self) -> String {
workspace_display_name_from_root(&self.config.workspace_root) LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
} }
}
fn workspace_display_name_from_root(workspace_root: &std::path::Path) -> String { fn workspace_display_name(&self) -> &str {
workspace_root self.config.workspace_display_name.as_str()
.file_name() }
.and_then(|name| name.to_str())
.expect("workspace root must have a final path component")
.to_string()
} }
pub fn build_router(api: WorkspaceApi) -> Router { pub fn build_router(api: WorkspaceApi) -> Router {
@ -238,7 +240,7 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
let display_name = stored let display_name = stored
.as_ref() .as_ref()
.map(|record| record.display_name.clone()) .map(|record| record.display_name.clone())
.unwrap_or_else(|| workspace_display_name_from_root(&api.config.workspace_root)); .unwrap_or_else(|| api.config.workspace_display_name.clone());
Ok(Json(WorkspaceResponse { Ok(Json(WorkspaceResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
display_name, display_name,
@ -314,7 +316,7 @@ async fn list_repositories(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> { ) -> ApiResult<Json<RepositoryListResponse>> {
let reader = api.local_repository_reader(); let reader = api.local_repository_reader();
let items = reader.list(&api.workspace_display_name()); let items = reader.list(api.workspace_display_name());
Ok(Json(RepositoryListResponse { Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
items, items,
@ -327,11 +329,11 @@ async fn repository_detail(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> { ) -> ApiResult<Json<RepositoryDetailResponse>> {
ensure_local_repository(&repository_id)?; let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let reader = api.local_repository_reader(); let reader = api.local_repository_reader();
Ok(Json(RepositoryDetailResponse { Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item: reader.summary(&api.workspace_display_name()), item: reader.summary(api.workspace_display_name()),
source: "local_workspace_root".to_string(), source: "local_workspace_root".to_string(),
})) }))
} }
@ -341,7 +343,7 @@ async fn repository_log(
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
Query(query): Query<LogQuery>, Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> { ) -> ApiResult<Json<RepositoryLogResponse>> {
ensure_local_repository(&repository_id)?; let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let RepositoryLogRead { let RepositoryLogRead {
limit, limit,
items, items,
@ -349,7 +351,7 @@ async fn repository_log(
} = api.local_repository_reader().recent_log(query.limit); } = api.local_repository_reader().recent_log(query.limit);
Ok(Json(RepositoryLogResponse { Ok(Json(RepositoryLogResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
repository_id, repository_id: canonical_repository_id,
limit, limit,
items, items,
diagnostics, diagnostics,
@ -361,7 +363,7 @@ async fn repository_tickets(
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>, Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> { ) -> ApiResult<Json<RepositoryTicketsResponse>> {
ensure_local_repository(&repository_id)?; let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let limit = query.limit.unwrap_or(api.config.max_records).min(200); let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let ProjectRecordList { let ProjectRecordList {
items, items,
@ -370,7 +372,7 @@ async fn repository_tickets(
} = api.records.list_tickets(limit)?; } = api.records.list_tickets(limit)?;
Ok(Json(RepositoryTicketsResponse { Ok(Json(RepositoryTicketsResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
repository_id, repository_id: canonical_repository_id,
limit, limit,
columns: ticket_kanban_columns(items), columns: ticket_kanban_columns(items),
invalid_records, invalid_records,
@ -429,9 +431,10 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
}) })
} }
fn ensure_local_repository(repository_id: &str) -> Result<()> { fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
if LocalRepositoryReader::is_local_repository_id(repository_id) { let canonical_repository_id = api.local_repository_id();
Ok(()) if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
Ok(canonical_repository_id)
} else { } else {
Err(Error::UnknownRepository(repository_id.to_string())) Err(Error::UnknownRepository(repository_id.to_string()))
} }
@ -612,6 +615,18 @@ mod tests {
use crate::store::SqliteWorkspaceStore; use crate::store::SqliteWorkspaceStore;
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
fn test_identity() -> WorkspaceIdentity {
WorkspaceIdentity {
workspace_id: TEST_WORKSPACE_ID.to_string(),
display_name: "Test Workspace".to_string(),
created_at: TEST_CREATED_AT.to_string(),
}
}
#[tokio::test] #[tokio::test]
async fn serves_bounded_read_apis_and_static_spa_separately() { async fn serves_bounded_read_apis_and_static_spa_separately() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -623,15 +638,15 @@ mod tests {
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap(); std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path()); let mut config = ServerConfig::local_dev(dir.path(), test_identity());
config.workspace_id = "local:test".to_string();
config.static_assets_dir = Some(static_dir); config.static_assets_dir = Some(static_dir);
config.local_runtime_data_dir = Some(dir.path().join("data")); config.local_runtime_data_dir = Some(dir.path().join("data"));
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app = build_router(api); let app = build_router(api);
let workspace = get_json(app.clone(), "/api/workspace").await; let workspace = get_json(app.clone(), "/api/workspace").await;
assert_eq!(workspace["workspace_id"], "local:test"); assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID);
assert_eq!(workspace["display_name"], "Test Workspace");
assert_eq!(workspace["record_authority"], "local_yoi_project_records"); assert_eq!(workspace["record_authority"], "local_yoi_project_records");
assert_eq!( assert_eq!(
workspace["extension_points"]["host_worker_bridge"]["status"], workspace["extension_points"]["host_worker_bridge"]["status"],
@ -647,18 +662,18 @@ mod tests {
assert_eq!(objectives["items"][0]["summary"], "Objective body."); assert_eq!(objectives["items"][0]["summary"], "Objective body.");
let repositories = get_json(app.clone(), "/api/repositories").await; let repositories = get_json(app.clone(), "/api/repositories").await;
assert_eq!(repositories["items"][0]["id"], "local"); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["kind"], "local"); assert_eq!(repositories["items"][0]["kind"], "local");
let repository_detail = get_json(app.clone(), "/api/repositories/local").await; let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
assert_eq!(repository_detail["item"]["id"], "local"); assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await; let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
assert_eq!(repository_log["repository_id"], "local"); assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(repository_log["limit"], 3); assert_eq!(repository_log["limit"], 3);
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await; let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
assert_eq!(repository_tickets["repository_id"], "local"); assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
let ready_column = repository_tickets["columns"] let ready_column = repository_tickets["columns"]
.as_array() .as_array()
.unwrap() .unwrap()
@ -684,7 +699,7 @@ mod tests {
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND); assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
let hosts = get_json(app.clone(), "/api/hosts").await; let hosts = get_json(app.clone(), "/api/hosts").await;
assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); assert_eq!(hosts["items"][0]["host_id"], TEST_REPOSITORY_ID);
assert_eq!(hosts["items"][0]["kind"], "local_host"); assert_eq!(hosts["items"][0]["kind"], "local_host");
assert_eq!( assert_eq!(
hosts["items"][0]["capabilities"]["local_pod_inspection"], hosts["items"][0]["capabilities"]["local_pod_inspection"],
@ -698,7 +713,11 @@ mod tests {
"local_pod_metadata_root_missing" "local_pod_metadata_root_missing"
); );
let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; let host_workers = get_json(
app.clone(),
&format!("/api/hosts/{TEST_REPOSITORY_ID}/workers"),
)
.await;
assert!(host_workers["items"].as_array().unwrap().is_empty()); assert!(host_workers["items"].as_array().unwrap().is_empty());
let runs_response = app let runs_response = app

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-M8cGY+eskFXSRjq3kBbRusflghvVKrWc1Pj50uKAlg8="; cargoHash = "sha256-XZxqEKKDU42fFjFnCCcRRFTA0jkkiaSn3eQ8QwXRYPk=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,