From 4337f415f91d43e6678307624f130cea935868b0 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 9 Oct 2026 16:26:01 +0900 Subject: [PATCH 1/6] fix: revalidate durable Workdir removal on ordinary retry --- crates/workspace-server/src/server.rs | 417 ++++++++++++++++-- .../workspace-server/src/workdir_removal.rs | 4 +- docs/development/workdir-removal.md | 53 +++ 3 files changed, 441 insertions(+), 33 deletions(-) create mode 100644 docs/development/workdir-removal.md diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bac464eda..5bc654a2a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -11919,10 +11919,6 @@ fn cleanup_workdir_cleanliness_from_runtime(value: Option<&str>) -> CleanupWorkd .unwrap_or(CleanupWorkdirCleanliness::Unknown) } -fn cleanup_workdir_cleanliness_is_clean(value: CleanupWorkdirCleanliness) -> bool { - matches!(value, CleanupWorkdirCleanliness::Clean) -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, @@ -24531,6 +24527,73 @@ fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static st } } +// Only bounded classifications cross the public removal boundary. Provider +// messages may contain OS paths or credentials and are never persisted here. +fn workdir_cleanup_failure_category( + result: &crate::hosts::RuntimeWorkingDirectoryResult, +) -> &'static str { + for diagnostic in &result.diagnostics { + let category = match diagnostic.code.as_str() { + "working_directory_unsupported" => "unsupported_target", + "working_directory_cleanup_mount_present" => "mount_present", + "working_directory_cleanup_mount_check_unavailable" => "mount_check_unavailable", + "working_directory_cleanup_permission_denied" => "permission_denied", + "working_directory_cleanup_resource_busy" => "resource_busy", + "working_directory_cleanup_storage_unavailable" => "storage_unavailable", + "working_directory_cleanup_ownership_unknown" => "ownership_unknown", + "working_directory_cleanup_changes_present" => "changes_present", + "working_directory_cleanup_changes_unknown" => "changes_unknown", + "working_directory_cleanup_target_invalid" + | "working_directory_cleanup_escape_rejected" => "ownership_unknown", + _ => continue, + }; + return category; + } + "provider_cleanup_failed" +} + +fn workdir_removal_failure_guidance(category: &str) -> &'static str { + match category { + "mount_present" => { + "A mount remains inside the Workdir. Confirm its owner and usage, have that owner safely release it, then retry deletion. Yoi does not unmount external resources." + } + "mount_check_unavailable" => { + "Runtime cannot inspect mount boundaries. Restore mount inspection before retrying deletion." + } + "permission_denied" => { + "Runtime lacks permission to remove the Workdir. Have its owner resolve access safely, then retry; do not force-clean or change shared resource permissions." + } + "resource_busy" => { + "A resource is busy. Have its owner stop or release it safely, then retry deletion." + } + "storage_unavailable" => { + "Runtime storage is unavailable. Restore storage access, then retry deletion." + } + "ownership_unknown" | "provider_identity_changed" | "authority_changed" => { + "Workdir identity or ownership could not be confirmed. Verify the target with its owner before retrying." + } + "changes_present" | "dirty_or_unknown" => { + "Current Workdir changes are protected. Preserve or resolve them before retrying deletion." + } + "changes_unknown" => { + "Runtime cannot verify remaining Workdir changes. Restore repository inspection or preserve remaining files before retrying." + } + "blocked_by_live_authority" => { + "Workdir has a current attachment, reservation or retention hold. Release the owning authority before retrying deletion." + } + "runtime_unavailable" | "provider_unavailable" => { + "Runtime is unavailable. Reconnect it, then retry deletion; the Workdir registry is retained." + } + "provider_observation_unknown" | "provider_cleanup_outcome_unknown" => { + "Runtime has not confirmed deletion. Retry to re-observe the target; the Workdir registry is retained." + } + "unsupported_target" => "This provider does not support Workdir deletion.", + _ => { + "Runtime cleanup failed. Inspect correlated Runtime diagnostics, resolve the cause, then retry deletion." + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WorkdirRemovalOwnerObservation { Running { process_start_marker: u64 }, @@ -24739,11 +24802,34 @@ fn execute_reserved_workdir_removal_with_provider( true, ); }; - let corrupted = status.summary.status == WorkingDirectoryStatusKind::Corrupted; - if !corrupted - && (status.summary.cleanliness.as_deref() != Some("clean") - || status.summary.status != WorkingDirectoryStatusKind::Active) + // A provider summary is not permission to remove a different materialization. + // Unknown/corrupted metadata is delegated to Runtime's guarded cleanup, never + // repaired into a fabricated identity here. + let summary = &status.summary; + if summary.occupied_by.is_some() { + return api.config_store.complete_workdir_removal_retained( + &operation, + WorkdirRemovalDisposition::Retained, + "blocked_by_live_authority", + ); + } + if summary.working_directory_id != operation.working_directory_id + || (summary.repository_id == "unknown" + && summary.status != WorkingDirectoryStatusKind::Corrupted) + || (summary.repository_id != "unknown" + && crate::workdir_removal::workdir_materialization_fingerprint( + &workdir_record_from_summary(api, &operation.runtime_id, summary), + ) != operation.materialization_fingerprint) { + return api.config_store.fail_workdir_removal_operation( + &operation, + "provider_identity_changed", + true, + ); + } + // Past lifecycle failure is not a current dirtiness observation. Runtime + // must revalidate unknown/partial checkouts at the actual deletion boundary. + if summary.cleanliness.as_deref() == Some("dirty") { return api.config_store.complete_workdir_removal_retained( &operation, WorkdirRemovalDisposition::Retained, @@ -24761,18 +24847,25 @@ fn execute_reserved_workdir_removal_with_provider( .fail_workdir_removal_operation(&operation, category, retryable); } }; - if deleted.state != InternalWorkerOperationState::Accepted - && !runtime_reports_workdir_not_found(&deleted) - { - let category = if deleted - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "working_directory_unsupported") - { - "unsupported_target" + let deletion_confirmed = runtime_reports_workdir_not_found(&deleted) + || (deleted.state == InternalWorkerOperationState::Accepted + && deleted.working_directory.as_ref().is_some_and(|status| { + status.summary.working_directory_id == operation.working_directory_id + && status.summary.status == WorkingDirectoryStatusKind::NotFound + })); + if !deletion_confirmed { + let category = if deleted.state == InternalWorkerOperationState::Accepted { + "provider_cleanup_outcome_unknown" } else { - "provider_cleanup_failed" + workdir_cleanup_failure_category(&deleted) }; + tracing::warn!( + workdir_id = %operation.working_directory_id, + operation_id = %operation.operation_id, + runtime_id = %operation.runtime_id, + category, + "Runtime did not confirm Workdir removal; registry retained" + ); return api.config_store.fail_workdir_removal_operation( &operation, category, @@ -25058,9 +25151,7 @@ fn build_runtime_cleanup_plan( .unwrap_or_else(|| cleanup_workdir_cleanliness_from_registry(&record.cleanliness)); let action = if cleanup_workdir_file_status_is_record_only(file_status) { CleanupTargetKind::WorkdirRecordDelete - } else if cleanup_workdir_file_status_is_corrupted(file_status) - || cleanup_workdir_cleanliness_is_clean(cleanliness) - { + } else if !matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) { CleanupTargetKind::WorkdirCleanCleanup } else { CleanupTargetKind::WorkdirDirtyDiscard @@ -25088,12 +25179,12 @@ fn build_runtime_cleanup_plan( } else if cleanup_workdir_file_status_is_record_only(file_status) { "Not-found Workdir record can be deleted from the Backend registry".to_string() } else if cleanup_workdir_file_status_is_corrupted(file_status) { - "Corrupted Workdir can be deleted from Runtime storage and Backend registry" + "Runtime will verify remaining files and removal authority for this incomplete Workdir" .to_string() } else if matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) { - "Dirty Workdir requires explicit discard confirmation before cleanup".to_string() + "Dirty Workdir is protected; preserve or resolve current changes before retrying".to_string() } else if matches!(cleanliness, CleanupWorkdirCleanliness::Unknown) { - "Workdir clean state is unknown; explicit discard confirmation is required" + "Runtime will recheck current changes, identity and removal blockers before deleting" .to_string() } else { "Clean Workdir can be manually cleaned up".to_string() @@ -25295,7 +25386,12 @@ async fn execute_runtime_cleanup_with_context( target_id: candidate.target_id.clone(), action: candidate.action.clone(), status: status.to_string(), - message: message.to_string(), + message: removal.failure_category.as_deref().map_or_else( + || message.to_string(), + |category| { + format!("{category}: {}", workdir_removal_failure_guidance(category)) + }, + ), }); } CleanupTargetKind::WorkerDelete => { @@ -52974,7 +53070,7 @@ mod tests { }; let source_fingerprint = repository_source_fingerprint(&repository_source); for (workdir_id, _, _) in &fixtures { - runtime + let created = runtime .create_working_directory(WorkingDirectoryRequest { repository: WorkingDirectoryRepository { id: "repo-test".to_string(), @@ -52997,6 +53093,13 @@ mod tests { "present", "clean", ); + api.store + .upsert_workdir_registry(&workdir_record_from_summary( + &api, + provider_runtime_id, + &created.summary, + )) + .unwrap(); } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let provider_base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -54227,6 +54330,18 @@ mod tests { } } + fn confirmed_workdir_cleanup_result() -> crate::hosts::RuntimeWorkingDirectoryResult { + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: "working_directory_not_found".into(), + severity: HostDiagnosticSeverity::Warning, + message: "authoritatively absent".into(), + }], + ) + } + fn reserve_removal_fixture( api: &WorkspaceApi, working_directory_id: &str, @@ -54267,7 +54382,7 @@ mod tests { Some(clean_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let removed = execute_reserved_workdir_removal_with_provider( &api, @@ -54333,7 +54448,7 @@ mod tests { message: "timeout".to_string(), }], ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let unknown = execute_reserved_workdir_removal_with_provider( &api, @@ -54355,7 +54470,7 @@ mod tests { Some(dirty_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let dirty = execute_reserved_workdir_removal_with_provider( &api, @@ -54373,7 +54488,7 @@ mod tests { Some(clean_retry_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let removed_after_retry = execute_reserved_workdir_removal_with_provider( &api, @@ -54399,7 +54514,7 @@ mod tests { Some(corrupted_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let corrupted = execute_reserved_workdir_removal_with_provider( &api, @@ -54448,6 +54563,244 @@ mod tests { assert_eq!(unsupported_provider.cleanup_calls(), 1); } + #[test] + fn workdir_cleanup_failure_classification_is_bounded_and_guides_normal_retry() { + for (code, category) in [ + ("working_directory_cleanup_mount_present", "mount_present"), + ( + "working_directory_cleanup_mount_check_unavailable", + "mount_check_unavailable", + ), + ( + "working_directory_cleanup_permission_denied", + "permission_denied", + ), + ("working_directory_cleanup_resource_busy", "resource_busy"), + ( + "working_directory_cleanup_storage_unavailable", + "storage_unavailable", + ), + ( + "working_directory_cleanup_ownership_unknown", + "ownership_unknown", + ), + ( + "working_directory_cleanup_changes_present", + "changes_present", + ), + ( + "working_directory_cleanup_changes_unknown", + "changes_unknown", + ), + ( + "arbitrary provider text /private/path", + "provider_cleanup_failed", + ), + ] { + let result = workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: code.into(), + severity: HostDiagnosticSeverity::Error, + message: "secret /private/path".into(), + }], + ); + assert_eq!(workdir_cleanup_failure_category(&result), category); + let guidance = workdir_removal_failure_guidance(category); + assert!(!guidance.contains("/private/path")); + assert!(!guidance.contains("secret")); + assert!(guidance.len() <= 500); + assert!(guidance.contains("retry")); + } + } + + #[tokio::test] + async fn workdir_removal_retry_rechecks_pending_materialization_changes_and_attachments() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let (operation, mut summary) = reserve_removal_fixture(&api, "cleanup-retry"); + let failed_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: "working_directory_cleanup_mount_present".into(), + severity: HostDiagnosticSeverity::Error, + message: "/private/mount credentials must not escape".into(), + }], + ), + ); + let failed = execute_reserved_workdir_removal_with_provider( + &api, + operation, + false, + &failed_provider, + ) + .unwrap(); + assert!(failed.retryable); + assert_eq!(failed.failure_category.as_deref(), Some("mount_present")); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "cleanup-retry") + .unwrap() + .is_some() + ); + assert!(!serde_json::to_string(&failed).unwrap().contains("/private")); + + summary.status = WorkingDirectoryStatusKind::CleanupPending; + summary.cleanliness = Some("dirty".into()); + let dirty_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let dirty = + execute_reserved_workdir_removal_with_provider(&api, failed, false, &dirty_provider) + .unwrap(); + assert_eq!(dirty.disposition, Some(WorkdirRemovalDisposition::Retained)); + assert!(dirty.retryable); + assert_eq!(dirty_provider.cleanup_calls(), 0); + + let worker = seed_cleanup_worker(&api, 731, "normal"); + seed_cleanup_link(&api, &worker, "cleanup-retry"); + let occupied_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let occupied = + execute_reserved_workdir_removal_with_provider(&api, dirty, false, &occupied_provider) + .unwrap(); + assert_eq!( + occupied.failure_category.as_deref(), + Some("blocked_by_live_authority") + ); + assert!(occupied.retryable); + assert_eq!(occupied_provider.cleanup_calls(), 0); + api.store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &RuntimeWorkerRef::new("runtime-test", worker), + Some("cleanup-retry"), + "resolve retry attachment", + ) + .unwrap(); + + // Unknown because checkout was partially removed is delegated to Runtime + // for current change/identity protection, not denied by the old state. + summary.cleanliness = Some("unknown".into()); + let retry_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let removed = execute_reserved_workdir_removal_with_provider( + &api, + occupied.clone(), + false, + &retry_provider, + ) + .unwrap(); + assert_eq!(removed.operation_id, occupied.operation_id); + assert_eq!(removed.attempt_count, 4); + assert_eq!( + removed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert_eq!(retry_provider.cleanup_calls(), 1); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "cleanup-retry") + .unwrap() + .is_none() + ); + assert_eq!( + execute_reserved_workdir_removal_with_provider( + &api, + removed.clone(), + false, + &retry_provider + ) + .unwrap(), + removed + ); + } + + #[tokio::test] + async fn workdir_removal_keeps_registry_for_changed_identity_and_unknown_cleanup_outcome() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + for (id, changed_identity) in [("identity-mismatch", true), ("unknown-cleanup", false)] { + let (operation, mut summary) = reserve_removal_fixture(&api, id); + if changed_identity { + summary.repository_id = "another-repository".into(); + } + let provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary), + Vec::new(), + ), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + ); + let result = + execute_reserved_workdir_removal_with_provider(&api, operation, false, &provider) + .unwrap(); + assert!(result.retryable); + assert_eq!( + result.failure_category.as_deref(), + Some(if changed_identity { + "provider_identity_changed" + } else { + "provider_cleanup_outcome_unknown" + }) + ); + assert_eq!(provider.cleanup_calls(), usize::from(!changed_identity)); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, id) + .unwrap() + .is_some() + ); + } + } + + #[tokio::test] + async fn cleanup_plan_offers_normal_revalidation_for_prior_failure_and_unknown() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + for (id, state) in [ + ("pending-retry", "pending"), + ("unknown-retry", "unknown"), + ("corrupt-retry", "corrupted"), + ] { + seed_cleanup_workdir(&api, id, state, "unknown"); + } + let plan = build_runtime_cleanup_plan(&api, "runtime-test").unwrap(); + for candidate in plan.workdirs { + assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); + assert!(candidate.blocking_reason.is_none()); + } + } + #[tokio::test] async fn concurrent_orphan_recovery_runs_delayed_provider_cleanup_once() { let workspace = tempfile::tempdir().unwrap(); @@ -54473,7 +54826,7 @@ mod tests { Some(clean_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ) .with_observation_delay(std::time::Duration::from_millis(100)), ); diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 0208714fe..9356ebdf0 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -438,7 +438,9 @@ impl SqliteWorkspaceStore { "retained completion cannot use removed disposition".to_string(), )); } - self.finish_workdir_removal_operation(operation, disposition, false, Some(category), false) + // Retention describes this attempt's current blockers, not a permanent + // ban. A subsequent normal request rechecks all live authority. + self.finish_workdir_removal_operation(operation, disposition, true, Some(category), false) } pub fn fail_workdir_removal_operation( diff --git a/docs/development/workdir-removal.md b/docs/development/workdir-removal.md new file mode 100644 index 000000000..097efb0a2 --- /dev/null +++ b/docs/development/workdir-removal.md @@ -0,0 +1,53 @@ +# Workdir removal and ordinary retry + +Use the Runtime **Workdirs** page or the normal typed Workdir removal tool to +remove an unused repository Workdir. Deletion is authorized by the Backend's +existing durable removal operation; it is not a registry-only hide operation. + +A failed attempt does not require a new recovery state or a manual edit of +`materialization.json`. Retry the same removal request after resolving the +reported cause. Each attempt rechecks current attachments, attachment +reservations, retention holds, materialization identity and current file changes. +A previous clean observation is not permission to discard new changes. A dirty +Workdir must be preserved or its changes resolved by its owner before deletion. + +## Interpreting a retained Workdir + +- **Mount remains:** Git status being clean does not mean mounted resources are + released. Confirm the mount's owner, users and data destination. Have that + owner safely release the resource, then retry normal deletion. Yoi does not + recursively delete mounted data or implicitly unmount external/unknown + resources. Do not use force-clean, recursive chmod or raw recursive deletion + to override this boundary. +- **Permission denied / resource busy / storage unavailable:** have the owning + operator resolve access, release the resource or restore storage. Retry the + same normal action; registry retention is intentional until cleanup is proven. +- **Changes present / changes cannot be verified:** preserve current files and + restore repository inspection where possible. Do not treat a previous cleanup + failure or missing checkout metadata as proof that all remaining files are + expendable. +- **Identity or ownership unavailable:** verify the Workdir target with its + owner. Do not rewrite registry or materialization identity to bypass checks. +- **Runtime unavailable / outcome unknown:** reconnect the Runtime and retry. + Absence of a provider response is not authoritative not-found evidence. +- **Attachment, reservation or hold:** release the current owning authority + through its normal API before retrying. A stale failure does not freeze these + conditions; newly attached Workers remain protected. + +The Web page refreshes authoritative inventory after each attempt. Successful +removal commits registry deletion only after Runtime confirms physical deletion +or authoritative not-found. An unknown result leaves the registry in place so +retry can reconcile the provider's current state. + +## Diagnostics + +Public responses expose a bounded cause category and safe guidance, not arbitrary +provider errors or host paths. For an OS failure, correlate Backend removal logs +(`operation_id`, `workdir_id`, Runtime) with Runtime cleanup logs for that Workdir +and cleanup action. Runtime logs retain the concrete OS error/errno for the +owning operator. These private diagnostics must not be copied unredacted into +public responses or Tickets. + +This procedure is not authorization to manipulate a live mount or another +Worker's storage. If ownership or required operator authority is unclear, stop +and request that specific owner/operator's decision. From c889c9a68d59452de73fd2924ce7556f9ea451c8 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 9 Oct 2026 16:35:26 +0900 Subject: [PATCH 2/6] fix: show Workdir removal causes and refresh ordinary retries --- tools/web-ux/browser-tests/workdir_fixture.ts | 140 ++++++ .../workdir_removal_shell_test.ts | 456 ++++++++++++++++++ .../workspace_home_fixture_server.ts | 4 + tools/web-ux/deno.json | 2 +- .../scenarios/workdir-removal-dark.json | 117 +++++ .../scenarios/workdir-removal-light.json | 81 ++++ web/workspace/deno.json | 2 +- .../src/lib/workspace/api/workdirs.ts | 70 ++- .../lib/workspace/settings/workdir-removal.ts | 167 +++++++ .../[runtimeId]/workdirs/+page.svelte | 271 ++++------- web/workspace/tests/workdir-removal.test.ts | 248 ++++++++++ 11 files changed, 1381 insertions(+), 177 deletions(-) create mode 100644 tools/web-ux/browser-tests/workdir_fixture.ts create mode 100644 tools/web-ux/browser-tests/workdir_removal_shell_test.ts create mode 100644 tools/web-ux/scenarios/workdir-removal-dark.json create mode 100644 tools/web-ux/scenarios/workdir-removal-light.json create mode 100644 web/workspace/src/lib/workspace/settings/workdir-removal.ts create mode 100644 web/workspace/tests/workdir-removal.test.ts diff --git a/tools/web-ux/browser-tests/workdir_fixture.ts b/tools/web-ux/browser-tests/workdir_fixture.ts new file mode 100644 index 000000000..4e3597870 --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_fixture.ts @@ -0,0 +1,140 @@ +// Synthetic API boundary only; the production route and shell own all rendering. +export function workdirFixture() { + let mountReleased = false; + const deleted = new Set(); + const requests: { + workspace: string; + method: string; + path: string; + body?: unknown; + }[] = []; + const summary = ( + id: string, + status = "active", + cleanliness = "clean", + occupied = false, + ) => ({ + working_directory_id: id, + display_name: id === "retry-dir" ? "Cleanup retry — distributed development checkout" : id, + source: { kind: "repository", repository_key: "main" }, + materializer_kind: "runtime_git_clone", + status, + cleanliness, + current_ref: "a".repeat(40), + current_selector: "develop", + occupied_by: occupied + ? { + runtime_id: "fixture-runtime", + worker_id: "worker-1", + display_name: "Active Worker", + linked_at: "2026-01-01T00:00:00Z", + } + : null, + }); + const items = (workspace: string) => + workspace === "home-empty" ? [] : [ + ...(deleted.has(workspace) ? [] : [summary("retry-dir", "cleanup_pending", "unknown")]), + summary("dirty-dir", "active", "dirty"), + summary("occupied-dir", "active", "clean", true), + ]; + const plan = (workspace: string) => ({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + generated_at: "2026-01-01T00:00:00Z", + revision: "plan-1", + digest: "digest-1", + workers: [], + diagnostics: [], + workdirs: items(workspace).map((item) => ({ + target_id: `workdir:${item.working_directory_id}`, + action: item.cleanliness === "dirty" ? "workdir_dirty_discard" : "workdir_clean_cleanup", + workdir_id: item.working_directory_id, + runtime_id: "fixture-runtime", + repository_key: "main", + reason: "Ordinary cleanup rechecks current conditions", + linked_worker_ids: item.occupied_by ? ["worker-1"] : [], + linked_running_worker_ids: item.occupied_by ? ["worker-1"] : [], + running_linked: Boolean(item.occupied_by), + pinned_linked: false, + file_status: item.status === "cleanup_pending" ? "pending" : "present", + cleanliness: item.cleanliness, + blocking_reason: item.occupied_by ? "Workdir is linked to a running Worker" : null, + })), + }); + return async (request: Request): Promise => { + const url = new URL(request.url); + if ( + url.pathname === "/fixture-workdirs-control" && request.method === "POST" + ) { + const control = await request.json(); + mountReleased = control.mountReleased === true; + if (control.reset) { + deleted.clear(); + requests.length = 0; + } + return Response.json({ ok: true }); + } + if (url.pathname === "/fixture-workdirs") { + return Response.json({ requests }); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/runtimes.*)$/); + if (!match) return null; + const [, workspace, path] = match; + if (path === "/runtimes") { + return Response.json({ + workspace_id: workspace, + limit: 100, + source: "fixture", + diagnostics: [], + items: [], + }); + } + if (!path.startsWith("/runtimes/fixture-runtime/")) return null; + const body = request.method !== "GET" ? await request.json() : undefined; + requests.push({ workspace, method: request.method, path, body }); + if (path.endsWith("/working-directories")) { + return Response.json({ + workspace_id: workspace, + items: items(workspace), + diagnostics: [], + }); + } + if (path.endsWith("/cleanup-plan")) return Response.json(plan(workspace)); + // Baseline route uses cleanup-executions; same retained outcome for before captures. + if (path.endsWith("/cleanup-executions")) { + return Response.json({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + executed_at: "2026-01-01T00:00:00Z", + diagnostics: [], + plan_after: plan(workspace), + results: [{ + target_id: "workdir:retry-dir", + action: "workdir_clean_cleanup", + status: "retained", + message: "Provider cleanup failed", + }], + }); + } + if ( + path.endsWith("/working-directories/retry-dir") && + request.method === "DELETE" + ) { + if (workspace === "home-error") { + return Response.json({ + message: "Synthetic private host path must not render", + }, { status: 503 }); + } + if (mountReleased) deleted.add(workspace); + return Response.json({ + working_directory_id: "retry-dir", + disposition: mountReleased ? "removed" : "attention_required", + retryable: !mountReleased, + failure_category: mountReleased ? null : "mount_present", + }); + } + return Response.json({ error: "Unexpected fixture Workdir request" }, { + status: 404, + }); + }; +} diff --git a/tools/web-ux/browser-tests/workdir_removal_shell_test.ts b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts new file mode 100644 index 000000000..e48f0724e --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts @@ -0,0 +1,456 @@ +/// +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const evidence = join(root, "target/web-ux/t730-browser"); + +async function noOverflow(page: Page) { + assertEquals( + await page.locator("main").evaluate((main) => ({ + page: document.documentElement.scrollWidth > innerWidth, + main: main.scrollWidth > main.clientWidth, + })), + { page: false, main: false }, + ); +} + +Deno.test("ordinary removal refreshes failed and successful inventory, supports exact retry, and preserves permission and live guards across viewports and themes", async () => { + await Deno.mkdir(evidence, { recursive: true }); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = await startOwnedProcesses( + [{ + id: "workdirs", + command: Deno.execPath(), + cwd: root, + args: [ + "run", + "--allow-net", + "--allow-read", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + String(port), + "web/workspace/build", + ], + readyUrl: `${url}/health`, + }], + join(root, "tools/web-ux/scenarios/workdir-removal-light.json"), + join(evidence, "process-logs"), + [], + ); + const observations: unknown[] = []; + const injectedConsoleErrors: string[] = []; + const injectedUnexpectedErrors: string[] = []; + const observeInjectedFailure = (page: Page) => { + page.on("console", (message) => { + if (message.type() === "error") { + injectedConsoleErrors.push(message.text()); + } + }); + page.on( + "pageerror", + (error) => injectedUnexpectedErrors.push(String(error)), + ); + page.on( + "requestfailed", + (request) => injectedUnexpectedErrors.push(request.url()), + ); + }; + const control = async (value: unknown) => { + const response = await fetch(`${url}/fixture-workdirs-control`, { + method: "POST", + body: JSON.stringify(value), + }); + assert(response.ok); + await response.body?.cancel(); + }; + const state = async () => await (await fetch(`${url}/fixture-workdirs`)).json(); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + await control({ reset: true }); + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + const route = `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`; + await page.goto(route); + const deletion = page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }); + await deletion.waitFor(); + assert( + await deletion.isEnabled(), + "past pending/unknown state permanently disabled ordinary removal", + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + assert( + await page.getByRole("button", { + name: "Delete occupied-dir", + exact: true, + }).isDisabled(), + ); + await noOverflow(page); + assert( + await deletion.evaluate((button) => { + const r = button.getBoundingClientRect(); + return r.left >= 0 && r.right <= innerWidth; + }), + "Delete is outside initial viewport", + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-initial.png`), + }); + // Hold the actual request boundary to verify duplicate gestures cannot dispatch another DELETE. + let release!: () => void; + const gate = new Promise((resolve) => release = resolve); + let observed!: () => void; + const entered = new Promise((resolve) => observed = resolve); + let deletes = 0; + await page.route( + "**/working-directories/retry-dir", + async (intercept) => { + deletes++; + observed(); + await gate; + await intercept.continue(); + }, + ); + await deletion.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }).filter({ hasText: "Deleting…" }).waitFor(); + assert(await deletion.isDisabled()); + await page.keyboard.press("Enter"); + await entered; + assertEquals(deletes, 1); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-deleting.png`), + }); + release(); + await page.getByRole("alert").filter({ + hasText: "mounted filesystem", + }).waitFor(); + const retry = page.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }); + await retry.waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + const failed = await state(); + const beforeGets = failed.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/working-directories")) + .length, + 2, + ); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 2, + ); + assertEquals(await page.locator("tbody tr").count(), 3); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-mount-failure.png`), + }); + await control({ mountReleased: true }); + await retry.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("status").filter({ + hasText: "Deletion confirmed", + }).waitFor(); + await page.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assertEquals( + await page.getByRole("button", { name: /retry-dir/ }).count(), + 0, + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + const final = await state(); + const mutations = final.requests.filter((r: { method: string }) => r.method === "DELETE"); + assertEquals(mutations.length, 2); + assertEquals( + mutations[0], + mutations[1], + "ordinary retry changed its request", + ); + const gets = final.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/working-directories")).length, + 3, + ); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 3, + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-removed.png`), + }); + await page.goto( + `${url}/w/home-member/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.locator("tbody tr").first().waitFor(); + assertEquals( + await page.getByRole("button", { name: /Delete/ }).count(), + 0, + ); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-member.png`), + }); + await page.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await noOverflow(page); + assertEquals(errors, []); + observations.push({ + colorScheme, + width, + requests: final.requests, + errors, + }); + } finally { + await context.close(); + } + } + } + + // Lost/HTTP failure responses still trigger fresh inventory and eligibility. Never echo provider bodies. + await control({ reset: true }); + const page = await browser.newPage(); + observeInjectedFailure(page); + await page.goto( + `${url}/w/home-error/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await page.getByRole("alert").filter({ hasText: "provider is unavailable" }) + .waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + assert( + !(await page.locator("main").innerText()).includes("Synthetic private"), + ); + const failed = await state(); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/working-directories") + ).length, + 2, + ); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/cleanup-plan") + ).length, + 2, + ); + await page.screenshot({ path: join(evidence, "http-failure.png") }); + await page.close(); + + // Plan refresh failure cannot leave stale delete authority enabled; manual Refresh recovers it. + await control({ reset: true }); + const stale = await browser.newPage(); + observeInjectedFailure(stale); + let planGets = 0; + let planFails = true; + await stale.route("**/cleanup-plan", async (route) => { + planGets++; + if (planGets > 1 && planFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }); + await stale.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await stale.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await stale.getByRole("alert").filter({ hasText: "could not be refreshed" }) + .waitFor(); + assert( + await stale.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assert( + !(await stale.locator("main").innerText()).includes("Synthetic private"), + ); + await stale.screenshot({ path: join(evidence, "refresh-failure.png") }); + planFails = false; + await stale.getByRole("button", { name: "Refresh", exact: true }).click(); + await stale.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + await stale.close(); + + // Current dirty changes after a failure override a retryable historical result. + await control({ reset: true }); + const changed = await browser.newPage(); + let changedGets = 0; + await changed.route("**/cleanup-plan", async (route) => { + const response = await route.fetch(); + const plan = await response.json(); + if (++changedGets > 1) plan.workdirs[0].action = "workdir_dirty_discard"; + await route.fulfill({ response, json: plan }); + }); + await changed.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await changed.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).waitFor(); + await changed.waitForFunction(() => + document.querySelector(".workdir-identity .removal-guard")?.textContent + ?.includes("Changes are protected") + ); + assert( + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assertStringIncludes( + await changed.locator("main").innerText(), + "Changes are protected", + ); + await changed.screenshot({ + path: join(evidence, "new-changes-protected.png"), + }); + await changed.close(); + // A malformed response may follow a real side effect. Inventory still removes the now-absent row. + await control({ reset: true, mountReleased: true }); + const lost = await browser.newPage(); + observeInjectedFailure(lost); + await lost.route("**/working-directories/retry-dir", async (route) => { + await route.fetch(); + await route.fulfill({ + status: 200, + json: { + message: "Synthetic private path", + failure_category: "/private/path token=synthetic", + }, + }); + }); + await lost.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await lost.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await lost.getByRole("alert").filter({ + hasText: "Removal could not be confirmed", + }).waitFor(); + await lost.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assert( + !(await lost.locator("main").innerText()).includes("Synthetic private"), + ); + assert(!(await lost.locator("main").innerText()).includes("/private/path")); + await lost.screenshot({ + path: join(evidence, "malformed-response-refreshed.png"), + }); + await lost.close(); + + // Even a failed initial inventory can recover into a confirmed empty list with manual Refresh. + const emptyRecovery = await browser.newPage(); + observeInjectedFailure(emptyRecovery); + let inventoryFails = true; + await emptyRecovery.route( + "**/runtimes/fixture-runtime/working-directories", + async (route) => { + if (inventoryFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }, + ); + await emptyRecovery.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await emptyRecovery.getByText("Workdir inventory is unavailable.") + .waitFor(); + inventoryFails = false; + await emptyRecovery.getByRole("button", { name: "Refresh", exact: true }) + .click(); + await emptyRecovery.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await emptyRecovery.close(); + assertEquals(injectedUnexpectedErrors, []); + assert( + injectedConsoleErrors.every((message) => message.includes("503")), + "unexpected injected failure console diagnostic", + ); + await Deno.writeTextFile( + join(evidence, "evidence.json"), + JSON.stringify( + { + observations, + injectedConsoleErrors, + injectedUnexpectedErrors, + failureRefresh: failed.requests, + refreshFailureRecovered: true, + newChangesProtected: true, + malformedResponseReconciled: true, + emptyInventoryRecovered: true, + expectedFailureDiagnostics: [ + "Synthetic DELETE HTTP 503 on home-error", + "Synthetic cleanup-plan HTTP 503 after removal", + "Malformed DELETE result after fixture physical removal", + "Synthetic initial inventory HTTP 503", + ], + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + assertEquals(await stopOwnedProcesses(server), []); + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index c80c4f697..5d478044f 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -2,6 +2,8 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +import { workdirFixture } from "./workdir_fixture.ts"; +const workdirsFixture = workdirFixture(); const settingsFixture = settingsFixtureHandler(); const port = Number(Deno.args[0]); @@ -30,6 +32,8 @@ const mime: Record = { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + const workdirsResponse = await workdirsFixture(request); + if (workdirsResponse) return workdirsResponse; const settingsResponse = await settingsFixture(request); if (settingsResponse) return settingsResponse; if (url.pathname === "/api/workspaces") { diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index e3e278be5..0ffbee433 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/workdir-removal-dark.json b/tools/web-ux/scenarios/workdir-removal-dark.json new file mode 100644 index 000000000..b689c72d1 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-dark.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-dark", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current inventory" + }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { + "kind": "selector", + "selector": "[role='alert']" + } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read only inventory" + } + ] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Empty inventory" + } + ] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/workdir-removal-light.json b/tools/web-ux/scenarios/workdir-removal-light.json new file mode 100644 index 000000000..2fd178d19 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-light.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-light", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { "kind": "anonymous" } + } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [ + { "id": "initial", "label": "Current inventory" }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { "kind": "selector", "selector": "[role='alert']" } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [{ "id": "initial", "label": "Read only inventory" }] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [{ "id": "initial", "label": "Empty inventory" }] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index ac4ca6e82..62fe36fd6 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "build": "deno run -A npm:vite@8.3.2 build", diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index dbfedcfb7..15619fe84 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -7,6 +7,7 @@ import type { WorkingDirectoryDetailResponse, WorkingDirectoryListResponse, WorkingDirectoryOccupancy, + WorkingDirectoryRemovalResponse, WorkingDirectorySource, WorkingDirectorySummary, } from "../../generated/workdir-api"; @@ -63,6 +64,40 @@ export function parseWorkingDirectoryListResponse( }; } +export function parseWorkingDirectoryRemovalResponse( + value: unknown, +): WorkingDirectoryRemovalResponse { + const record = exactRecord( + value, + new Set([ + "working_directory_id", + "disposition", + "retryable", + "failure_category", + ]), + "Workdir removal response", + ); + const working_directory_id = stringField(record, "working_directory_id"); + const category = record.failure_category; + if ( + working_directory_id.length > 128 || + (category != null && + (typeof category !== "string" || category.length > 128)) + ) { + throw new Error("Invalid Workdir removal response"); + } + return { + working_directory_id, + disposition: enumField(record, "disposition", [ + "removed", + "retained", + "attention_required", + ]), + retryable: booleanField(record, "retryable", "Workdir removal response"), + failure_category: category as string | null | undefined, + }; +} + export function parseWorkingDirectoryDetailResponse( value: unknown, ): WorkingDirectoryDetailResponse { @@ -178,13 +213,26 @@ export function parseWorkingDirectorySummary( function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { const source = exactRecord( value, - new Set(["kind", "repository_key", "grant_id", "grant_permissions", "access", "content_path", "purpose"]), + new Set([ + "kind", + "repository_key", + "grant_id", + "grant_permissions", + "access", + "content_path", + "purpose", + ]), "Workdir source", ); const kind = stringField(source, "kind"); if (kind === "workspace_config") { - if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) { - throw new Error("Logical config source must not contain repository or External authority"); + if ( + source.repository_key !== undefined || source.grant_id !== undefined || + source.grant_permissions !== undefined + ) { + throw new Error( + "Logical config source must not contain repository or External authority", + ); } const access = stringField(source, "access"); if (access !== "read_only" && access !== "read_write") { @@ -194,10 +242,20 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { if (content_path !== "/workspace-config") { throw new Error("Logical config must reference its WIP content entrance"); } - return { kind, access, content_path, purpose: stringField(source, "purpose") }; + return { + kind, + access, + content_path, + purpose: stringField(source, "purpose"), + }; } - if (source.access !== undefined || source.content_path !== undefined || source.purpose !== undefined) { - throw new Error("Filesystem Workdir source must not contain logical config metadata"); + if ( + source.access !== undefined || source.content_path !== undefined || + source.purpose !== undefined + ) { + throw new Error( + "Filesystem Workdir source must not contain logical config metadata", + ); } if (kind === "repository") { if ( diff --git a/web/workspace/src/lib/workspace/settings/workdir-removal.ts b/web/workspace/src/lib/workspace/settings/workdir-removal.ts new file mode 100644 index 000000000..4c7194e86 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-removal.ts @@ -0,0 +1,167 @@ +import type { WorkingDirectoryRemovalResponse } from "#lib/generated/workdir-api.ts"; +import type { + CleanupWorkdirCandidate, + WorkingDirectorySummary, +} from "#lib/workspace/sidebar/types.ts"; +import { workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { parseWorkingDirectoryRemovalResponse } from "#lib/workspace/api/workdirs.ts"; + +// Only reviewed category codes become public copy. Never render a provider message, +// category verbatim, OS error, or host path (including for unknown future codes). +const causes: Readonly> = { + mount_present: + "A mounted filesystem remains inside this Workdir. Ask its resource owner to verify ownership and safely release it before retrying. Unknown or shared mounts must not be forcibly unmounted.", + mount_check_unavailable: + "The Runtime could not check for mounted filesystems. Restore mount inspection on the Runtime before retrying.", + permission_denied: + "Removal permission was denied. Ask the Workspace or Runtime administrator to verify the required access; do not bypass protection with a forced deletion.", + resource_busy: + "A resource is still in use. Ask its owner to stop using it and release it safely before retrying.", + storage_unavailable: + "Runtime storage is unavailable. Restore storage access and capacity before retrying.", + ownership_unknown: + "Resource ownership could not be verified. Ask the Runtime administrator to verify ownership before any resource is released or removed.", + changes_present: + "This Workdir has changes. Preserve or clean those changes before requesting removal again.", + changes_unknown: + "Current changes could not be checked. Restore access so the Runtime can verify change protection before removal.", + provider_cleanup_failed: + "The provider could not finish removal. Ask the Runtime administrator to inspect the correlated removal diagnostics and resolve the cause before retrying.", + provider_observation_unknown: + "The provider could not confirm the remaining Workdir. Restore provider access so the next removal can recheck its current contents.", + provider_cleanup_outcome_unknown: + "The provider has not confirmed deletion. Restore provider access and retry the same removal request to recheck the remaining target. The Workdir stays listed until deletion is confirmed.", + authority_invalid: + "The removal authority could not be verified. Ask the Workspace administrator to verify the Workdir target and owning Runtime before requesting removal again.", + provider_identity_changed: + "The provider target identity changed. Ask the Runtime administrator to verify the target; do not delete a replacement resource.", + runtime_unavailable: + "The Runtime is unavailable. Restore its connection before retrying removal.", + provider_unavailable: + "The Workdir provider is unavailable. Restore its connection before retrying removal.", + unsupported_target: + "This target does not support ordinary removal. Ask its resource owner for the supported cleanup operation.", + blocked_by_live_authority: + "This Workdir has an active attachment, reservation, or hold. Release it through its owning operation before requesting removal again.", + dirty_or_unknown: + "Current change protection prevents removal. Preserve changes or restore change inspection before requesting removal again.", + authority_changed: + "Removal authority or current usage changed. Refresh the inventory and resolve current attachments, reservations, or holds before requesting removal again.", +}; +const unknownCause = + "Removal could not be confirmed. Check the Runtime's correlated removal diagnostics and resolve the cause before requesting removal again. The Workdir remains listed until deletion is confirmed."; + +export function removalCause(category: string | null | undefined): string { + return category != null && Object.hasOwn(causes, category) + ? causes[category] + : unknownCause; +} + +export function canRemoveWorkdir( + workdir: WorkingDirectorySummary, + candidate: CleanupWorkdirCandidate | undefined, +): boolean { + return Boolean( + candidate && !candidate.blocking_reason && !candidate.running_linked && + !candidate.pinned_linked && + (candidate.action === "workdir_clean_cleanup" || + candidate.action === "workdir_record_delete") && + workdir.cleanliness !== "dirty" && !workdir.occupied_by, + ); +} + +export function removalGuard( + workdir: WorkingDirectorySummary, + candidate: CleanupWorkdirCandidate | undefined, +): string { + if ( + workdir.occupied_by || candidate?.running_linked || + candidate?.pinned_linked || candidate?.blocking_reason + ) { + return "An attachment, reservation, or hold protects this Workdir. Release it through its owning operation, then refresh."; + } + if ( + workdir.cleanliness === "dirty" || + candidate?.action === "workdir_dirty_discard" + ) { + return "Changes are protected. Preserve or clean them, then refresh before deletion."; + } + return "Removal eligibility is unavailable. Refresh before deletion."; +} + +export class WorkdirRemovalError extends Error { + constructor(readonly category?: string) { + super(removalCause(category)); + } +} + +// Read only a small public DTO. Discard HTTP error bodies without interpreting or +// displaying them, and drain small bodies rather than aborting completed requests. +async function removalBody(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > 4096) { + await reader.cancel(); + throw new WorkdirRemovalError(); + } + chunks.push(value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); + } finally { + reader.releaseLock(); + } +} + +export async function removeWorkdir( + fetchFn: typeof fetch, + workspaceId: string, + runtimeId: string, + workdirId: string, +): Promise { + const response = await fetchFn( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/working-directories/${ + encodeURIComponent(workdirId) + }`, + ), + { + method: "DELETE", + headers: { "content-type": "application/json" }, + // An ordinary repeated request uses the existing durable Backend removal authority. + body: JSON.stringify({ reason: "User requested Workdir deletion" }), + }, + ); + if (!response.ok) { + await removalBody(response).catch(() => undefined); + throw new WorkdirRemovalError( + response.status === 403 + ? "permission_denied" + : response.status === 409 + ? "authority_changed" + : response.status === 503 + ? "provider_unavailable" + : undefined, + ); + } + const result = parseWorkingDirectoryRemovalResponse( + JSON.parse(await removalBody(response)), + ); + if (result.working_directory_id !== workdirId) { + throw new WorkdirRemovalError(); + } + return result; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index cb44d6047..6275f9d94 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -1,102 +1,89 @@