workspace: type workdir cleanup status

This commit is contained in:
Keisuke Hirata 2026-07-13 21:57:20 +09:00
parent daa06f98fb
commit 59524679c1
No known key found for this signature in database
3 changed files with 133 additions and 26 deletions

View File

@ -1,8 +1,8 @@
---
title: 'Type Workdir cleanup plan status'
state: 'inprogress'
state: 'done'
created_at: '2026-07-13T12:45:21Z'
updated_at: '2026-07-13T12:46:05Z'
updated_at: '2026-07-13T12:56:55Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-13T12:46:05Z'

View File

@ -39,4 +39,38 @@ Ticket を `yoi ticket` が queued にしました。
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-13T12:56:55Z -->
## Implementation report
Typed Workdir cleanup plan status and cleanliness.
- Added `CleanupWorkdirFileStatus` and `CleanupWorkdirCleanliness` typed enums with snake_case JSON serialization.
- `CleanupWorkdirCandidate.file_status` and `.cleanliness` now use those enums instead of `String`.
- Runtime-observed `WorkingDirectoryStatusKind` is converted directly into cleanup status without string formatting.
- Registry string fields are parsed only at the DB/API boundary; unknown values become `Unknown`.
- Cleanup action policy now uses enum matches/predicates rather than string matching.
- `NotFound` remains represented as `not_found` over JSON and maps to `workdir_record_delete` internally.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -q`
- `cargo test -q -p yoi-workspace-server`
- `cargo test -q -p worker-runtime --features fs-store,ws-server`
- `cd web/workspace && deno task check && deno task test`
- `nix build .#yoi --no-link`
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-13T12:56:55Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---

View File

