chore: merge latest develop into Ticket source
# Conflicts: # crates/workspace-server/src/store.rs
This commit is contained in:
Generated
+2
@@ -6131,9 +6131,11 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"url",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
@@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord {
|
||||
pub repository_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub uri: String,
|
||||
pub provider: Option<String>,
|
||||
pub source: RepositorySource,
|
||||
pub default_ref: Option<String>,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -41,9 +41,11 @@ tar.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
toml.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
worker.workspace = true
|
||||
workspace-api = { path = "../workspace-api" }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef};
|
||||
use crate::interaction::WorkerInput;
|
||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
@@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector {
|
||||
pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_path: Option<PathBuf>,
|
||||
pub source: workspace_api::RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
@@ -2631,8 +2631,12 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
|
||||
@@ -318,18 +318,36 @@ impl LocalGitWorktreeMaterializer {
|
||||
),
|
||||
));
|
||||
}
|
||||
if is_remote_uri(&request.repository.uri) {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_remote_repository_unsupported",
|
||||
"remote repository URI materialization is not implemented in v0",
|
||||
));
|
||||
}
|
||||
|
||||
let source_path = request
|
||||
.repository
|
||||
.local_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
|
||||
let source_path = match request.repository.source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => {
|
||||
PathBuf::from(&request.repository.source.uri)
|
||||
}
|
||||
workspace_api::RepositorySourceKind::File => {
|
||||
url::Url::parse(&request.repository.source.uri)
|
||||
.ok()
|
||||
.and_then(|uri| uri.to_file_path().ok())
|
||||
.ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_source_invalid",
|
||||
"configured file Repository source is invalid",
|
||||
)
|
||||
})?
|
||||
}
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Http
|
||||
| workspace_api::RepositorySourceKind::Https => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_remote_repository_access_required",
|
||||
"remote Repository materialization requires an explicit authenticated access and trust handle",
|
||||
));
|
||||
}
|
||||
workspace_api::RepositorySourceKind::Invalid => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_source_invalid",
|
||||
"configured Repository source is invalid and cannot be materialized",
|
||||
));
|
||||
}
|
||||
};
|
||||
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
|
||||
.map(|value| PathBuf::from(value.trim()))
|
||||
.map_err(|_| {
|
||||
@@ -661,10 +679,6 @@ fn path_str(path: &Path) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_remote_uri(uri: &str) -> bool {
|
||||
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
@@ -793,8 +807,12 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
@@ -908,19 +926,21 @@ mod tests {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let mut remote = request(Path::new("."));
|
||||
remote.repository.local_path = None;
|
||||
remote.repository.uri = "https://example.invalid/repo.git".to_string();
|
||||
remote.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Https,
|
||||
uri: "https://example.invalid/repo.git".to_string(),
|
||||
};
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(1), &remote)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.code,
|
||||
"working_directory_remote_repository_unsupported"
|
||||
"working_directory_remote_repository_access_required"
|
||||
);
|
||||
|
||||
let mut non_git = remote;
|
||||
non_git.repository.provider = "archive".to_string();
|
||||
non_git.repository.uri = ".".to_string();
|
||||
non_git.repository.source.uri = ".".to_string();
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(2), &non_git)
|
||||
.unwrap_err();
|
||||
|
||||
@@ -7,6 +7,88 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workdir::workspace::WorkingDirectorySummary;
|
||||
|
||||
/// Provider-neutral classification of an authoritative Repository source.
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySourceKind {
|
||||
LocalPath,
|
||||
File,
|
||||
Ssh,
|
||||
Http,
|
||||
Https,
|
||||
/// A legacy value that could not be classified during migration. It remains
|
||||
/// inspectable but every provider operation must fail closed.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl RepositorySourceKind {
|
||||
pub const fn is_remote(self) -> bool {
|
||||
matches!(self, Self::Ssh | Self::Http | Self::Https)
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LocalPath => "local_path",
|
||||
Self::File => "file",
|
||||
Self::Ssh => "ssh",
|
||||
Self::Http => "http",
|
||||
Self::Https => "https",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
Some(match value {
|
||||
"local_path" => Self::LocalPath,
|
||||
"file" => Self::File,
|
||||
"ssh" => Self::Ssh,
|
||||
"http" => Self::Http,
|
||||
"https" => Self::Https,
|
||||
"invalid" => Self::Invalid,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable Repository source identity stored by Workspace authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySource {
|
||||
pub kind: RepositorySourceKind,
|
||||
/// Canonical source representation. This is an absolute local path for
|
||||
/// `local_path`, and a normalized URI/remote specification otherwise.
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryObservedStatus {
|
||||
Unverified,
|
||||
Ready,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl RepositoryObservedStatus {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unverified => "unverified",
|
||||
Self::Ready => "ready",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
Some(match value {
|
||||
"unverified" => Self::Unverified,
|
||||
"ready" => Self::Ready,
|
||||
"invalid" => Self::Invalid,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
|
||||
@@ -471,14 +471,18 @@ fn resolve_repository(
|
||||
let provider =
|
||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
||||
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
|
||||
let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?;
|
||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
||||
|
||||
Ok(ConfiguredRepository {
|
||||
id,
|
||||
provider,
|
||||
uri,
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path,
|
||||
display_name,
|
||||
default_selector,
|
||||
@@ -517,13 +521,41 @@ fn validate_repository_id(id: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
|
||||
if uri.contains("://") {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
|
||||
)));
|
||||
fn resolve_repository_source(
|
||||
workspace_root: &Path,
|
||||
id: &str,
|
||||
uri: &str,
|
||||
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
|
||||
match crate::repository_source::parse_repository_source(uri) {
|
||||
Ok(source) => {
|
||||
let path = match source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
|
||||
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
|
||||
.ok()
|
||||
.and_then(|uri| uri.to_file_path().ok()),
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Http
|
||||
| workspace_api::RepositorySourceKind::Https => None,
|
||||
workspace_api::RepositorySourceKind::Invalid => {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{id}` has an invalid source"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok((source, path))
|
||||
}
|
||||
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
|
||||
let path = resolve_workspace_path(workspace_root, Path::new(uri));
|
||||
let source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: path.to_string_lossy().into_owned(),
|
||||
};
|
||||
Ok((source, Some(path)))
|
||||
}
|
||||
Err(error) => Err(Error::Config(format!(
|
||||
"repository `{id}` has an invalid source: {error}"
|
||||
))),
|
||||
}
|
||||
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_remote_runtime(
|
||||
@@ -741,13 +773,13 @@ default_selector = "HEAD"
|
||||
|
||||
assert_eq!(repository.id, "main");
|
||||
assert_eq!(repository.provider, "git");
|
||||
assert_eq!(repository.path, dir.path());
|
||||
assert_eq!(repository.path.as_deref(), Some(dir.path()));
|
||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_repository_uri_fails_closed() {
|
||||
fn remote_repository_source_is_preserved_without_a_local_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
@@ -759,17 +791,15 @@ uri = "https://example.com/org/repo.git"
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let error = match config.resolve(dir.path(), identity()) {
|
||||
Ok(_) => panic!("remote repository URI should fail closed"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
||||
let repository = &resolved.server.repositories[0];
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("remote repository materialization is not implemented"),
|
||||
"unexpected error: {error}"
|
||||
assert_eq!(
|
||||
repository.source.kind,
|
||||
workspace_api::RepositorySourceKind::Https
|
||||
);
|
||||
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
|
||||
assert!(repository.path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod records;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub use records::ticket_api_typescript;
|
||||
pub mod repositories;
|
||||
pub mod repository_source;
|
||||
pub mod resource_broker;
|
||||
pub mod retention;
|
||||
pub mod runtime_settings;
|
||||
|
||||
@@ -711,14 +711,15 @@ fn infer_workspace_root_from_repositories(
|
||||
)));
|
||||
};
|
||||
|
||||
let repository_path = PathBuf::from(&repository.uri);
|
||||
if !repository_path.is_absolute() {
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||
return Err(CliError(format!(
|
||||
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
|
||||
repository.repository_id, repository.uri
|
||||
"repository `{}` has an invalid migrated source and cannot be used by serve",
|
||||
repository.repository_id
|
||||
)));
|
||||
}
|
||||
Ok(repository_path)
|
||||
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||
&workspace.workspace_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
pub type RepositoryId = String;
|
||||
pub type RepositorySelector = String;
|
||||
@@ -13,8 +14,12 @@ pub type RepositorySelector = String;
|
||||
pub struct ConfiguredRepository {
|
||||
pub id: RepositoryId,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
pub path: PathBuf,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
pub path: Option<PathBuf>,
|
||||
pub display_name: Option<String>,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
}
|
||||
@@ -25,6 +30,12 @@ pub struct RepositorySummary {
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub record_authority: String,
|
||||
@@ -262,9 +273,17 @@ impl RepositoryRegistryReader {
|
||||
descendant: &str,
|
||||
) -> Result<(), RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let repository_path =
|
||||
repository
|
||||
.path
|
||||
.as_ref()
|
||||
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
|
||||
id: id.into(),
|
||||
operation: "repository source is not materialized for local Git access".into(),
|
||||
})?;
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repository.path)
|
||||
.arg(repository_path)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.status()
|
||||
.map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
@@ -306,7 +325,24 @@ impl RepositoryRegistryReader {
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone());
|
||||
let mut diagnostics = Vec::new();
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_source_insecure_http".to_string(),
|
||||
message:
|
||||
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let git = match repository.provider.as_str() {
|
||||
"git" if repository.path.is_none() => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "info".to_string(),
|
||||
code: "repository_source_unverified".to_string(),
|
||||
message: "Remote Repository source is registered but is not materialized for server-local inspection.".to_string(),
|
||||
});
|
||||
None
|
||||
}
|
||||
"git" => match self.inspect_git(repository) {
|
||||
Ok(git) => Some(git),
|
||||
Err(message) => {
|
||||
@@ -335,6 +371,11 @@ impl RepositoryRegistryReader {
|
||||
display_name,
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
source: repository.source.clone(),
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
observed_status: repository.observed_status,
|
||||
observed_at: repository.observed_at.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-control-plane".to_string(),
|
||||
git,
|
||||
@@ -346,12 +387,15 @@ impl RepositoryRegistryReader {
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
) -> Result<GitRepositorySummary, String> {
|
||||
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
|
||||
let path = repository.path.as_ref().ok_or_else(|| {
|
||||
"Repository source is not materialized for local Git inspection.".to_string()
|
||||
})?;
|
||||
let head = git_stdout(path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(path, ["branch", "--show-current"])
|
||||
.ok()
|
||||
.and_then(|value| non_empty_string(value.trim()));
|
||||
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(&repository.path, ["remote", "-v"])
|
||||
let status = git_stdout(path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(path, ["remote", "-v"])
|
||||
.map(|raw| parse_remotes(&raw))
|
||||
.unwrap_or_default();
|
||||
Ok(GitRepositorySummary {
|
||||
@@ -369,8 +413,11 @@ impl RepositoryRegistryReader {
|
||||
limit: usize,
|
||||
) -> Result<Vec<GitCommitSummary>, String> {
|
||||
let limit_arg = format!("-{limit}");
|
||||
let path = repository.path.as_ref().ok_or_else(|| {
|
||||
"Repository source is not materialized for local Git log access.".to_string()
|
||||
})?;
|
||||
let output = git_stdout(
|
||||
&repository.path,
|
||||
path,
|
||||
[
|
||||
"log",
|
||||
"--date=iso-strict",
|
||||
@@ -419,11 +466,16 @@ fn merge_git_stdout(
|
||||
operation: &str,
|
||||
args: &[&str],
|
||||
) -> Result<String, RepositoryLookupError> {
|
||||
git_stdout(&repository.path, args.iter().copied()).map_err(|_| {
|
||||
RepositoryLookupError::ProviderFailure {
|
||||
let path = repository
|
||||
.path
|
||||
.as_ref()
|
||||
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
|
||||
id: repository.id.clone(),
|
||||
operation: operation.into(),
|
||||
}
|
||||
operation: "repository source is not materialized for local Git access".into(),
|
||||
})?;
|
||||
git_stdout(path, args.iter().copied()).map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
id: repository.id.clone(),
|
||||
operation: operation.into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -593,6 +645,47 @@ mod tests {
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
||||
let source = RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
uri: "git@example.test:org/repository.git".to_string(),
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "remote".into(),
|
||||
display_name: Some("Remote".into()),
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: None,
|
||||
default_selector: Some("main".into()),
|
||||
}]);
|
||||
|
||||
let projection = reader.list();
|
||||
let summary = &projection.items[0];
|
||||
assert_eq!(
|
||||
summary.source.kind,
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
);
|
||||
assert_eq!(
|
||||
summary.observed_status,
|
||||
RepositoryObservedStatus::Unverified
|
||||
);
|
||||
assert!(summary.git.is_none());
|
||||
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified");
|
||||
|
||||
let repository = reader.merge_repository("remote").unwrap();
|
||||
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
RepositoryLookupError::ProviderFailure { operation, .. }
|
||||
if operation.contains("not materialized")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_evidence_is_resolved_by_repository_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -675,12 +768,22 @@ mod tests {
|
||||
.success()
|
||||
);
|
||||
|
||||
let source_descriptor = RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: path.display().to_string(),
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "main".into(),
|
||||
display_name: Some("Main".into()),
|
||||
provider: "git".into(),
|
||||
path: path.to_path_buf(),
|
||||
uri: path.display().to_string(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(
|
||||
&source_descriptor,
|
||||
),
|
||||
source: source_descriptor,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: Some(path.to_path_buf()),
|
||||
default_selector: Some("main".into()),
|
||||
}]);
|
||||
let target = reader.observe_merge_target("main", Some("main")).unwrap();
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
use workspace_api::{RepositorySource, RepositorySourceKind};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_REPOSITORY_SOURCE_BYTES: usize = 4096;
|
||||
|
||||
/// Parse and canonicalize a user-authored Git source without accessing the
|
||||
/// filesystem or network.
|
||||
pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > MAX_REPOSITORY_SOURCE_BYTES {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"initial repository source must be between 1 and {MAX_REPOSITORY_SOURCE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not contain control characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if Path::new(value).is_absolute() {
|
||||
return Ok(RepositorySource {
|
||||
kind: RepositorySourceKind::LocalPath,
|
||||
uri: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_scp_like_ssh(value) {
|
||||
validate_scp_like_ssh(value)?;
|
||||
return Ok(RepositorySource {
|
||||
kind: RepositorySourceKind::Ssh,
|
||||
uri: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed = Url::parse(value).map_err(|_| {
|
||||
Error::InvalidInput(
|
||||
"initial repository source must be an absolute local path or a supported Git URI"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
if parsed.query().is_some() || parsed.fragment().is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not contain query parameters or fragments".to_string(),
|
||||
));
|
||||
}
|
||||
if parsed.password().is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not embed a password or token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let kind = match parsed.scheme() {
|
||||
"file" => {
|
||||
if !parsed.username().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"file repository URI must not contain user information".to_string(),
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_some_and(|host| host != "localhost") {
|
||||
return Err(Error::InvalidInput(
|
||||
"file repository URI host must be empty or localhost".to_string(),
|
||||
));
|
||||
}
|
||||
parsed.to_file_path().map_err(|_| {
|
||||
Error::InvalidInput("file repository URI must contain an absolute path".to_string())
|
||||
})?;
|
||||
RepositorySourceKind::File
|
||||
}
|
||||
"ssh" => {
|
||||
require_remote_host_and_path(&parsed)?;
|
||||
RepositorySourceKind::Ssh
|
||||
}
|
||||
"http" | "https" => {
|
||||
if !parsed.username().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"HTTP repository URI must not contain user information".to_string(),
|
||||
));
|
||||
}
|
||||
require_remote_host_and_path(&parsed)?;
|
||||
if parsed.scheme() == "http" {
|
||||
RepositorySourceKind::Http
|
||||
} else {
|
||||
RepositorySourceKind::Https
|
||||
}
|
||||
}
|
||||
scheme => {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"unsupported initial repository source scheme `{scheme}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(RepositorySource {
|
||||
kind,
|
||||
uri: parsed.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Classify persisted pre-source-contract rows without guessing a usable remote
|
||||
/// when the legacy value is malformed. No filesystem or network access occurs.
|
||||
pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
|
||||
parse_repository_source(value).unwrap_or_else(|_| RepositorySource {
|
||||
kind: RepositorySourceKind::Invalid,
|
||||
uri: value.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
||||
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"yoi.repository-source.v1\0");
|
||||
hasher.update(payload);
|
||||
let digest = hasher.finalize();
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
format!("sha256:{encoded}")
|
||||
}
|
||||
|
||||
fn require_remote_host_and_path(parsed: &Url) -> Result<()> {
|
||||
if parsed.host_str().is_none() || parsed.path().is_empty() || parsed.path() == "/" {
|
||||
return Err(Error::InvalidInput(
|
||||
"remote repository URI must contain a host and repository path".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_scp_like_ssh(value: &str) -> bool {
|
||||
!value.contains("://")
|
||||
&& value
|
||||
.split_once(':')
|
||||
.is_some_and(|(identity, _)| identity.contains('@'))
|
||||
}
|
||||
|
||||
fn validate_scp_like_ssh(value: &str) -> Result<()> {
|
||||
let (identity, path) = value.split_once(':').ok_or_else(|| {
|
||||
Error::InvalidInput("scp-like SSH source must contain `host:path`".to_string())
|
||||
})?;
|
||||
let (username, host) = identity.split_once('@').ok_or_else(|| {
|
||||
Error::InvalidInput("scp-like SSH source must contain `user@host:path`".to_string())
|
||||
})?;
|
||||
if username.is_empty()
|
||||
|| host.is_empty()
|
||||
|| path.is_empty()
|
||||
|| username.contains('@')
|
||||
|| username.contains(':')
|
||||
|| host.contains('@')
|
||||
|| path.starts_with('-')
|
||||
|| value.contains('?')
|
||||
|| value.contains('#')
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"scp-like SSH source must use `user@host:path` without credentials or parameters"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_local_file_ssh_http_and_https_sources_without_io() {
|
||||
let cases = [
|
||||
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
||||
("file:///runtime/repos/project", RepositorySourceKind::File),
|
||||
(
|
||||
"ssh://git@example.test/org/project.git",
|
||||
RepositorySourceKind::Ssh,
|
||||
),
|
||||
(
|
||||
"git@example.test:org/project.git",
|
||||
RepositorySourceKind::Ssh,
|
||||
),
|
||||
(
|
||||
"http://git.test/org/project.git",
|
||||
RepositorySourceKind::Http,
|
||||
),
|
||||
(
|
||||
"https://git.test/org/project.git",
|
||||
RepositorySourceKind::Https,
|
||||
),
|
||||
];
|
||||
for (source, expected_kind) in cases {
|
||||
assert_eq!(parse_repository_source(source).unwrap().kind, expected_kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
||||
for source in [
|
||||
"relative/project",
|
||||
"ftp://git.test/project.git",
|
||||
"https://user@git.test/project.git",
|
||||
"https://git.test/project.git?token=secret",
|
||||
"ssh://git:secret@git.test/project.git",
|
||||
"git@example.test:",
|
||||
"git:secret@example.test:org/project.git",
|
||||
"https://git.test/project.git\nother",
|
||||
] {
|
||||
assert!(
|
||||
parse_repository_source(source).is_err(),
|
||||
"accepted {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_uses_canonical_source_identity() {
|
||||
let first = parse_repository_source(" https://EXAMPLE.test/a/../project.git ").unwrap();
|
||||
let second = parse_repository_source("https://example.test/project.git").unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
repository_source_fingerprint(&first),
|
||||
repository_source_fingerprint(&second)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,30 +269,26 @@ impl ServerConfig {
|
||||
workspace: &WorkspaceRecord,
|
||||
repositories: Vec<RepositoryRecord>,
|
||||
) -> Result<Self> {
|
||||
let primary = repositories
|
||||
.iter()
|
||||
.find(|repository| repository.repository_id == "main")
|
||||
.or_else(|| repositories.first())
|
||||
.ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"Workspace {} has no registered repository",
|
||||
workspace.workspace_id
|
||||
))
|
||||
})?;
|
||||
let workspace_root = PathBuf::from(&primary.uri);
|
||||
if !workspace_root.is_absolute() {
|
||||
if repositories.is_empty() {
|
||||
return Err(Error::Config(format!(
|
||||
"Workspace {} repository uri is not an absolute local path",
|
||||
"Workspace {} has no registered repository",
|
||||
workspace.workspace_id
|
||||
)));
|
||||
}
|
||||
let workspace_data_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 {
|
||||
id: repository.repository_id,
|
||||
provider: repository.provider.unwrap_or(repository.kind),
|
||||
path: PathBuf::from(&repository.uri),
|
||||
uri: repository.uri,
|
||||
path: repository_local_path(&repository.source),
|
||||
source: repository.source,
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint,
|
||||
observed_status: repository.observed_status,
|
||||
observed_at: repository.observed_at,
|
||||
display_name: Some(repository.name),
|
||||
default_selector: repository.default_ref,
|
||||
})
|
||||
@@ -319,6 +315,19 @@ impl ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_local_path(source: &workspace_api::RepositorySource) -> Option<PathBuf> {
|
||||
match source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
|
||||
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
|
||||
.ok()
|
||||
.and_then(|uri| uri.to_file_path().ok()),
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Http
|
||||
| workspace_api::RepositorySourceKind::Https
|
||||
| workspace_api::RepositorySourceKind::Invalid => None,
|
||||
}
|
||||
}
|
||||
|
||||
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
|
||||
const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention";
|
||||
|
||||
@@ -1516,10 +1525,12 @@ fn import_configured_repositories(
|
||||
.unwrap_or_else(|| repository.id.clone()),
|
||||
kind: repository.provider.clone(),
|
||||
provider: Some(repository.provider.clone()),
|
||||
uri: repository.uri.clone(),
|
||||
source: repository.source.clone(),
|
||||
default_ref: repository.default_selector.clone(),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
observed_status: repository.observed_status,
|
||||
observed_at: repository.observed_at.clone(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
})?;
|
||||
@@ -1539,35 +1550,25 @@ fn load_configured_repositories_from_store(
|
||||
}
|
||||
|
||||
fn configured_repository_from_record(
|
||||
workspace_root: &Path,
|
||||
_workspace_root: &Path,
|
||||
record: RepositoryRecord,
|
||||
) -> Result<ConfiguredRepository> {
|
||||
let provider = record.provider.unwrap_or_else(|| record.kind.clone());
|
||||
if record.uri.contains("://") {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{}` uses a remote URI, but remote repository materialization is not implemented",
|
||||
record.repository_id
|
||||
)));
|
||||
}
|
||||
let path = resolve_backend_path(workspace_root, Path::new(&record.uri));
|
||||
let path = repository_local_path(&record.source);
|
||||
Ok(ConfiguredRepository {
|
||||
id: record.repository_id,
|
||||
provider,
|
||||
uri: record.uri,
|
||||
path,
|
||||
source: record.source,
|
||||
source_revision: record.source_revision,
|
||||
source_fingerprint: record.source_fingerprint,
|
||||
observed_status: record.observed_status,
|
||||
observed_at: record.observed_at,
|
||||
display_name: Some(record.name),
|
||||
default_selector: record.default_ref,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
workspace_root.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_server_auth_router(api: ServerAuthApi) -> Router {
|
||||
Router::new()
|
||||
.route("/api/auth/config", get(get_auth_config))
|
||||
@@ -10107,8 +10108,9 @@ fn working_directory_request_from_repository(
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: repository.id.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
uri: repository.uri.clone(),
|
||||
local_path: Some(repository.path.clone()),
|
||||
source: repository.source.clone(),
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
selector: selector
|
||||
.map(|selector| RuntimeRepositorySelector::from(selector.to_string()))
|
||||
.or_else(|| {
|
||||
@@ -13140,8 +13142,9 @@ fn working_directory_request_for_browser(
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: repository.id.clone(),
|
||||
provider: "git".to_string(),
|
||||
uri: repository.path.to_string_lossy().to_string(),
|
||||
local_path: Some(repository.path.clone()),
|
||||
source: repository.source.clone(),
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
selector: selector.map(RuntimeRepositorySelector),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
@@ -14059,10 +14062,15 @@ mod tests {
|
||||
name: "Foreign".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: dir.path().join("foreign").display().to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: dir.path().join("foreign").display().to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
@@ -15319,6 +15327,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_workspace_with_remote_source_does_not_require_server_local_repository() {
|
||||
let base = test_server_config(tempfile::tempdir().unwrap().path());
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "remote-workspace".to_string(),
|
||||
display_name: "Remote Workspace".to_string(),
|
||||
state: "active".to_string(),
|
||||
owner_account_id: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
};
|
||||
let source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Https,
|
||||
uri: "https://example.test/org/repository.git".to_string(),
|
||||
};
|
||||
let repositories = vec![RepositoryRecord {
|
||||
workspace_id: "remote-workspace".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
name: "Main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
default_ref: Some("main".to_string()),
|
||||
source_revision: 1,
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
}];
|
||||
|
||||
let scoped = base
|
||||
.for_catalog_workspace(&workspace, repositories)
|
||||
.unwrap();
|
||||
assert!(scoped.repositories[0].path.is_none());
|
||||
assert_eq!(
|
||||
scoped.repositories[0].source.kind,
|
||||
workspace_api::RepositorySourceKind::Https
|
||||
);
|
||||
assert_eq!(
|
||||
scoped.workspace_root,
|
||||
ServerConfig::default_workspace_backend_data_root("remote-workspace")
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -15326,11 +15379,19 @@ mod tests {
|
||||
.with_embedded_runtime_store_root(store_root);
|
||||
config.database_path = workspace_root.join(".test-yoi-server.db");
|
||||
config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml"));
|
||||
let source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: workspace_root.display().to_string(),
|
||||
};
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: TEST_REPOSITORY_ID.to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: workspace_root.display().to_string(),
|
||||
path: workspace_root,
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: Some(workspace_root),
|
||||
display_name: Some("Test Repository".to_string()),
|
||||
default_selector: Some("HEAD".to_string()),
|
||||
}];
|
||||
@@ -18478,10 +18539,15 @@ mod tests {
|
||||
name: repository_id.to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: api.config.workspace_root.display().to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: api.config.workspace_root.display().to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
@@ -20709,8 +20775,15 @@ mod tests {
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: "files".to_string(),
|
||||
provider: "local_fs".to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: root.path().to_path_buf(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: root.path().display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: Some(root.path().to_path_buf()),
|
||||
display_name: None,
|
||||
default_selector: None,
|
||||
}];
|
||||
|
||||
@@ -14,6 +14,7 @@ use uuid::Uuid;
|
||||
use worker_runtime::identity::{
|
||||
LegacyWorkerIdentityMapping, RuntimeWorkerRef, WorkerId, legacy_worker_identity_mapping_digest,
|
||||
};
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -243,6 +244,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
},
|
||||
Migration {
|
||||
version: 44,
|
||||
name: "create Repository source authority",
|
||||
apply: create_repository_source_authority,
|
||||
},
|
||||
Migration {
|
||||
version: 45,
|
||||
name: "create Workdir create operations",
|
||||
apply: create_workdir_create_operations,
|
||||
},
|
||||
@@ -300,10 +306,12 @@ pub struct RepositoryRecord {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub provider: Option<String>,
|
||||
pub uri: String,
|
||||
pub source: RepositorySource,
|
||||
pub default_ref: Option<String>,
|
||||
pub auth_ref_kind: Option<String>,
|
||||
pub auth_ref_key: Option<String>,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -1793,8 +1801,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
read_workspace_record,
|
||||
)?;
|
||||
let repository = tx.query_row(
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#,
|
||||
params![workspace.workspace_id, record.repository.repository_id],
|
||||
read_repository_record,
|
||||
@@ -1845,8 +1854,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
let existing_repository = tx
|
||||
.query_row(
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#,
|
||||
params![record.repository.workspace_id, record.repository.repository_id],
|
||||
read_repository_record,
|
||||
@@ -1884,19 +1894,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO repositories (
|
||||
workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
|
||||
workspace_id, repository_id, name, kind, provider, uri,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
|
||||
params![
|
||||
record.repository.workspace_id,
|
||||
record.repository.repository_id,
|
||||
record.repository.name,
|
||||
record.repository.kind,
|
||||
record.repository.provider,
|
||||
record.repository.uri,
|
||||
record.repository.source.uri,
|
||||
record.repository.source.kind.as_str(),
|
||||
record.repository.source.uri,
|
||||
record.repository.default_ref,
|
||||
record.repository.auth_ref_kind,
|
||||
record.repository.auth_ref_key,
|
||||
record.repository.source_revision,
|
||||
record.repository.source_fingerprint,
|
||||
record.repository.observed_status.as_str(),
|
||||
record.repository.observed_at,
|
||||
record.repository.created_at,
|
||||
record.repository.updated_at,
|
||||
],
|
||||
@@ -2076,17 +2091,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO repositories (
|
||||
workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
workspace_id, repository_id, name, kind, provider, uri,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
||||
ON CONFLICT(workspace_id, repository_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
kind = excluded.kind,
|
||||
provider = excluded.provider,
|
||||
uri = excluded.uri,
|
||||
default_ref = excluded.default_ref,
|
||||
auth_ref_kind = excluded.auth_ref_kind,
|
||||
auth_ref_key = excluded.auth_ref_key,
|
||||
observed_status = excluded.observed_status,
|
||||
observed_at = excluded.observed_at,
|
||||
updated_at = excluded.updated_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
@@ -2094,10 +2109,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.name,
|
||||
record.kind,
|
||||
record.provider,
|
||||
record.uri,
|
||||
record.source.uri,
|
||||
record.source.kind.as_str(),
|
||||
record.source.uri,
|
||||
record.default_ref,
|
||||
record.auth_ref_kind,
|
||||
record.auth_ref_key,
|
||||
record.source_revision,
|
||||
record.source_fingerprint,
|
||||
record.observed_status.as_str(),
|
||||
record.observed_at,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
@@ -2113,8 +2132,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<Option<RepositoryRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
FROM repositories
|
||||
WHERE workspace_id = ?1 AND repository_id = ?2"#,
|
||||
params![workspace_id, repository_id],
|
||||
@@ -2128,8 +2148,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
r#"SELECT workspace_id, repository_id, name, kind, provider,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
FROM repositories
|
||||
WHERE workspace_id = ?1
|
||||
ORDER BY repository_id ASC"#,
|
||||
@@ -5092,18 +5113,38 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
|
||||
}
|
||||
|
||||
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
|
||||
let source_kind_value = row.get::<_, String>(5)?;
|
||||
let source_kind = workspace_api::RepositorySourceKind::parse(&source_kind_value)
|
||||
.unwrap_or(workspace_api::RepositorySourceKind::Invalid);
|
||||
let source_revision = row.get::<_, u64>(8)?;
|
||||
let source_fingerprint = row.get::<_, String>(9)?;
|
||||
let mut source = RepositorySource {
|
||||
kind: source_kind,
|
||||
uri: row.get(6)?,
|
||||
};
|
||||
let observed_status_value = row.get::<_, String>(10)?;
|
||||
let mut observed_status = RepositoryObservedStatus::parse(&observed_status_value)
|
||||
.unwrap_or(RepositoryObservedStatus::Invalid);
|
||||
if source_revision == 0
|
||||
|| crate::repository_source::repository_source_fingerprint(&source) != source_fingerprint
|
||||
{
|
||||
source.kind = workspace_api::RepositorySourceKind::Invalid;
|
||||
observed_status = RepositoryObservedStatus::Invalid;
|
||||
}
|
||||
Ok(RepositoryRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
repository_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
provider: row.get(4)?,
|
||||
uri: row.get(5)?,
|
||||
default_ref: row.get(6)?,
|
||||
auth_ref_kind: row.get(7)?,
|
||||
auth_ref_key: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
source,
|
||||
default_ref: row.get(7)?,
|
||||
source_revision,
|
||||
source_fingerprint,
|
||||
observed_status,
|
||||
observed_at: row.get(11)?,
|
||||
created_at: row.get(12)?,
|
||||
updated_at: row.get(13)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6422,6 +6463,61 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_repository_source_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE repositories ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'invalid';
|
||||
ALTER TABLE repositories ADD COLUMN source_uri TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE repositories ADD COLUMN source_revision INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE repositories ADD COLUMN source_fingerprint TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE repositories ADD COLUMN observed_status TEXT NOT NULL DEFAULT 'unverified';
|
||||
ALTER TABLE repositories ADD COLUMN observed_at TEXT;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let legacy = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT workspace_id, repository_id, uri FROM repositories ORDER BY workspace_id, repository_id",
|
||||
)?;
|
||||
stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
for (workspace_id, repository_id, uri) in legacy {
|
||||
let source = crate::repository_source::classify_legacy_repository_source(&uri);
|
||||
let fingerprint = crate::repository_source::repository_source_fingerprint(&source);
|
||||
let observed_status = if source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||
RepositoryObservedStatus::Invalid
|
||||
} else {
|
||||
RepositoryObservedStatus::Unverified
|
||||
};
|
||||
conn.execute(
|
||||
r#"UPDATE repositories
|
||||
SET source_kind = ?3,
|
||||
source_uri = ?4,
|
||||
source_revision = 1,
|
||||
source_fingerprint = ?5,
|
||||
observed_status = ?6,
|
||||
observed_at = NULL
|
||||
WHERE workspace_id = ?1 AND repository_id = ?2"#,
|
||||
params![
|
||||
workspace_id,
|
||||
repository_id,
|
||||
source.kind.as_str(),
|
||||
source.uri,
|
||||
fingerprint,
|
||||
observed_status.as_str(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workdir_create_operations(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
@@ -9283,6 +9379,72 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations_through(&conn, 43).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) \
|
||||
VALUES ('workspace-a', 'Workspace A', 'active', '1', '1')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for (repository_id, uri, auth_kind, auth_key) in [
|
||||
(
|
||||
"remote",
|
||||
"https://example.test/org/repository.git",
|
||||
Some("secret_store"),
|
||||
Some("legacy/key"),
|
||||
),
|
||||
("invalid", "relative/repository", Some("file"), Some("/key")),
|
||||
] {
|
||||
conn.execute(
|
||||
r#"INSERT INTO repositories(
|
||||
workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
auth_ref_kind, auth_ref_key, created_at, updated_at
|
||||
) VALUES ('workspace-a', ?1, ?1, 'git', 'git', ?2, 'main', ?3, ?4, '1', '1')"#,
|
||||
params![repository_id, uri, auth_kind, auth_key],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
let remote = conn
|
||||
.query_row(
|
||||
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
|
||||
FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'remote'",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(remote.0, "https");
|
||||
assert_eq!(remote.1, "https://example.test/org/repository.git");
|
||||
assert_eq!(remote.2, 1);
|
||||
assert!(remote.3.starts_with("sha256:"));
|
||||
assert_eq!(remote.4, "unverified");
|
||||
|
||||
let invalid = conn
|
||||
.query_row(
|
||||
"SELECT source_kind, observed_status FROM repositories \
|
||||
WHERE workspace_id = 'workspace-a' AND repository_id = 'invalid'",
|
||||
[],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(invalid, ("invalid".to_string(), "invalid".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_composes_ticket_migrations_when_control_plane_is_current() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
@@ -9327,7 +9489,7 @@ mod tests {
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 36);
|
||||
assert_eq!(plan.target_schema_version, 44);
|
||||
assert_eq!(plan.target_schema_version, 45);
|
||||
assert!(plan.migration_required);
|
||||
assert_eq!(plan.worker_count, 1);
|
||||
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
|
||||
@@ -9341,7 +9503,7 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(table_exists(conn, "worker_diagnostics_archives")?);
|
||||
assert_eq!(current_schema_version(conn)?, 44);
|
||||
assert_eq!(current_schema_version(conn)?, 45);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -9420,7 +9582,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
let foreign_key_error: Option<String> = conn
|
||||
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
|
||||
.optional()
|
||||
@@ -9549,7 +9711,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||
let controller_worker_id: String = conn
|
||||
.query_row(
|
||||
@@ -9667,7 +9829,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
@@ -9685,7 +9847,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
let settings = conn
|
||||
.query_row(
|
||||
"SELECT settings_revision, language FROM workspace_memory_settings \
|
||||
@@ -9726,7 +9888,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
@@ -9793,7 +9955,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
let repositories_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||
@@ -9938,10 +10100,15 @@ INSERT INTO workdir_registry (
|
||||
name: "Main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: "/repo-a".to_string(),
|
||||
source: RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/repo-a".to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
@@ -9971,7 +10138,7 @@ INSERT INTO workdir_registry (
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -9988,7 +10155,7 @@ INSERT INTO workdir_registry (
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
@@ -10742,7 +10909,7 @@ INSERT INTO worker_registry (
|
||||
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
migrated
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 44);
|
||||
assert_eq!(current_schema_version(conn)?, 45);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
|
||||
1,
|
||||
@@ -10832,10 +10999,15 @@ INSERT INTO worker_registry (
|
||||
name: "Main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: "file:///tmp/main".to_string(),
|
||||
source: RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::File,
|
||||
uri: "file:///tmp/main".to_string(),
|
||||
},
|
||||
default_ref: Some("develop".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
})
|
||||
@@ -11041,19 +11213,19 @@ INSERT INTO worker_registry (
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v44_adds_workdir_create_operations_to_v43_database() {
|
||||
fn schema_v45_adds_workdir_create_operations_to_v44_database() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE workdir_create_operations;
|
||||
DELETE FROM __yoi_schema_migrations WHERE version = 44;",
|
||||
DELETE FROM __yoi_schema_migrations WHERE version = 45;",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 43);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert!(table_exists(&conn, "workdir_create_operations").unwrap());
|
||||
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
|
||||
for required in [
|
||||
@@ -11078,13 +11250,13 @@ INSERT INTO worker_registry (
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (45, 'future')",
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (46, 'future')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||
assert!(error.contains("schema version 45 is newer"), "{error}");
|
||||
assert!(error.contains("schema version 46 is newer"), "{error}");
|
||||
assert!(error.contains("refusing to serve"), "{error}");
|
||||
}
|
||||
|
||||
@@ -11305,7 +11477,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
|
||||
|
||||
apply_migrations(&mut conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
let workspace_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
|
||||
@@ -11808,6 +11980,12 @@ WHERE workspace_id = 'workspace-a'
|
||||
"auth_ref_key",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"source_kind",
|
||||
"source_uri",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"observed_at",
|
||||
],
|
||||
);
|
||||
assert_columns(
|
||||
@@ -11922,7 +12100,7 @@ WHERE workspace_id = 'workspace-a'
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -12111,7 +12289,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -12128,10 +12306,20 @@ CREATE TABLE ticket_assignment_operations (
|
||||
name: "Yoi".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: ".".to_string(),
|
||||
source: RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/repo".to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(
|
||||
&RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/repo".to_string(),
|
||||
},
|
||||
),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "2".to_string(),
|
||||
updated_at: "2".to_string(),
|
||||
};
|
||||
@@ -12161,7 +12349,9 @@ CREATE TABLE ticket_assignment_operations (
|
||||
let mut other_repository = repository.clone();
|
||||
other_repository.workspace_id = other_workspace.workspace_id.clone();
|
||||
other_repository.name = "Other Yoi".to_string();
|
||||
other_repository.uri = "/other/yoi".to_string();
|
||||
other_repository.source.uri = "/other/yoi".to_string();
|
||||
other_repository.source_fingerprint =
|
||||
crate::repository_source::repository_source_fingerprint(&other_repository.source);
|
||||
store.upsert_repository(&other_repository).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
@@ -12177,7 +12367,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -12270,10 +12460,15 @@ CREATE TABLE ticket_assignment_operations (
|
||||
name: "Repository".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: ".".to_string(),
|
||||
source: RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/repo".to_string(),
|
||||
},
|
||||
default_ref: Some("HEAD".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
@@ -12579,7 +12774,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 44);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
@@ -185,10 +185,15 @@ mod tests {
|
||||
name: "main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: "/tmp/main".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/tmp/main".to_string(),
|
||||
},
|
||||
default_ref: Some("develop".to_string()),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
@@ -6,6 +5,9 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
||||
};
|
||||
@@ -109,8 +111,8 @@ impl WorkspaceCatalogService {
|
||||
)?;
|
||||
let display_name =
|
||||
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
||||
let repository_path = validate_repository_uri(&request.repository.uri)?;
|
||||
let repository_uri = repository_path.to_string_lossy().into_owned();
|
||||
let repository_source = validate_repository_source(&request.repository.uri)?;
|
||||
let repository_uri = repository_source.uri.clone();
|
||||
let repository_name = request
|
||||
.repository
|
||||
.display_name
|
||||
@@ -166,10 +168,12 @@ impl WorkspaceCatalogService {
|
||||
name: repository_name,
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: repository_uri,
|
||||
source: repository_source.clone(),
|
||||
default_ref: Some(default_ref),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -194,35 +198,8 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<St
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_repository_uri(uri: &str) -> Result<PathBuf> {
|
||||
let uri = uri.trim();
|
||||
if uri.is_empty() || uri.contains("://") {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository uri must be an absolute server-local path".to_string(),
|
||||
));
|
||||
}
|
||||
let path = Path::new(uri);
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository uri must be an absolute server-local path".to_string(),
|
||||
));
|
||||
}
|
||||
let path = path.canonicalize().map_err(|error| {
|
||||
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
|
||||
})?;
|
||||
if !path.is_dir() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository path must be a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let normal_git = path.join(".git").exists();
|
||||
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
|
||||
if !normal_git && !bare_git {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository path is not a Git repository".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(path)
|
||||
fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
|
||||
parse_repository_source(uri)
|
||||
}
|
||||
|
||||
fn workspace_create_fingerprint(
|
||||
@@ -259,6 +236,7 @@ fn workspace_create_fingerprint(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::SqliteWorkspaceStore;
|
||||
use workspace_api::RepositorySourceKind;
|
||||
|
||||
fn git_repository() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -383,12 +361,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_intent_rejects_remote_and_non_git_paths() {
|
||||
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err();
|
||||
assert!(remote.to_string().contains("server-local path"));
|
||||
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
||||
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
||||
assert_eq!(remote.kind, RepositorySourceKind::Https);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err();
|
||||
assert!(non_git.to_string().contains("not a Git repository"));
|
||||
let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
|
||||
assert_eq!(local.kind, RepositorySourceKind::LocalPath);
|
||||
assert_eq!(local.uri, "/runtime-only/missing/repository");
|
||||
|
||||
let file = validate_repository_source("file:///runtime-only/missing/repository").unwrap();
|
||||
assert_eq!(file.kind, RepositorySourceKind::File);
|
||||
|
||||
assert!(validate_repository_source("relative/repository").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let service = WorkspaceCatalogService::new(store.clone());
|
||||
let result = service
|
||||
.create_first_ownerless(WorkspaceCreateRequest {
|
||||
operation_key: "remote-create".to_string(),
|
||||
display_name: "Remote Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||
display_name: Some("Remote Repository".to_string()),
|
||||
default_ref: Some("main".to_string()),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let persisted = store
|
||||
.get_repository(
|
||||
&result.workspace.workspace_id,
|
||||
&result.repository.repository_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
|
||||
assert_eq!(persisted.source_revision, 1);
|
||||
assert!(persisted.source_fingerprint.starts_with("sha256:"));
|
||||
assert_eq!(
|
||||
persisted.observed_status,
|
||||
RepositoryObservedStatus::Unverified
|
||||
);
|
||||
let json = serde_json::to_value(&persisted).unwrap();
|
||||
assert!(json.get("source").is_some());
|
||||
assert!(json.get("auth_ref_kind").is_none());
|
||||
assert!(json.get("auth_ref_key").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,29 @@ export type WorkspaceCatalogRecord = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RepositorySourceKind =
|
||||
| "local_path"
|
||||
| "file"
|
||||
| "ssh"
|
||||
| "http"
|
||||
| "https"
|
||||
| "invalid";
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
uri: string;
|
||||
provider: string | null;
|
||||
source: {
|
||||
kind: RepositorySourceKind;
|
||||
uri: string;
|
||||
};
|
||||
default_ref: string | null;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||
|
||||
@@ -269,6 +269,14 @@ export type RepositorySummary = {
|
||||
display_name: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
source: {
|
||||
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
|
||||
uri: string;
|
||||
};
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at?: string | null;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git?: GitRepositorySummary | null;
|
||||
|
||||
@@ -155,8 +155,7 @@
|
||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||
<p>
|
||||
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
|
||||
not authority.
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={submitCreation}>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div>
|
||||
<h3>{data.repository.item.display_name}</h3>
|
||||
</div>
|
||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
@@ -27,6 +27,18 @@
|
||||
<dt>Provider</dt>
|
||||
<dd>{data.repository.item.provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source revision</dt>
|
||||
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Observed</dt>
|
||||
<dd>{data.repository.item.observed_at ?? 'not observed'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{data.repository.item.record_authority}</dd>
|
||||
|
||||
Reference in New Issue
Block a user