feat: add guarded workspace deletion workflow

This commit is contained in:
2026-09-06 07:22:33 +09:00
parent 668a9062b3
commit e3e9e83bc1
11 changed files with 1940 additions and 83 deletions
+153 -1
View File
@@ -540,6 +540,7 @@ pub struct WorkspacePermissionSummary {
pub manage_repositories: bool,
pub manage_secrets: bool,
pub manage_runtimes: bool,
pub delete_workspace: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -606,6 +607,108 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>,
}
/// Lifecycle state for one durable Workspace deletion operation.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionState {
Queued,
Running,
Blocked,
Failed,
Succeeded,
}
/// Stable category explaining why Workspace deletion cannot currently advance.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionBlockerKind {
LastAccessibleWorkspace,
RevisionConflict,
DirtyWorkdir,
WorkerRemovalBlocked,
WorkdirRemovalBlocked,
RetentionHold,
CleanupUnavailable,
}
/// One bounded, user-actionable blocker returned by preflight or execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionBlocker {
pub kind: WorkspaceDeletionBlockerKind,
pub resource_kind: Option<String>,
pub resource_key: Option<String>,
pub message: String,
}
/// Workspace-owned resources summarized before destructive confirmation.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionResourceCounts {
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workers: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workdirs: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub repositories: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub runtime_bindings: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub secrets: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub artifacts: u64,
}
/// Owner-only impact preview for deleting one Workspace.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionPreflightResponse {
pub workspace_id: String,
pub display_name: String,
/// Opaque persisted Workspace metadata revision used as a CAS fence.
pub expected_revision: String,
pub can_delete: bool,
pub force_delete_dirty_workdirs_available: bool,
pub resources: WorkspaceDeletionResourceCounts,
pub blockers: Vec<WorkspaceDeletionBlocker>,
}
/// Idempotent request to start or resume Workspace deletion.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionRequest {
pub operation_id: String,
pub expected_revision: String,
pub confirmation: String,
#[serde(default)]
pub force_delete_dirty_workdirs: bool,
}
/// Durable deletion operation projection used by request responses and polling.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionOperationResponse {
pub operation_id: String,
pub workspace_id: String,
pub display_name: String,
pub state: WorkspaceDeletionState,
pub force_delete_dirty_workdirs: bool,
pub resources: WorkspaceDeletionResourceCounts,
pub child_operation_ids: Vec<String>,
pub blockers: Vec<WorkspaceDeletionBlocker>,
pub failure_category: Option<String>,
pub created_at: String,
pub updated_at: String,
pub completed_at: Option<String>,
}
/// Read-only Profile catalog projected from one active Workspace config revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -2492,6 +2595,13 @@ pub fn catalog_typescript() -> String {
WorkspaceCreateResponse::decl(&config),
WorkspaceAuthConfig::decl(&config),
WorkspacePermissionSummary::decl(&config),
WorkspaceDeletionState::decl(&config),
WorkspaceDeletionBlockerKind::decl(&config),
WorkspaceDeletionBlocker::decl(&config),
WorkspaceDeletionResourceCounts::decl(&config),
WorkspaceDeletionPreflightResponse::decl(&config),
WorkspaceDeletionRequest::decl(&config),
WorkspaceDeletionOperationResponse::decl(&config),
DiagnosticSeverity::decl(&config),
Diagnostic::decl(&config),
WorkspaceExtensionPointState::decl(&config),
@@ -3145,6 +3255,47 @@ mod tests {
}
}
#[test]
fn workspace_deletion_wire_contract_is_closed_and_typed() {
let preflight = WorkspaceDeletionPreflightResponse {
workspace_id: "workspace-test".to_string(),
display_name: "Test".to_string(),
expected_revision: "revision-7".to_string(),
can_delete: true,
force_delete_dirty_workdirs_available: true,
resources: WorkspaceDeletionResourceCounts {
workers: 2,
workdirs: 1,
repositories: 1,
runtime_bindings: 1,
secrets: 0,
artifacts: 3,
},
blockers: Vec::new(),
};
let value = serde_json::to_value(&preflight).unwrap();
assert_eq!(
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(value.clone()).unwrap(),
preflight
);
let mut stale = value.as_object().unwrap().clone();
stale.insert("revision".to_string(), serde_json::json!(7));
assert!(
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(stale.into()).is_err()
);
assert!(
serde_json::from_value::<WorkspaceDeletionRequest>(serde_json::json!({
"operation_id": "delete-test",
"expected_revision": "revision-7",
"confirmation": "delete Test",
"force_delete_dirty_workdirs": false,
"workspace_id": "caller-controlled"
}))
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
@@ -3161,7 +3312,8 @@ mod tests {
"permissions": {
"manage_repositories": true,
"manage_secrets": true,
"manage_runtimes": true
"manage_runtimes": true,
"delete_workspace": true
},
"extension_points": {
"store": "sqlite",