@ -591,6 +591,80 @@ pub enum CleanupTargetKind {
WorkdirRecordDelete,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CleanupWorkdirFileStatus {
Pending,
Present,
Active,
CleanupPending,
NotFound,
Corrupted,
Failed,
Unknown,
}
impl CleanupWorkdirFileStatus {
fn from_registry(value: &str) -> Self {
match value {
"pending" => Self::Pending,
"present" => Self::Present,
"active" => Self::Active,
"cleanup_pending" => Self::CleanupPending,
"not_found" => Self::NotFound,
"corrupted" => Self::Corrupted,
"failed" => Self::Failed,
"unknown" => Self::Unknown,
_ => Self::Unknown,
}
}
fn from_runtime(value: &WorkingDirectoryStatusKind) -> Self {
match value {
WorkingDirectoryStatusKind::Active => Self::Active,
WorkingDirectoryStatusKind::CleanupPending => Self::CleanupPending,
WorkingDirectoryStatusKind::Corrupted => Self::Corrupted,
WorkingDirectoryStatusKind::NotFound => Self::NotFound,
WorkingDirectoryStatusKind::Unknown => Self::Unknown,
}
}
fn is_record_only(self) -> bool {
matches!(self, Self::NotFound)
}
fn is_corrupted(self) -> bool {
matches!(self, Self::Corrupted)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CleanupWorkdirCleanliness {
Clean,
Dirty,
Unknown,
}
impl CleanupWorkdirCleanliness {
fn from_registry(value: &str) -> Self {
match value {
"clean" => Self::Clean,
"dirty" => Self::Dirty,
"unknown" => Self::Unknown,
_ => Self::Unknown,
}
}
fn from_runtime(value: Option<&str>) -> Self {
value.map(Self::from_registry).unwrap_or(Self::Unknown)
}
fn is_clean(self) -> bool {
matches!(self, Self::Clean)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CleanupWorkerCandidate {
pub target_id: String,
@ -620,8 +694,8 @@ pub struct CleanupWorkdirCandidate {
pub linked_running_worker_ids: Vec<String>,
pub running_linked: bool,
pub pinned_linked: bool,
pub file_status: String,
pub cleanliness: String,
pub file_status: CleanupWorkdirFileStatus,
pub cleanliness: CleanupWorkdirCleanliness,
pub estimated_reclaim_bytes: Option<u64>,
}
@ -1683,12 +1757,17 @@ fn build_runtime_cleanup_plan(
let running_linked = !linked_running_worker_ids.is_empty();
let observed_status = observed_workdirs
.get(record.workdir_id.as_str())
.map(|summary| workdir_status_kind_label(&summary.status).to_string());
let file_status = observed_status.unwrap_or_else(|| record.materialization_status.clone());
let cleanliness = record.cleanliness.clone();
let action = if matches!(file_status.as_str(), "missing" | "not_found") {
.map(|summary| CleanupWorkdirFileStatus::from_runtime(&summary.status));
let file_status = observed_status.unwrap_or_else(|| {
CleanupWorkdirFileStatus::from_registry(&record.materialization_status)
});
let cleanliness = observed_workdirs
.get(record.workdir_id.as_str())
.map(|summary| CleanupWorkdirCleanliness::from_runtime(summary.cleanliness.as_deref()))
.unwrap_or_else(|| CleanupWorkdirCleanliness::from_registry(&record.cleanliness));
let action = if file_status.is_record_only() {
CleanupTargetKind::WorkdirRecordDelete
} else if file_status == "corrupted" || cleanliness == "clean" {
} else if file_status.is_corrupted() || cleanliness.is_clean() {
CleanupTargetKind::WorkdirCleanCleanup
} else {
CleanupTargetKind::WorkdirDirtyDiscard
@ -1708,14 +1787,14 @@ 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(), "missing" | "not_found") {
} else if file_status.is_record_only() {
"Not-found Workdir record can be deleted from the Backend registry".to_string()
} else if file_status == "corrupted" {
} else if file_status.is_corrupted() {
"Corrupted Workdir can be deleted from Runtime storage and Backend registry"
.to_string()
} else if cleanliness == "dirty" {
} else if matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) {
"Dirty Workdir requires explicit discard confirmation before cleanup".to_string()
} else if cleanliness == "unknown" {
} else if matches!(cleanliness, CleanupWorkdirCleanliness::Unknown) {
"Workdir clean state is unknown; explicit discard confirmation is required"
.to_string()
} else {
@ -4287,16 +4366,6 @@ fn sync_runtime_workdir_observations(
Ok(response.diagnostics)
}
fn workdir_status_kind_label(status: &WorkingDirectoryStatusKind) -> &'static str {
match status {
WorkingDirectoryStatusKind::Active => "active",
WorkingDirectoryStatusKind::CleanupPending => "cleanup_pending",
WorkingDirectoryStatusKind::Corrupted => "corrupted",
WorkingDirectoryStatusKind::NotFound => "not_found",
WorkingDirectoryStatusKind::Unknown => "unknown",
}
}
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
if diagnostics
.iter()
@ -5743,7 +5812,7 @@ mod tests {
.iter()
.find(|candidate| candidate.workdir_id == workdir_id)
.expect("cleanup candidate");
assert_eq!(candidate.cleanliness, "clean");
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
assert!(candidate.reason.contains("clean"));
@ -5785,7 +5854,7 @@ mod tests {
.iter()
.find(|candidate| candidate.workdir_id == workdir_id)
.expect("cleanup candidate");
assert_eq!(candidate.cleanliness, "clean");
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
}
@ -5803,7 +5872,7 @@ mod tests {
.iter()
.find(|candidate| candidate.workdir_id == "verified-clean")
.expect("cleanup candidate");
assert_eq!(candidate.cleanliness, "clean");
assert_eq!(candidate.cleanliness, CleanupWorkdirCleanliness::Clean);
assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup);
}
@ -5836,6 +5905,10 @@ mod tests {
.iter()
.find(|candidate| candidate.workdir_id == "workdir-not-found")
.unwrap();
assert_eq!(
running_linked_workdir.file_status,
CleanupWorkdirFileStatus::NotFound
);
assert_eq!(
running_linked_workdir.action,
CleanupTargetKind::WorkdirRecordDelete