workspace: track workdir observation state
This commit is contained in:
parent
3009bb4ad9
commit
52cccc7f2f
|
|
@ -103,20 +103,11 @@ pub enum MaterializerKind {
|
||||||
LocalGitWorktree,
|
LocalGitWorktree,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum DirtyStatePolicy {
|
|
||||||
#[default]
|
|
||||||
CleanPointOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryRequest {
|
pub struct WorkingDirectoryRequest {
|
||||||
pub repository: WorkingDirectoryRepository,
|
pub repository: WorkingDirectoryRepository,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub materializer: MaterializerKind,
|
pub materializer: MaterializerKind,
|
||||||
#[serde(default)]
|
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
||||||
/// Backend can create canonical registry rows before materialization.
|
/// Backend can create canonical registry rows before materialization.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -136,6 +127,9 @@ pub enum WorkingDirectoryStatusKind {
|
||||||
Active,
|
Active,
|
||||||
Removed,
|
Removed,
|
||||||
CleanupPending,
|
CleanupPending,
|
||||||
|
Corrupted,
|
||||||
|
NotFound,
|
||||||
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -152,16 +146,15 @@ pub struct WorkingDirectorySummary {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub requested_selector: Option<String>,
|
pub requested_selector: Option<String>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_commit: Option<String>,
|
pub resolved_commit: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_tree: Option<String>,
|
pub resolved_tree: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cleanup_policy: Option<String>,
|
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cleanliness: Option<String>,
|
||||||
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
|
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
|
||||||
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
|
||||||
|
|
@ -866,8 +866,8 @@ mod tests {
|
||||||
|
|
||||||
use crate::Runtime as EmbeddedRuntime;
|
use crate::Runtime as EmbeddedRuntime;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector,
|
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, RepositorySelector,
|
||||||
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||||
};
|
};
|
||||||
use crate::execution::WorkerExecutionContext;
|
use crate::execution::WorkerExecutionContext;
|
||||||
use crate::identity::RuntimeId;
|
use crate::identity::RuntimeId;
|
||||||
|
|
@ -1114,7 +1114,6 @@ mod tests {
|
||||||
selector: Some(RepositorySelector::from("HEAD")),
|
selector: Some(RepositorySelector::from("HEAD")),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
|
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -23,7 +23,6 @@ pub struct WorkingDirectoryEvidence {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub resolved_tree: Option<String>,
|
pub resolved_tree: Option<String>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -31,10 +30,8 @@ pub struct WorkingDirectory {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
|
||||||
pub evidence: WorkingDirectoryEvidence,
|
pub evidence: WorkingDirectoryEvidence,
|
||||||
pub cleanup_target: WorkingDirectoryCleanupTarget,
|
pub cleanup_target: WorkingDirectoryCleanupTarget,
|
||||||
pub cleanup_policy: String,
|
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,12 +42,11 @@ impl WorkingDirectory {
|
||||||
repository_id: self.repository_id.clone(),
|
repository_id: self.repository_id.clone(),
|
||||||
requested_selector: self.evidence.requested_selector.clone(),
|
requested_selector: self.evidence.requested_selector.clone(),
|
||||||
materializer_kind: self.materializer_kind.clone(),
|
materializer_kind: self.materializer_kind.clone(),
|
||||||
dirty_state_policy: self.dirty_state_policy.clone(),
|
|
||||||
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
||||||
resolved_tree: self.evidence.resolved_tree.clone(),
|
resolved_tree: self.evidence.resolved_tree.clone(),
|
||||||
cleanup_target: Some(self.cleanup_target.clone()),
|
cleanup_target: Some(self.cleanup_target.clone()),
|
||||||
cleanup_policy: Some(self.cleanup_policy.clone()),
|
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
|
cleanliness: None,
|
||||||
management_kind: None,
|
management_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +79,19 @@ impl WorkingDirectoryBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> WorkingDirectoryStatus {
|
pub fn status(&self) -> WorkingDirectoryStatus {
|
||||||
WorkingDirectoryStatus {
|
let mut working_directory = self.working_directory.clone();
|
||||||
summary: self.working_directory.status_summary(),
|
if working_directory.status == WorkingDirectoryStatusKind::Active
|
||||||
|
&& !binding_paths_are_available(self)
|
||||||
|
{
|
||||||
|
working_directory.status = WorkingDirectoryStatusKind::Corrupted;
|
||||||
}
|
}
|
||||||
|
let mut summary = working_directory.status_summary();
|
||||||
|
summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active {
|
||||||
|
Some(binding_cleanliness(self))
|
||||||
|
} else {
|
||||||
|
Some("unknown".to_string())
|
||||||
|
};
|
||||||
|
WorkingDirectoryStatus { summary }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +157,27 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
||||||
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
|
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
|
||||||
|
let Ok(root) = binding.root.canonicalize() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !root.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(source_repository_path) = binding.source_repository_path.canonicalize() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
source_repository_path.is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
|
||||||
|
match git_stdout(binding.root(), ["status", "--porcelain"]) {
|
||||||
|
Ok(output) if output.is_empty() => "clean".to_string(),
|
||||||
|
Ok(_) => "dirty".to_string(),
|
||||||
|
Err(_) => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LocalGitWorktreeMaterializer {
|
pub struct LocalGitWorktreeMaterializer {
|
||||||
runtime_root: PathBuf,
|
runtime_root: PathBuf,
|
||||||
|
|
@ -177,6 +204,27 @@ impl LocalGitWorktreeMaterializer {
|
||||||
.join(working_directory_id)
|
.join(working_directory_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn corrupted_status(&self, working_directory_id: &str) -> WorkingDirectoryStatus {
|
||||||
|
WorkingDirectoryStatus {
|
||||||
|
summary: WorkingDirectorySummary {
|
||||||
|
working_directory_id: working_directory_id.to_string(),
|
||||||
|
repository_id: "unknown".to_string(),
|
||||||
|
requested_selector: None,
|
||||||
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
|
resolved_commit: None,
|
||||||
|
resolved_tree: None,
|
||||||
|
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
||||||
|
kind: "local_git_worktree".to_string(),
|
||||||
|
working_directory_id: working_directory_id.to_string(),
|
||||||
|
repository_id: "unknown".to_string(),
|
||||||
|
}),
|
||||||
|
status: WorkingDirectoryStatusKind::Corrupted,
|
||||||
|
cleanliness: Some("unknown".to_string()),
|
||||||
|
management_kind: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn write_record(
|
fn write_record(
|
||||||
&self,
|
&self,
|
||||||
binding: &WorkingDirectoryBinding,
|
binding: &WorkingDirectoryBinding,
|
||||||
|
|
@ -232,7 +280,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
&self,
|
&self,
|
||||||
working_directory_id: String,
|
working_directory_id: String,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
cleanup_policy: &str,
|
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(&working_directory_id)?;
|
validate_working_directory_id(&working_directory_id)?;
|
||||||
if request.materializer != MaterializerKind::LocalGitWorktree {
|
if request.materializer != MaterializerKind::LocalGitWorktree {
|
||||||
|
|
@ -250,12 +297,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
|
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
|
||||||
"working_directory_dirty_policy_unsupported",
|
|
||||||
"only clean_point_only dirty-state policy is supported in v0",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if is_remote_uri(&request.repository.uri) {
|
if is_remote_uri(&request.repository.uri) {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_remote_repository_unsupported",
|
"working_directory_remote_repository_unsupported",
|
||||||
|
|
@ -281,7 +322,7 @@ impl LocalGitWorktreeMaterializer {
|
||||||
if !status.trim().is_empty() {
|
if !status.trim().is_empty() {
|
||||||
return Err(WorkingDirectoryDiagnostic::new(
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
"working_directory_dirty_source_rejected",
|
"working_directory_dirty_source_rejected",
|
||||||
"clean_point_only working directory materialization rejects dirty source repository state",
|
"working directory materialization rejects dirty source repository state",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +381,6 @@ impl LocalGitWorktreeMaterializer {
|
||||||
id: working_directory_id.clone(),
|
id: working_directory_id.clone(),
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
evidence: WorkingDirectoryEvidence {
|
evidence: WorkingDirectoryEvidence {
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
requested_selector: request
|
requested_selector: request
|
||||||
|
|
@ -351,14 +391,12 @@ impl LocalGitWorktreeMaterializer {
|
||||||
resolved_commit,
|
resolved_commit,
|
||||||
resolved_tree,
|
resolved_tree,
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
},
|
},
|
||||||
cleanup_target: WorkingDirectoryCleanupTarget {
|
cleanup_target: WorkingDirectoryCleanupTarget {
|
||||||
kind: "git_worktree".to_string(),
|
kind: "git_worktree".to_string(),
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
repository_id: request.repository.id.clone(),
|
repository_id: request.repository.id.clone(),
|
||||||
},
|
},
|
||||||
cleanup_policy: cleanup_policy.to_string(),
|
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
status: WorkingDirectoryStatusKind::Active,
|
||||||
};
|
};
|
||||||
let binding = WorkingDirectoryBinding {
|
let binding = WorkingDirectoryBinding {
|
||||||
|
|
@ -380,11 +418,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
|
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(working_directory_id, request)
|
||||||
working_directory_id,
|
|
||||||
request,
|
|
||||||
"remove_on_worker_stop",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(
|
fn create(
|
||||||
|
|
@ -395,11 +429,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
.backend_workdir_id
|
.backend_workdir_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(working_directory_id, request)
|
||||||
working_directory_id,
|
|
||||||
request,
|
|
||||||
"manual_or_worker_stop",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_working_directory(
|
fn bind_working_directory(
|
||||||
|
|
@ -446,8 +476,9 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
if validate_working_directory_id(&working_directory_id).is_err() {
|
if validate_working_directory_id(&working_directory_id).is_err() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(status) = self.working_directory_status(&working_directory_id) {
|
match self.read_binding(&working_directory_id) {
|
||||||
statuses.push(status);
|
Ok(binding) => statuses.push(binding.status()),
|
||||||
|
Err(_) => statuses.push(self.corrupted_status(&working_directory_id)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statuses.sort_by(|left, right| {
|
statuses.sort_by(|left, right| {
|
||||||
|
|
@ -463,7 +494,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(working_directory_id)?;
|
validate_working_directory_id(working_directory_id)?;
|
||||||
Ok(self.read_binding(working_directory_id)?.status())
|
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||||
|
if !working_directory_root.exists() {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_not_found",
|
||||||
|
"working directory working_directory was not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match self.read_binding(working_directory_id) {
|
||||||
|
Ok(binding) => Ok(binding.status()),
|
||||||
|
Err(_) => Ok(self.corrupted_status(working_directory_id)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_working_directory(
|
fn cleanup_working_directory(
|
||||||
|
|
@ -471,6 +512,21 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
validate_working_directory_id(working_directory_id)?;
|
validate_working_directory_id(working_directory_id)?;
|
||||||
|
let status = self.working_directory_status(working_directory_id)?;
|
||||||
|
if status.summary.status == WorkingDirectoryStatusKind::Corrupted {
|
||||||
|
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||||
|
if working_directory_root.exists() {
|
||||||
|
fs::remove_dir_all(&working_directory_root).map_err(|_| {
|
||||||
|
WorkingDirectoryDiagnostic::new(
|
||||||
|
"working_directory_corrupted_cleanup_failed",
|
||||||
|
"failed to remove corrupted working directory; backend-private path details were omitted",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let mut summary = status.summary;
|
||||||
|
summary.status = WorkingDirectoryStatusKind::Removed;
|
||||||
|
return Ok(WorkingDirectoryStatus { summary });
|
||||||
|
}
|
||||||
let binding = self.read_binding(working_directory_id)?;
|
let binding = self.read_binding(working_directory_id)?;
|
||||||
self.cleanup(&binding)?;
|
self.cleanup(&binding)?;
|
||||||
self.working_directory_status(working_directory_id)
|
self.working_directory_status(working_directory_id)
|
||||||
|
|
@ -714,7 +770,6 @@ mod tests {
|
||||||
selector: Some(RepositorySelector::from("HEAD")),
|
selector: Some(RepositorySelector::from("HEAD")),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -746,10 +801,6 @@ mod tests {
|
||||||
binding.working_directory.materializer_kind,
|
binding.working_directory.materializer_kind,
|
||||||
MaterializerKind::LocalGitWorktree
|
MaterializerKind::LocalGitWorktree
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
binding.working_directory.dirty_state_policy,
|
|
||||||
DirtyStatePolicy::CleanPointOnly
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
binding
|
binding
|
||||||
.working_directory_root()
|
.working_directory_root()
|
||||||
|
|
@ -778,7 +829,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dirty_source_is_rejected_by_clean_point_only_policy() {
|
fn dirty_source_is_rejected_by_materialization() {
|
||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
||||||
let runtime_root = tempfile::tempdir().unwrap();
|
let runtime_root = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -789,7 +840,7 @@ mod tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(error.code, "working_directory_dirty_source_rejected");
|
assert_eq!(error.code, "working_directory_dirty_source_rejected");
|
||||||
assert!(error.message.contains("clean_point_only"));
|
assert!(error.message.contains("dirty source"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ use crate::store::{
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, DirtyStatePolicy, MaterializerKind, ProfileSelector,
|
ConfigBundleRef, MaterializerKind, ProfileSelector,
|
||||||
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim,
|
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim,
|
||||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind,
|
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
|
|
@ -773,28 +773,6 @@ pub struct WorkingDirectoryRepositoryOption {
|
||||||
pub default_selector: Option<String>,
|
pub default_selector: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct BrowserWorkingDirectoryCreatePolicy {
|
|
||||||
#[serde(default)]
|
|
||||||
pub dirty_state: BrowserWorkingDirectoryDirtyStatePolicy,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cleanup: BrowserWorkingDirectoryCleanupPolicy,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum BrowserWorkingDirectoryDirtyStatePolicy {
|
|
||||||
#[default]
|
|
||||||
CleanPointOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum BrowserWorkingDirectoryCleanupPolicy {
|
|
||||||
#[default]
|
|
||||||
ManualOrWorkerStop,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrowserWorkingDirectoryCreateRequest {
|
pub struct BrowserWorkingDirectoryCreateRequest {
|
||||||
|
|
@ -803,8 +781,6 @@ pub struct BrowserWorkingDirectoryCreateRequest {
|
||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub selector: Option<String>,
|
pub selector: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub policy: BrowserWorkingDirectoryCreatePolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -1709,14 +1685,8 @@ fn build_runtime_cleanup_plan(
|
||||||
.get(record.workdir_id.as_str())
|
.get(record.workdir_id.as_str())
|
||||||
.map(|summary| format!("{:?}", summary.status).to_lowercase());
|
.map(|summary| format!("{:?}", summary.status).to_lowercase());
|
||||||
let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone());
|
let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone());
|
||||||
let observed_without_clean_evidence =
|
let cleanliness = record.cleanliness.clone();
|
||||||
observed_workdirs.contains_key(record.workdir_id.as_str());
|
let action = if matches!(file_status.as_str(), "removed" | "missing" | "not_found") {
|
||||||
let cleanliness = if observed_without_clean_evidence && record.cleanliness != "dirty" {
|
|
||||||
"unknown".to_string()
|
|
||||||
} else {
|
|
||||||
record.cleanliness.clone()
|
|
||||||
};
|
|
||||||
let action = if matches!(file_status.as_str(), "removed" | "missing") {
|
|
||||||
CleanupTargetKind::WorkdirRecordDelete
|
CleanupTargetKind::WorkdirRecordDelete
|
||||||
} else if cleanliness == "clean" {
|
} else if cleanliness == "clean" {
|
||||||
CleanupTargetKind::WorkdirCleanCleanup
|
CleanupTargetKind::WorkdirCleanCleanup
|
||||||
|
|
@ -1738,8 +1708,8 @@ fn build_runtime_cleanup_plan(
|
||||||
repository_id: record.repository_id.clone(),
|
repository_id: record.repository_id.clone(),
|
||||||
reason: if blocking_reason.is_some() {
|
reason: if blocking_reason.is_some() {
|
||||||
"Workdir cleanup is blocked until linked Worker state is safe".to_string()
|
"Workdir cleanup is blocked until linked Worker state is safe".to_string()
|
||||||
} else if matches!(file_status.as_str(), "removed" | "missing") {
|
} else if matches!(file_status.as_str(), "removed" | "missing" | "not_found") {
|
||||||
"Removed or missing Workdir record can be deleted from the Backend registry"
|
"Removed or not-found Workdir record can be deleted from the Backend registry"
|
||||||
.to_string()
|
.to_string()
|
||||||
} else if cleanliness == "dirty" {
|
} else if cleanliness == "dirty" {
|
||||||
"Dirty Workdir requires explicit discard confirmation before cleanup".to_string()
|
"Dirty Workdir requires explicit discard confirmation before cleanup".to_string()
|
||||||
|
|
@ -2620,7 +2590,6 @@ fn working_directory_request_from_repository(
|
||||||
.or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))),
|
.or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4189,16 +4158,17 @@ fn sync_runtime_workdir_observations(
|
||||||
let mut observed = std::collections::BTreeSet::new();
|
let mut observed = std::collections::BTreeSet::new();
|
||||||
for status in &response.items {
|
for status in &response.items {
|
||||||
observed.insert(status.summary.working_directory_id.clone());
|
observed.insert(status.summary.working_directory_id.clone());
|
||||||
let management_kind = api
|
let existing = api.store.get_workdir_registry(
|
||||||
.store
|
&api.config.workspace_id,
|
||||||
.get_workdir_registry(
|
&status.summary.working_directory_id,
|
||||||
&api.config.workspace_id,
|
)?;
|
||||||
&status.summary.working_directory_id,
|
let management_kind = existing
|
||||||
)?
|
.as_ref()
|
||||||
.map(|existing| existing.management_kind)
|
.map(|existing| existing.management_kind.clone())
|
||||||
.unwrap_or_else(|| "runtime_unmanaged".to_string());
|
.unwrap_or_else(|| "runtime_unmanaged".to_string());
|
||||||
let record =
|
let mut record =
|
||||||
workdir_record_from_summary(api, runtime_id, &status.summary, management_kind.as_str());
|
workdir_record_from_summary(api, runtime_id, &status.summary, management_kind.as_str());
|
||||||
|
preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref());
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
}
|
}
|
||||||
for mut record in api
|
for mut record in api
|
||||||
|
|
@ -4207,15 +4177,51 @@ fn sync_runtime_workdir_observations(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|record| record.runtime_id == runtime_id && !observed.contains(&record.workdir_id))
|
.filter(|record| record.runtime_id == runtime_id && !observed.contains(&record.workdir_id))
|
||||||
{
|
{
|
||||||
if record.materialization_status == "present" {
|
match api
|
||||||
record.materialization_status = "missing".to_string();
|
.runtime
|
||||||
record.updated_at = now_registry_timestamp();
|
.working_directory(runtime_id, record.workdir_id.as_str())
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
{
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(status) = result.working_directory {
|
||||||
|
let management_kind = record.management_kind.clone();
|
||||||
|
let mut updated = workdir_record_from_summary(
|
||||||
|
api,
|
||||||
|
runtime_id,
|
||||||
|
&status.summary,
|
||||||
|
management_kind.as_str(),
|
||||||
|
);
|
||||||
|
preserve_workdir_identity_for_corrupted_summary(&mut updated, Some(&record));
|
||||||
|
api.store.upsert_workdir_registry(&updated)?;
|
||||||
|
} else {
|
||||||
|
record.materialization_status =
|
||||||
|
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
|
||||||
|
record.cleanliness = "unknown".to_string();
|
||||||
|
record.updated_at = now_registry_timestamp();
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
record.materialization_status = "unknown".to_string();
|
||||||
|
record.cleanliness = "unknown".to_string();
|
||||||
|
record.updated_at = now_registry_timestamp();
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(response.diagnostics)
|
Ok(response.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
||||||
|
if diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.code.contains("not_found"))
|
||||||
|
{
|
||||||
|
"not_found"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||||
|
|
@ -4262,7 +4268,9 @@ fn sync_linked_workdir_after_worker_stop(
|
||||||
.store
|
.store
|
||||||
.get_workdir_registry(&api.config.workspace_id, link.workdir_id.as_str())?
|
.get_workdir_registry(&api.config.workspace_id, link.workdir_id.as_str())?
|
||||||
{
|
{
|
||||||
record.materialization_status = "missing".to_string();
|
record.materialization_status =
|
||||||
|
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
|
||||||
|
record.cleanliness = "unknown".to_string();
|
||||||
record.updated_at = now_registry_timestamp();
|
record.updated_at = now_registry_timestamp();
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
}
|
}
|
||||||
|
|
@ -4288,19 +4296,49 @@ fn workdir_record_from_summary(
|
||||||
WorkingDirectoryStatusKind::Active => "present",
|
WorkingDirectoryStatusKind::Active => "present",
|
||||||
WorkingDirectoryStatusKind::Removed => "removed",
|
WorkingDirectoryStatusKind::Removed => "removed",
|
||||||
WorkingDirectoryStatusKind::CleanupPending => "pending",
|
WorkingDirectoryStatusKind::CleanupPending => "pending",
|
||||||
|
WorkingDirectoryStatusKind::Corrupted => "corrupted",
|
||||||
|
WorkingDirectoryStatusKind::NotFound => "not_found",
|
||||||
|
WorkingDirectoryStatusKind::Unknown => "unknown",
|
||||||
}
|
}
|
||||||
.to_string(),
|
.to_string(),
|
||||||
cleanliness: "unknown".to_string(),
|
cleanliness: summary
|
||||||
|
.cleanliness
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
management_kind: management_kind.to_string(),
|
management_kind: management_kind.to_string(),
|
||||||
created_at: timestamp.clone(),
|
created_at: timestamp.clone(),
|
||||||
updated_at: timestamp,
|
updated_at: timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preserve_workdir_identity_for_corrupted_summary(
|
||||||
|
record: &mut WorkdirRegistryRecord,
|
||||||
|
existing: Option<&WorkdirRegistryRecord>,
|
||||||
|
) {
|
||||||
|
if record.materialization_status != "corrupted" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(existing) = existing else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if record.repository_id == "unknown" {
|
||||||
|
record.repository_id = existing.repository_id.clone();
|
||||||
|
}
|
||||||
|
if record.selector.is_none() {
|
||||||
|
record.selector = existing.selector.clone();
|
||||||
|
}
|
||||||
|
if record.resolved_commit.is_none() {
|
||||||
|
record.resolved_commit = existing.resolved_commit.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirectorySummary {
|
fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirectorySummary {
|
||||||
let status = match record.materialization_status.as_str() {
|
let status = match record.materialization_status.as_str() {
|
||||||
"present" => WorkingDirectoryStatusKind::Active,
|
"present" => WorkingDirectoryStatusKind::Active,
|
||||||
"pending" => WorkingDirectoryStatusKind::CleanupPending,
|
"pending" => WorkingDirectoryStatusKind::CleanupPending,
|
||||||
|
"corrupted" => WorkingDirectoryStatusKind::Corrupted,
|
||||||
|
"not_found" | "missing" => WorkingDirectoryStatusKind::NotFound,
|
||||||
|
"unknown" => WorkingDirectoryStatusKind::Unknown,
|
||||||
_ => WorkingDirectoryStatusKind::Removed,
|
_ => WorkingDirectoryStatusKind::Removed,
|
||||||
};
|
};
|
||||||
WorkingDirectorySummary {
|
WorkingDirectorySummary {
|
||||||
|
|
@ -4308,7 +4346,6 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto
|
||||||
repository_id: record.repository_id.clone(),
|
repository_id: record.repository_id.clone(),
|
||||||
requested_selector: record.selector.clone(),
|
requested_selector: record.selector.clone(),
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
resolved_commit: record.resolved_commit.clone(),
|
resolved_commit: record.resolved_commit.clone(),
|
||||||
resolved_tree: None,
|
resolved_tree: None,
|
||||||
cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget {
|
cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget {
|
||||||
|
|
@ -4316,8 +4353,8 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto
|
||||||
working_directory_id: record.workdir_id.clone(),
|
working_directory_id: record.workdir_id.clone(),
|
||||||
repository_id: record.repository_id.clone(),
|
repository_id: record.repository_id.clone(),
|
||||||
}),
|
}),
|
||||||
cleanup_policy: Some("manual_or_worker_stop".to_string()),
|
|
||||||
status,
|
status,
|
||||||
|
cleanliness: Some(record.cleanliness.clone()),
|
||||||
management_kind: Some(record.management_kind.clone()),
|
management_kind: Some(record.management_kind.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4385,12 +4422,6 @@ fn working_directory_request_for_browser(
|
||||||
.selector
|
.selector
|
||||||
.or_else(|| repository.default_selector.clone())
|
.or_else(|| repository.default_selector.clone())
|
||||||
.filter(|selector| !selector.trim().is_empty());
|
.filter(|selector| !selector.trim().is_empty());
|
||||||
match request.policy.dirty_state {
|
|
||||||
BrowserWorkingDirectoryDirtyStatePolicy::CleanPointOnly => {}
|
|
||||||
}
|
|
||||||
match request.policy.cleanup {
|
|
||||||
BrowserWorkingDirectoryCleanupPolicy::ManualOrWorkerStop => {}
|
|
||||||
}
|
|
||||||
Ok(WorkingDirectoryRequest {
|
Ok(WorkingDirectoryRequest {
|
||||||
repository: WorkingDirectoryRepository {
|
repository: WorkingDirectoryRepository {
|
||||||
id: repository.id.clone(),
|
id: repository.id.clone(),
|
||||||
|
|
@ -4400,7 +4431,6 @@ fn working_directory_request_for_browser(
|
||||||
selector: selector.map(RuntimeRepositorySelector),
|
selector: selector.map(RuntimeRepositorySelector),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -4965,7 +4995,7 @@ mod tests {
|
||||||
assert_eq!(projected.state, "missing");
|
assert_eq!(projected.state, "missing");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected.working_directory.as_ref().unwrap().status,
|
projected.working_directory.as_ref().unwrap().status,
|
||||||
WorkingDirectoryStatusKind::Removed
|
WorkingDirectoryStatusKind::NotFound
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected
|
projected
|
||||||
|
|
@ -5574,7 +5604,6 @@ mod tests {
|
||||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
repository_id: TEST_REPOSITORY_ID.to_string(),
|
repository_id: TEST_REPOSITORY_ID.to_string(),
|
||||||
selector: Some("HEAD".to_string()),
|
selector: Some("HEAD".to_string()),
|
||||||
policy: BrowserWorkingDirectoryCreatePolicy::default(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_or_else(|err| panic!("create observed workdir: {}", err.error));
|
.unwrap_or_else(|err| panic!("create observed workdir: {}", err.error));
|
||||||
|
|
@ -5582,7 +5611,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn observed_workdir_without_verified_clean_evidence_requires_discard_confirmation() {
|
async fn observed_workdir_reports_verified_cleanliness_for_cleanup() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
init_clean_git_workspace(workspace.path());
|
||||||
let api = test_api(workspace.path()).await;
|
let api = test_api(workspace.path()).await;
|
||||||
|
|
@ -5596,7 +5625,7 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("workdir registry row");
|
.expect("workdir registry row");
|
||||||
assert_eq!(stored.materialization_status, "present");
|
assert_eq!(stored.materialization_status, "present");
|
||||||
assert_eq!(stored.cleanliness, "unknown");
|
assert_eq!(stored.cleanliness, "clean");
|
||||||
|
|
||||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
||||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
||||||
|
|
@ -5605,44 +5634,25 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
.find(|candidate| candidate.workdir_id == workdir_id)
|
||||||
.expect("cleanup candidate");
|
.expect("cleanup candidate");
|
||||||
assert_eq!(candidate.cleanliness, "unknown");
|
assert_eq!(candidate.cleanliness, "clean");
|
||||||
assert_eq!(candidate.action, CleanupTargetKind::WorkdirDirtyDiscard);
|
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
||||||
assert!(candidate.reason.contains("unknown"));
|
assert!(candidate.reason.contains("clean"));
|
||||||
|
|
||||||
let direct_cleanup = cleanup_working_directory_for_runtime(
|
|
||||||
api.clone(),
|
|
||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
|
||||||
workdir_id.as_str(),
|
|
||||||
);
|
|
||||||
assert!(direct_cleanup.is_err());
|
|
||||||
|
|
||||||
let target_id = candidate.target_id.clone();
|
let target_id = candidate.target_id.clone();
|
||||||
let missing_confirmation = ExecuteRuntimeCleanupRequest {
|
let request = ExecuteRuntimeCleanupRequest {
|
||||||
expected_plan_revision: plan.revision.clone(),
|
|
||||||
expected_plan_digest: plan.digest.clone(),
|
|
||||||
worker_target_ids: Vec::new(),
|
|
||||||
workdir_target_ids: vec![target_id.clone()],
|
|
||||||
confirm_dirty_discard_target_ids: Vec::new(),
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, missing_confirmation)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let confirmed = ExecuteRuntimeCleanupRequest {
|
|
||||||
expected_plan_revision: plan.revision,
|
expected_plan_revision: plan.revision,
|
||||||
expected_plan_digest: plan.digest,
|
expected_plan_digest: plan.digest,
|
||||||
worker_target_ids: Vec::new(),
|
worker_target_ids: Vec::new(),
|
||||||
workdir_target_ids: vec![target_id.clone()],
|
workdir_target_ids: vec![target_id],
|
||||||
confirm_dirty_discard_target_ids: vec![target_id],
|
confirm_dirty_discard_target_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
let response = execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, confirmed)
|
let response = execute_runtime_cleanup(&api, EMBEDDED_WORKER_RUNTIME_ID, request)
|
||||||
.unwrap_or_else(|err| panic!("cleanup execution: {}", err.error));
|
.unwrap_or_else(|err| panic!("cleanup execution: {}", err.error));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.results[0].action,
|
response.results[0].action,
|
||||||
CleanupTargetKind::WorkdirDirtyDiscard
|
CleanupTargetKind::WorkdirCleanCleanup
|
||||||
);
|
);
|
||||||
assert_eq!(response.results[0].status, "discarded");
|
assert_eq!(response.results[0].status, "cleaned");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -5666,8 +5676,8 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.workdir_id == workdir_id)
|
.find(|candidate| candidate.workdir_id == workdir_id)
|
||||||
.expect("cleanup candidate");
|
.expect("cleanup candidate");
|
||||||
assert_eq!(candidate.cleanliness, "unknown");
|
assert_eq!(candidate.cleanliness, "clean");
|
||||||
assert_ne!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -6041,7 +6051,6 @@ mod tests {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"repository_id": TEST_REPOSITORY_ID,
|
"repository_id": TEST_REPOSITORY_ID,
|
||||||
"selector": "HEAD",
|
"selector": "HEAD",
|
||||||
"policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -6066,21 +6075,6 @@ mod tests {
|
||||||
let detail = get_json(app.clone(), &detail_path).await;
|
let detail = get_json(app.clone(), &detail_path).await;
|
||||||
assert_eq!(detail["item"]["working_directory_id"], working_directory_id);
|
assert_eq!(detail["item"]["working_directory_id"], working_directory_id);
|
||||||
|
|
||||||
let direct_cleanup = request_json(
|
|
||||||
app.clone(),
|
|
||||||
"DELETE",
|
|
||||||
&detail_path,
|
|
||||||
None,
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(
|
|
||||||
direct_cleanup["message"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.contains("workspace_cleanup_dirty_confirmation_required")
|
|
||||||
);
|
|
||||||
|
|
||||||
let cleanup_plan_path = format!(
|
let cleanup_plan_path = format!(
|
||||||
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan"
|
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan"
|
||||||
);
|
);
|
||||||
|
|
@ -6109,7 +6103,7 @@ mod tests {
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(removed["results"][0]["status"], "discarded");
|
assert_eq!(removed["results"][0]["status"], "cleaned");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -6124,7 +6118,6 @@ mod tests {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"repository_id": TEST_REPOSITORY_ID,
|
"repository_id": TEST_REPOSITORY_ID,
|
||||||
"selector": "HEAD",
|
"selector": "HEAD",
|
||||||
"policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "use composite worker registry keys",
|
name: "use composite worker registry keys",
|
||||||
apply: use_composite_worker_registry_keys,
|
apply: use_composite_worker_registry_keys,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 6,
|
||||||
|
name: "add workdir runtime observation states",
|
||||||
|
apply: add_workdir_runtime_observation_states,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
|
|
@ -622,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
|
||||||
repository_id TEXT NOT NULL,
|
repository_id TEXT NOT NULL,
|
||||||
selector TEXT,
|
selector TEXT,
|
||||||
resolved_commit TEXT,
|
resolved_commit TEXT,
|
||||||
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'missing', 'removed', 'failed')),
|
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'removed', 'failed')),
|
||||||
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
||||||
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
|
@ -927,6 +932,49 @@ fn use_composite_worker_registry_keys(conn: &Connection) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
|
||||||
|
if !table_exists(conn, "workdir_registry")? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE workdir_registry_v6 (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
workdir_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
repository_id TEXT NOT NULL,
|
||||||
|
selector TEXT,
|
||||||
|
resolved_commit TEXT,
|
||||||
|
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'removed', 'failed')),
|
||||||
|
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
||||||
|
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, workdir_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT OR REPLACE INTO workdir_registry_v6 (
|
||||||
|
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
|
||||||
|
materialization_status, cleanliness, management_kind, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
|
||||||
|
CASE materialization_status
|
||||||
|
WHEN 'missing' THEN 'not_found'
|
||||||
|
ELSE materialization_status
|
||||||
|
END,
|
||||||
|
cleanliness, management_kind, created_at, updated_at
|
||||||
|
FROM workdir_registry;
|
||||||
|
DROP TABLE workdir_registry;
|
||||||
|
ALTER TABLE workdir_registry_v6 RENAME TO workdir_registry;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
|
||||||
|
ON workdir_registry(workspace_id, updated_at DESC);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
|
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -1150,7 +1198,7 @@ mod tests {
|
||||||
let db = dir.path().join("control-plane.sqlite");
|
let db = dir.path().join("control-plane.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 5);
|
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||||
|
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
|
|
@ -1162,7 +1210,7 @@ mod tests {
|
||||||
store.upsert_workspace(&record).await.unwrap();
|
store.upsert_workspace(&record).await.unwrap();
|
||||||
|
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
assert_eq!(reopened.schema_version().await.unwrap(), 5);
|
assert_eq!(reopened.schema_version().await.unwrap(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
|
|
@ -1371,7 +1419,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||||
assert_eq!(store.schema_version().await.unwrap(), 5);
|
assert_eq!(store.schema_version().await.unwrap(), 6);
|
||||||
|
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
|
|
|
||||||
|
|
@ -122,12 +122,11 @@ export type WorkingDirectorySummary = {
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
requested_selector?: string | null;
|
requested_selector?: string | null;
|
||||||
materializer_kind: string;
|
materializer_kind: string;
|
||||||
dirty_state_policy: string;
|
|
||||||
resolved_commit: string;
|
resolved_commit: string;
|
||||||
resolved_tree?: string | null;
|
resolved_tree?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
cleanliness?: string | null;
|
||||||
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
|
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
|
||||||
cleanup_policy: string;
|
|
||||||
cleanup_target: {
|
cleanup_target: {
|
||||||
kind: string;
|
kind: string;
|
||||||
working_directory_id: string;
|
working_directory_id: string;
|
||||||
|
|
@ -219,10 +218,6 @@ export type BrowserWorkingDirectoryCreateRequest = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
repository_id: string;
|
repository_id: string;
|
||||||
selector?: string | null;
|
selector?: string | null;
|
||||||
policy?: {
|
|
||||||
dirty_state?: "clean_point_only";
|
|
||||||
cleanup?: "manual_or_worker_stop";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkerLaunchOptionsResponse = {
|
export type WorkerLaunchOptionsResponse = {
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,8 @@ const options: WorkerLaunchOptionsResponse = {
|
||||||
repository_id: "repo",
|
repository_id: "repo",
|
||||||
requested_selector: "HEAD",
|
requested_selector: "HEAD",
|
||||||
materializer_kind: "local_git_worktree",
|
materializer_kind: "local_git_worktree",
|
||||||
dirty_state_policy: "clean_point_only",
|
|
||||||
resolved_commit: "0123456789abcdef",
|
resolved_commit: "0123456789abcdef",
|
||||||
status: "active",
|
status: "active",
|
||||||
cleanup_policy: "manual_or_worker_stop",
|
|
||||||
cleanup_target: {
|
cleanup_target: {
|
||||||
kind: "git_worktree",
|
kind: "git_worktree",
|
||||||
working_directory_id: "wd-1-repo",
|
working_directory_id: "wd-1-repo",
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
<th>Selector</th>
|
<th>Selector</th>
|
||||||
<th>Commit</th>
|
<th>Commit</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Policy</th>
|
<th>Cleanliness</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -121,10 +121,7 @@
|
||||||
<td>{selectorLabel(workdir)}</td>
|
<td>{selectorLabel(workdir)}</td>
|
||||||
<td><code>{commitLabel(workdir)}</code></td>
|
<td><code>{commitLabel(workdir)}</code></td>
|
||||||
<td>{workdir.status}</td>
|
<td>{workdir.status}</td>
|
||||||
<td>
|
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
||||||
<span>{workdir.dirty_state_policy}</span>
|
|
||||||
<small>{workdir.cleanup_policy}</small>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{#if cleanup}
|
{#if cleanup}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user