feat: persist workspace identity
This commit is contained in:
parent
4cda83b748
commit
31565c9b9e
3
.yoi/workspace.toml
Normal file
3
.yoi/workspace.toml
Normal 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
3
Cargo.lock
generated
|
|
@ -6050,6 +6050,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"manifest",
|
||||
"pod-store",
|
||||
"project-record",
|
||||
|
|
@ -6061,8 +6062,10 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ publish = false
|
|||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
manifest = { workspace = true }
|
||||
pod-store = { workspace = true }
|
||||
project-record.workspace = true
|
||||
|
|
@ -18,7 +19,9 @@ serde_yaml.workspace = true
|
|||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
301
crates/workspace-server/src/identity.rs
Normal file
301
crates/workspace-server/src/identity.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
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> {
|
||||
if path.exists() {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
return Self::parse_str(&raw, path);
|
||||
}
|
||||
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(path)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
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(&self, path: &Path) -> Result<()> {
|
||||
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}"))
|
||||
})?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
fs::write(&tmp, raw)?;
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
let raw = fs::read_to_string(path)?;
|
||||
return Self::parse_str(&raw, path).map(|_| ());
|
||||
}
|
||||
fs::rename(tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,13 @@
|
|||
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod records;
|
||||
pub mod repositories;
|
||||
pub mod server;
|
||||
pub mod store;
|
||||
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
|
|
@ -40,6 +42,8 @@ pub enum Error {
|
|||
UnknownHost(String),
|
||||
#[error("unknown local repository `{0}`")]
|
||||
UnknownRepository(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::process::ExitCode;
|
|||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve};
|
||||
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, WorkspaceIdentity, serve};
|
||||
|
||||
#[derive(Debug)]
|
||||
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>> {
|
||||
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
||||
let db = options
|
||||
.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 mut config = ServerConfig::local_dev(&options.workspace);
|
||||
let mut config = ServerConfig::local_dev(&options.workspace, identity);
|
||||
config.static_assets_dir = options.frontend;
|
||||
let listener = TcpListener::bind(options.listen).await?;
|
||||
eprintln!(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
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 DEFAULT_LOG_LIMIT: usize = 10;
|
||||
const MAX_LOG_LIMIT: usize = 50;
|
||||
|
|
@ -14,12 +15,14 @@ const MAX_FIELD_LEN: usize = 240;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepositoryReader {
|
||||
workspace_root: PathBuf,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
let git = inspect_git(&self.workspace_root);
|
||||
RepositorySummary {
|
||||
id: LOCAL_REPOSITORY_ID.to_string(),
|
||||
id: Self::repository_id_for_workspace(&self.workspace_id),
|
||||
display_name: workspace_display_name.to_string(),
|
||||
kind: "local".to_string(),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
|
|
@ -46,8 +49,42 @@ impl LocalRepositoryReader {
|
|||
git_log(&self.workspace_root, limit)
|
||||
}
|
||||
|
||||
pub fn is_local_repository_id(id: &str) -> bool {
|
||||
id == LOCAL_REPOSITORY_ID
|
||||
pub fn repository_id_for_workspace(workspace_id: &str) -> String {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
|
|
@ -28,6 +29,8 @@ pub enum AuthConfig {
|
|||
#[derive(Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub workspace_id: String,
|
||||
pub workspace_display_name: String,
|
||||
pub workspace_created_at: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub static_assets_dir: Option<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
|
|
@ -36,11 +39,12 @@ pub struct 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 display = workspace_display_name_from_root(&workspace_root);
|
||||
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,
|
||||
static_assets_dir: None,
|
||||
auth: AuthConfig::LocalDevToken {
|
||||
|
|
@ -61,14 +65,13 @@ pub struct WorkspaceApi {
|
|||
|
||||
impl WorkspaceApi {
|
||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||
let display_name = workspace_display_name_from_root(&config.workspace_root);
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
display_name,
|
||||
display_name: config.workspace_display_name.clone(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "1970-01-01T00:00:00Z".to_string(),
|
||||
created_at: config.workspace_created_at.clone(),
|
||||
updated_at: config.workspace_created_at.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(Self {
|
||||
|
|
@ -91,20 +94,19 @@ impl WorkspaceApi {
|
|||
}
|
||||
|
||||
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 {
|
||||
workspace_display_name_from_root(&self.config.workspace_root)
|
||||
fn local_repository_id(&self) -> String {
|
||||
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_display_name_from_root(workspace_root: &std::path::Path) -> String {
|
||||
workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("workspace root must have a final path component")
|
||||
.to_string()
|
||||
fn workspace_display_name(&self) -> &str {
|
||||
self.config.workspace_display_name.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.as_ref()
|
||||
.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 {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
display_name,
|
||||
|
|
@ -314,7 +316,7 @@ async fn list_repositories(
|
|||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||
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 {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
|
|
@ -327,11 +329,11 @@ async fn repository_detail(
|
|||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let reader = api.local_repository_reader();
|
||||
Ok(Json(RepositoryDetailResponse {
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
|
@ -341,7 +343,7 @@ async fn repository_log(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<LogQuery>,
|
||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||
ensure_local_repository(&repository_id)?;
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let RepositoryLogRead {
|
||||
limit,
|
||||
items,
|
||||
|
|
@ -349,7 +351,7 @@ async fn repository_log(
|
|||
} = api.local_repository_reader().recent_log(query.limit);
|
||||
Ok(Json(RepositoryLogResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
repository_id: canonical_repository_id,
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
|
|
@ -361,7 +363,7 @@ async fn repository_tickets(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<TicketKanbanQuery>,
|
||||
) -> 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 ProjectRecordList {
|
||||
items,
|
||||
|
|
@ -370,7 +372,7 @@ async fn repository_tickets(
|
|||
} = api.records.list_tickets(limit)?;
|
||||
Ok(Json(RepositoryTicketsResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id,
|
||||
repository_id: canonical_repository_id,
|
||||
limit,
|
||||
columns: ticket_kanban_columns(items),
|
||||
invalid_records,
|
||||
|
|
@ -429,9 +431,10 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||
})
|
||||
}
|
||||
|
||||
fn ensure_local_repository(repository_id: &str) -> Result<()> {
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id) {
|
||||
Ok(())
|
||||
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
|
||||
let canonical_repository_id = api.local_repository_id();
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
|
||||
Ok(canonical_repository_id)
|
||||
} else {
|
||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||
}
|
||||
|
|
@ -612,6 +615,18 @@ mod tests {
|
|||
|
||||
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]
|
||||
async fn serves_bounded_read_apis_and_static_spa_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -623,15 +638,15 @@ mod tests {
|
|||
std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut config = ServerConfig::local_dev(dir.path());
|
||||
config.workspace_id = "local:test".to_string();
|
||||
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
|
||||
config.static_assets_dir = Some(static_dir);
|
||||
config.local_runtime_data_dir = Some(dir.path().join("data"));
|
||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
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["extension_points"]["host_worker_bridge"]["status"],
|
||||
|
|
@ -647,18 +662,18 @@ mod tests {
|
|||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
assert_eq!(repository_log["repository_id"], "local");
|
||||
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repository_log["limit"], 3);
|
||||
|
||||
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"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
|
|
@ -684,7 +699,7 @@ mod tests {
|
|||
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
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]["capabilities"]["local_pod_inspection"],
|
||||
|
|
@ -698,7 +713,11 @@ mod tests {
|
|||
"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());
|
||||
|
||||
let runs_response = app
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-M8cGY+eskFXSRjq3kBbRusflghvVKrWc1Pj50uKAlg8=";
|
||||
cargoHash = "sha256-XZxqEKKDU42fFjFnCCcRRFTA0jkkiaSn3eQ8QwXRYPk=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user