diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index a42dbc70..178bdccd 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -103,20 +103,11 @@ pub enum MaterializerKind { 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)] pub struct WorkingDirectoryRequest { pub repository: WorkingDirectoryRepository, #[serde(default)] pub materializer: MaterializerKind, - #[serde(default)] - pub dirty_state_policy: DirtyStatePolicy, /// Backend-assigned stable Workdir id. Runtimes use this when present so the /// Backend can create canonical registry rows before materialization. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -136,6 +127,9 @@ pub enum WorkingDirectoryStatusKind { Active, Removed, CleanupPending, + Corrupted, + NotFound, + Unknown, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -152,16 +146,15 @@ pub struct WorkingDirectorySummary { #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_selector: Option, pub materializer_kind: MaterializerKind, - pub dirty_state_policy: DirtyStatePolicy, #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_commit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_tree: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cleanup_target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cleanup_policy: Option, pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, /// Backend projection metadata. Runtimes leave this absent; Workspace Browser /// APIs fill it with `backend_managed` or `runtime_unmanaged`. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d7807c1e..bd914eaa 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -866,8 +866,8 @@ mod tests { use crate::Runtime as EmbeddedRuntime; use crate::catalog::{ - ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector, - RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest, + ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, RepositorySelector, + WorkingDirectoryRepository, WorkingDirectoryRequest, }; use crate::execution::WorkerExecutionContext; use crate::identity::RuntimeId; @@ -1114,7 +1114,6 @@ mod tests { selector: Some(RepositorySelector::from("HEAD")), }, materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, backend_workdir_id: None, } } diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index d4858f40..fc6c77e9 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -1,6 +1,6 @@ use crate::catalog::{ - DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest, - WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary, + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkingDirectoryStatusKind, WorkingDirectorySummary, }; use crate::identity::WorkerRef; use serde::{Deserialize, Serialize}; @@ -23,7 +23,6 @@ pub struct WorkingDirectoryEvidence { #[serde(default, skip_serializing_if = "Option::is_none")] pub resolved_tree: Option, pub materializer_kind: MaterializerKind, - pub dirty_state_policy: DirtyStatePolicy, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -31,10 +30,8 @@ pub struct WorkingDirectory { pub id: String, pub repository_id: String, pub materializer_kind: MaterializerKind, - pub dirty_state_policy: DirtyStatePolicy, pub evidence: WorkingDirectoryEvidence, pub cleanup_target: WorkingDirectoryCleanupTarget, - pub cleanup_policy: String, pub status: WorkingDirectoryStatusKind, } @@ -45,12 +42,11 @@ impl WorkingDirectory { repository_id: self.repository_id.clone(), requested_selector: self.evidence.requested_selector.clone(), materializer_kind: self.materializer_kind.clone(), - dirty_state_policy: self.dirty_state_policy.clone(), resolved_commit: Some(self.evidence.resolved_commit.clone()), resolved_tree: self.evidence.resolved_tree.clone(), cleanup_target: Some(self.cleanup_target.clone()), - cleanup_policy: Some(self.cleanup_policy.clone()), status: self.status.clone(), + cleanliness: None, management_kind: None, } } @@ -83,9 +79,19 @@ impl WorkingDirectoryBinding { } pub fn status(&self) -> WorkingDirectoryStatus { - WorkingDirectoryStatus { - summary: self.working_directory.status_summary(), + let mut working_directory = self.working_directory.clone(); + 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 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)] pub struct LocalGitWorktreeMaterializer { runtime_root: PathBuf, @@ -177,6 +204,27 @@ impl LocalGitWorktreeMaterializer { .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( &self, binding: &WorkingDirectoryBinding, @@ -232,7 +280,6 @@ impl LocalGitWorktreeMaterializer { &self, working_directory_id: String, request: &WorkingDirectoryRequest, - cleanup_policy: &str, ) -> Result { validate_working_directory_id(&working_directory_id)?; 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) { return Err(WorkingDirectoryDiagnostic::new( "working_directory_remote_repository_unsupported", @@ -281,7 +322,7 @@ impl LocalGitWorktreeMaterializer { if !status.trim().is_empty() { return Err(WorkingDirectoryDiagnostic::new( "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(), repository_id: request.repository.id.clone(), materializer_kind: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, evidence: WorkingDirectoryEvidence { repository_id: request.repository.id.clone(), requested_selector: request @@ -351,14 +391,12 @@ impl LocalGitWorktreeMaterializer { resolved_commit, resolved_tree, materializer_kind: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, }, cleanup_target: WorkingDirectoryCleanupTarget { kind: "git_worktree".to_string(), working_directory_id, repository_id: request.repository.id.clone(), }, - cleanup_policy: cleanup_policy.to_string(), status: WorkingDirectoryStatusKind::Active, }; let binding = WorkingDirectoryBinding { @@ -380,11 +418,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { request: &WorkingDirectoryRequest, ) -> Result { let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id); - self.materialize_with_working_directory_id( - working_directory_id, - request, - "remove_on_worker_stop", - ) + self.materialize_with_working_directory_id(working_directory_id, request) } fn create( @@ -395,11 +429,7 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { .backend_workdir_id .clone() .unwrap_or_else(|| next_working_directory_id(&request.repository.id)); - self.materialize_with_working_directory_id( - working_directory_id, - request, - "manual_or_worker_stop", - ) + self.materialize_with_working_directory_id(working_directory_id, request) } fn bind_working_directory( @@ -446,8 +476,9 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { if validate_working_directory_id(&working_directory_id).is_err() { continue; } - if let Ok(status) = self.working_directory_status(&working_directory_id) { - statuses.push(status); + match self.read_binding(&working_directory_id) { + Ok(binding) => statuses.push(binding.status()), + Err(_) => statuses.push(self.corrupted_status(&working_directory_id)), } } statuses.sort_by(|left, right| { @@ -463,7 +494,17 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { working_directory_id: &str, ) -> Result { 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( @@ -471,6 +512,21 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer { working_directory_id: &str, ) -> Result { 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)?; self.cleanup(&binding)?; self.working_directory_status(working_directory_id) @@ -714,7 +770,6 @@ mod tests { selector: Some(RepositorySelector::from("HEAD")), }, materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, backend_workdir_id: None, } } @@ -746,10 +801,6 @@ mod tests { binding.working_directory.materializer_kind, MaterializerKind::LocalGitWorktree ); - assert_eq!( - binding.working_directory.dirty_state_policy, - DirtyStatePolicy::CleanPointOnly - ); assert!( binding .working_directory_root() @@ -778,7 +829,7 @@ mod tests { } #[test] - fn dirty_source_is_rejected_by_clean_point_only_policy() { + fn dirty_source_is_rejected_by_materialization() { let repo = create_clean_repo(); fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap(); let runtime_root = tempfile::tempdir().unwrap(); @@ -789,7 +840,7 @@ mod tests { .unwrap_err(); assert_eq!(error.code, "working_directory_dirty_source_rejected"); - assert!(error.message.contains("clean_point_only")); + assert!(error.message.contains("dirty source")); } #[test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2881448a..81ab6380 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -58,7 +58,7 @@ use crate::store::{ }; use crate::{Error, Result}; use worker_runtime::catalog::{ - ConfigBundleRef, DirtyStatePolicy, MaterializerKind, ProfileSelector, + ConfigBundleRef, MaterializerKind, ProfileSelector, RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind, WorkingDirectorySummary, @@ -773,28 +773,6 @@ pub struct WorkingDirectoryRepositoryOption { pub default_selector: Option, } -#[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)] #[serde(deny_unknown_fields)] pub struct BrowserWorkingDirectoryCreateRequest { @@ -803,8 +781,6 @@ pub struct BrowserWorkingDirectoryCreateRequest { pub repository_id: String, #[serde(default)] pub selector: Option, - #[serde(default)] - pub policy: BrowserWorkingDirectoryCreatePolicy, } #[derive(Debug, Serialize, Deserialize)] @@ -1709,14 +1685,8 @@ fn build_runtime_cleanup_plan( .get(record.workdir_id.as_str()) .map(|summary| format!("{:?}", summary.status).to_lowercase()); let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone()); - let observed_without_clean_evidence = - observed_workdirs.contains_key(record.workdir_id.as_str()); - 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") { + let cleanliness = record.cleanliness.clone(); + let action = if matches!(file_status.as_str(), "removed" | "missing" | "not_found") { CleanupTargetKind::WorkdirRecordDelete } else if cleanliness == "clean" { CleanupTargetKind::WorkdirCleanCleanup @@ -1738,8 +1708,8 @@ fn build_runtime_cleanup_plan( repository_id: record.repository_id.clone(), reason: if blocking_reason.is_some() { "Workdir cleanup is blocked until linked Worker state is safe".to_string() - } else if matches!(file_status.as_str(), "removed" | "missing") { - "Removed or missing Workdir record can be deleted from the Backend registry" + } else if matches!(file_status.as_str(), "removed" | "missing" | "not_found") { + "Removed or not-found Workdir record can be deleted from the Backend registry" .to_string() } else if cleanliness == "dirty" { "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"))), }, materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, backend_workdir_id: None, } } @@ -4189,16 +4158,17 @@ fn sync_runtime_workdir_observations( let mut observed = std::collections::BTreeSet::new(); for status in &response.items { observed.insert(status.summary.working_directory_id.clone()); - let management_kind = api - .store - .get_workdir_registry( - &api.config.workspace_id, - &status.summary.working_directory_id, - )? - .map(|existing| existing.management_kind) + let existing = api.store.get_workdir_registry( + &api.config.workspace_id, + &status.summary.working_directory_id, + )?; + let management_kind = existing + .as_ref() + .map(|existing| existing.management_kind.clone()) .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()); + preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref()); api.store.upsert_workdir_registry(&record)?; } for mut record in api @@ -4207,15 +4177,51 @@ fn sync_runtime_workdir_observations( .into_iter() .filter(|record| record.runtime_id == runtime_id && !observed.contains(&record.workdir_id)) { - if record.materialization_status == "present" { - record.materialization_status = "missing".to_string(); - record.updated_at = now_registry_timestamp(); - api.store.upsert_workdir_registry(&record)?; + match api + .runtime + .working_directory(runtime_id, record.workdir_id.as_str()) + { + 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) } +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 { let mut diagnostics = Vec::new(); let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200)); @@ -4262,7 +4268,9 @@ fn sync_linked_workdir_after_worker_stop( .store .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(); api.store.upsert_workdir_registry(&record)?; } @@ -4288,19 +4296,49 @@ fn workdir_record_from_summary( WorkingDirectoryStatusKind::Active => "present", WorkingDirectoryStatusKind::Removed => "removed", WorkingDirectoryStatusKind::CleanupPending => "pending", + WorkingDirectoryStatusKind::Corrupted => "corrupted", + WorkingDirectoryStatusKind::NotFound => "not_found", + WorkingDirectoryStatusKind::Unknown => "unknown", } .to_string(), - cleanliness: "unknown".to_string(), + cleanliness: summary + .cleanliness + .clone() + .unwrap_or_else(|| "unknown".to_string()), management_kind: management_kind.to_string(), created_at: timestamp.clone(), 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 { let status = match record.materialization_status.as_str() { "present" => WorkingDirectoryStatusKind::Active, "pending" => WorkingDirectoryStatusKind::CleanupPending, + "corrupted" => WorkingDirectoryStatusKind::Corrupted, + "not_found" | "missing" => WorkingDirectoryStatusKind::NotFound, + "unknown" => WorkingDirectoryStatusKind::Unknown, _ => WorkingDirectoryStatusKind::Removed, }; WorkingDirectorySummary { @@ -4308,7 +4346,6 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto repository_id: record.repository_id.clone(), requested_selector: record.selector.clone(), materializer_kind: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, resolved_commit: record.resolved_commit.clone(), resolved_tree: None, 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(), repository_id: record.repository_id.clone(), }), - cleanup_policy: Some("manual_or_worker_stop".to_string()), status, + cleanliness: Some(record.cleanliness.clone()), management_kind: Some(record.management_kind.clone()), } } @@ -4385,12 +4422,6 @@ fn working_directory_request_for_browser( .selector .or_else(|| repository.default_selector.clone()) .filter(|selector| !selector.trim().is_empty()); - match request.policy.dirty_state { - BrowserWorkingDirectoryDirtyStatePolicy::CleanPointOnly => {} - } - match request.policy.cleanup { - BrowserWorkingDirectoryCleanupPolicy::ManualOrWorkerStop => {} - } Ok(WorkingDirectoryRequest { repository: WorkingDirectoryRepository { id: repository.id.clone(), @@ -4400,7 +4431,6 @@ fn working_directory_request_for_browser( selector: selector.map(RuntimeRepositorySelector), }, materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, backend_workdir_id: None, }) } @@ -4965,7 +4995,7 @@ mod tests { assert_eq!(projected.state, "missing"); assert_eq!( projected.working_directory.as_ref().unwrap().status, - WorkingDirectoryStatusKind::Removed + WorkingDirectoryStatusKind::NotFound ); assert_eq!( projected @@ -5574,7 +5604,6 @@ mod tests { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), repository_id: TEST_REPOSITORY_ID.to_string(), selector: Some("HEAD".to_string()), - policy: BrowserWorkingDirectoryCreatePolicy::default(), }, ) .unwrap_or_else(|err| panic!("create observed workdir: {}", err.error)); @@ -5582,7 +5611,7 @@ mod tests { } #[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(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -5596,7 +5625,7 @@ mod tests { .unwrap() .expect("workdir registry row"); 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) .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); @@ -5605,44 +5634,25 @@ mod tests { .iter() .find(|candidate| candidate.workdir_id == workdir_id) .expect("cleanup candidate"); - assert_eq!(candidate.cleanliness, "unknown"); - assert_eq!(candidate.action, CleanupTargetKind::WorkdirDirtyDiscard); - assert!(candidate.reason.contains("unknown")); - - let direct_cleanup = cleanup_working_directory_for_runtime( - api.clone(), - EMBEDDED_WORKER_RUNTIME_ID, - workdir_id.as_str(), - ); - assert!(direct_cleanup.is_err()); + assert_eq!(candidate.cleanliness, "clean"); + assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); + assert!(candidate.reason.contains("clean")); let target_id = candidate.target_id.clone(); - let missing_confirmation = 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 { + let request = ExecuteRuntimeCleanupRequest { expected_plan_revision: plan.revision, expected_plan_digest: plan.digest, worker_target_ids: Vec::new(), - workdir_target_ids: vec![target_id.clone()], - confirm_dirty_discard_target_ids: vec![target_id], + workdir_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)); assert_eq!( response.results[0].action, - CleanupTargetKind::WorkdirDirtyDiscard + CleanupTargetKind::WorkdirCleanCleanup ); - assert_eq!(response.results[0].status, "discarded"); + assert_eq!(response.results[0].status, "cleaned"); } #[tokio::test] @@ -5666,8 +5676,8 @@ mod tests { .iter() .find(|candidate| candidate.workdir_id == workdir_id) .expect("cleanup candidate"); - assert_eq!(candidate.cleanliness, "unknown"); - assert_ne!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); + assert_eq!(candidate.cleanliness, "clean"); + assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); } #[tokio::test] @@ -6041,7 +6051,6 @@ mod tests { serde_json::json!({ "repository_id": TEST_REPOSITORY_ID, "selector": "HEAD", - "policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" } }), ) .await; @@ -6066,21 +6075,6 @@ mod tests { let detail = get_json(app.clone(), &detail_path).await; 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!( "/api/w/{TEST_WORKSPACE_ID}/runtimes/{EMBEDDED_WORKER_RUNTIME_ID}/cleanup-plan" ); @@ -6109,7 +6103,7 @@ mod tests { StatusCode::OK, ) .await; - assert_eq!(removed["results"][0]["status"], "discarded"); + assert_eq!(removed["results"][0]["status"], "cleaned"); } #[tokio::test] @@ -6124,7 +6118,6 @@ mod tests { serde_json::json!({ "repository_id": TEST_REPOSITORY_ID, "selector": "HEAD", - "policy": { "dirty_state": "clean_point_only", "cleanup": "manual_or_worker_stop" } }), ) .await; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 2d36031a..9a16190a 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -42,6 +42,11 @@ const MIGRATIONS: &[Migration] = &[ name: "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 { @@ -622,7 +627,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry ( repository_id TEXT NOT NULL, selector 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')), management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')), created_at TEXT NOT NULL, @@ -927,6 +932,49 @@ fn use_composite_worker_registry_keys(conn: &Connection) -> Result<()> { 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<()> { conn.execute_batch( r#" @@ -1150,7 +1198,7 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); 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 { workspace_id: "local-dev".to_string(), @@ -1162,7 +1210,7 @@ mod tests { store.upsert_workspace(&record).await.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!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -1371,7 +1419,7 @@ mod tests { .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 .with_conn(|conn| { diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 3f34af1e..330ab497 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -122,12 +122,11 @@ export type WorkingDirectorySummary = { repository_id: string; requested_selector?: string | null; materializer_kind: string; - dirty_state_policy: string; resolved_commit: string; resolved_tree?: string | null; status: string; + cleanliness?: string | null; management_kind?: "backend_managed" | "runtime_unmanaged" | string | null; - cleanup_policy: string; cleanup_target: { kind: string; working_directory_id: string; @@ -219,10 +218,6 @@ export type BrowserWorkingDirectoryCreateRequest = { runtime_id: string; repository_id: string; selector?: string | null; - policy?: { - dirty_state?: "clean_point_only"; - cleanup?: "manual_or_worker_stop"; - }; }; export type WorkerLaunchOptionsResponse = { diff --git a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts index 116cb8a2..3e334d9f 100644 --- a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts @@ -51,10 +51,8 @@ const options: WorkerLaunchOptionsResponse = { repository_id: "repo", requested_selector: "HEAD", materializer_kind: "local_git_worktree", - dirty_state_policy: "clean_point_only", resolved_commit: "0123456789abcdef", status: "active", - cleanup_policy: "manual_or_worker_stop", cleanup_target: { kind: "git_worktree", working_directory_id: "wd-1-repo", diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte index a3741961..b15503b4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte @@ -108,7 +108,7 @@ Selector Commit Status - Policy + Cleanliness Action @@ -121,10 +121,7 @@ {selectorLabel(workdir)} {commitLabel(workdir)} {workdir.status} - - {workdir.dirty_state_policy} - {workdir.cleanup_policy} - + {workdir.cleanliness ?? 'unknown'} {#if cleanup}