diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 4464ea5..c544609 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -23,6 +23,7 @@ use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; use DataMachineCode\Workspace\Workspace; +use DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator; use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorkspaceMutationLock; @@ -607,7 +608,7 @@ public function adopt_repo( array $args, array $assoc_args ): void { * ## OPTIONS * * - * : Cleanup operation. One of: . + * : Cleanup operation. One of: . * Existing task-backed controls remain: . * * [] @@ -628,17 +629,19 @@ public function adopt_repo( array $args, array $assoc_args ): void { * --- * * [--dry-run] - * : Run the selected cleanup review synchronously through workspace abilities. + * : Run the selected cleanup review synchronously through workspace abilities. For + * `safe`, preview all safe stages and stale lock pruning without removals. * * [--force] * : Pass force=true into the cleanup task params for modes that support it. + * Refused by `safe`. * - * [--include-artifacts] - * : For `plan --mode=retention`, include artifact cleanup rows. Retention - * planning includes a bounded artifact inventory page by default; this flag - * remains accepted for explicitness and `--mode=artifacts` still creates an - * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless - * this flag is passed. + * [--include-artifacts] + * : For `plan --mode=retention`, include artifact cleanup rows. Retention + * planning includes a bounded artifact inventory page by default; this flag + * remains accepted for explicitness and `--mode=artifacts` still creates an + * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless + * this flag is passed. * * [--older-than=] * : Pass an age gate such as 7d or 24h into cleanup task params. @@ -647,22 +650,22 @@ public function adopt_repo( array $args, array $assoc_args ): void { * : For `plan`, number of largest reclaimable paths to show in the upfront * summary. Defaults to 10. * - * [--limit=] - * : For DB-backed `apply` / `resume`, maximum pending rows to process in this - * invocation (default 25, max 100). For `plan`, maximum worktrees to scan in - * each cleanup lane page. Plan pages default to 100 so huge workspaces return - * actionable JSON quickly. Use --exhaustive for a full audit. + * [--limit=] + * : For DB-backed `apply` / `resume`, maximum pending rows to process in this + * invocation (default 25, max 100). For `plan`, maximum worktrees to scan in + * each cleanup lane page. Plan pages default to 100 so huge workspaces return + * actionable JSON quickly. Use --exhaustive for a full audit. * - * [--offset=] - * : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run - * pages. Walk huge workspaces by feeding the previous response's - * `continuation.next_offset` until `continuation.complete` is true. + * [--offset=] + * : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run + * pages. Walk huge workspaces by feeding the previous response's + * `continuation.next_offset` until `continuation.complete` is true. * - * [--exhaustive] - * : For `plan`, request a full unbounded audit instead of the default bounded - * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree - * AND run per-worktree git status / unpushed-commit safety probes. Slow on - * huge workspaces; use sparingly for full audits. + * [--exhaustive] + * : For `plan`, request a full unbounded audit instead of the default bounded + * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree + * AND run per-worktree git status / unpushed-commit safety probes. Slow on + * huge workspaces; use sparingly for full audits. * * [--safety-probes] * : For `--mode=artifacts --dry-run`, run the per-worktree git safety probes @@ -688,6 +691,12 @@ public function adopt_repo( array $args, array $assoc_args ): void { * : For `cleanup until-empty --mode=artifacts`, stop before starting another * pass after this many seconds. * + * [--passes=] + * : For `cleanup safe`, maximum child-drain passes per cycle. Defaults to 10. + * + * [--cycles=] + * : For `cleanup safe`, maximum safe cleanup cycles before stopping. Defaults to 5. + * * [--format=] * : Output format. * --- @@ -700,6 +709,9 @@ public function adopt_repo( array $args, array $assoc_args ): void { * * ## EXAMPLES * + * # Apply all currently safe DMC workspace cleanup and report blockers + * wp datamachine-code workspace cleanup safe --format=json + * * # Create a DB-backed cleanup plan for review * wp datamachine-code workspace cleanup plan --mode=retention * @@ -746,11 +758,15 @@ public function adopt_repo( array $args, array $assoc_args ): void { public function cleanup( array $args, array $assoc_args ): void { $operation = (string) ( $args[0] ?? '' ); if ( '' === $operation ) { - WP_CLI::error('Usage: wp datamachine-code workspace cleanup [] [--mode=]'); + WP_CLI::error('Usage: wp datamachine-code workspace cleanup [] [--mode=]'); return; } switch ( $operation ) { + case 'safe': + $this->run_cleanup_safe($assoc_args); + return; + case 'plan': $this->run_cleanup_plan($assoc_args); return; @@ -797,6 +813,78 @@ public function cleanup( array $args, array $assoc_args ): void { } } + private function run_cleanup_safe( array $assoc_args ): void { + $input = array( + 'dry_run' => ! empty($assoc_args['dry-run']), + 'force' => ! empty($assoc_args['force']), + 'discard_unpushed' => ! empty($assoc_args['discard-unpushed']), + 'source' => self::CLEANUP_CLI_SOURCE, + ); + foreach ( array( 'limit', 'passes', 'cycles' ) as $key ) { + if ( isset($assoc_args[ $key ]) ) { + $input[ $key ] = (int) $assoc_args[ $key ]; + } + } + if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) { + $input['until_budget'] = trim( (string) $assoc_args['until-budget']); + } + + $orchestrator = new WorkspaceSafeCleanupOrchestrator(); + $result = $orchestrator->run($input); + if ( is_wp_error($result) ) { + $this->render_workspace_error($result); + return; + } + + $this->render_cleanup_safe_result($result, $assoc_args); + } + + private function render_cleanup_safe_result( array $result, array $assoc_args ): void { + if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) { + if ( empty($assoc_args['verbose']) ) { + $result['steps'] = $this->compact_safe_cleanup_steps( (array) ( $result['steps'] ?? array() ) ); + } + $this->renderer()->json($result); + return; + } + + $summary = (array) ( $result['summary'] ?? array() ); + WP_CLI::log('Safe workspace cleanup:'); + $this->format_items( + array( + array( 'metric' => 'applied', 'value' => ! empty($result['applied']) ? 'yes' : 'no' ), + array( 'metric' => 'state', 'value' => (string) ( $result['state'] ?? '-' ) ), + array( 'metric' => 'cycles', 'value' => (string) ( $summary['cycles'] ?? 0 ) ), + array( 'metric' => 'removed', 'value' => (string) ( $summary['removed'] ?? 0 ) ), + array( 'metric' => 'would_remove', 'value' => (string) ( $summary['would_remove'] ?? 0 ) ), + array( 'metric' => 'marked_cleanup_eligible', 'value' => (string) ( $summary['marked_cleanup_eligible'] ?? 0 ) ), + array( 'metric' => 'bytes_reclaimed', 'value' => $this->format_bytes( (int) ( $summary['bytes_reclaimed'] ?? 0 ) ) ), + array( 'metric' => 'stale_lock_files_removed', 'value' => (string) ( $summary['lock_files_removed'] ?? 0 ) ), + array( 'metric' => 'blockers', 'value' => (string) ( $summary['blocker_count'] ?? 0 ) ), + ), + array( 'metric', 'value' ), + array( 'format' => 'table' ), + 'metric' + ); + + $blockers = (array) ( $result['blockers'] ?? array() ); + if ( array() !== $blockers ) { + WP_CLI::log('Compact blockers:'); + $this->format_items($blockers, array( 'reason_code', 'count' ), array( 'format' => 'table' ), 'reason_code'); + } + } + + private function compact_safe_cleanup_steps( array $steps ): array { + $compact = array(); + foreach ( $steps as $key => $step ) { + if ( is_array($step) ) { + $compact[ $key ] = $step; + } + } + + return $compact; + } + private function run_cleanup_task( array $assoc_args ): void { if ( isset($assoc_args['dry-run']) ) { $this->run_cleanup_review($assoc_args); diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index af9bb33..ce6af1f 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -32,6 +32,7 @@ require_once __DIR__ . '/WorkspaceWorktreeInventoryCleanup.php'; require_once __DIR__ . '/WorkspaceWorktreeEmergencyCleanup.php'; require_once __DIR__ . '/WorktreeCleanupClassifier.php'; +require_once __DIR__ . '/WorkspaceSafeCleanupOrchestrator.php'; class Workspace { use WorkspaceCoreUtilities; diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php new file mode 100644 index 0000000..07903df --- /dev/null +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -0,0 +1,253 @@ +ability_resolver = $ability_resolver ? $ability_resolver : static fn( string $name ) => function_exists('wp_get_ability') ? wp_get_ability($name) : null; + $this->lock_pruner = $lock_pruner ? $lock_pruner : array( $this, 'prune_locks' ); + } + + /** + * Run the safe workspace cleanup flow. + * + * @param array $input Orchestration input. + * @return array|\WP_Error + */ + public function run( array $input ): array|\WP_Error { + if ( ! empty($input['force']) ) { + return new \WP_Error('safe_cleanup_refuses_force', 'Safe workspace cleanup refuses force. Dirty worktrees remain blockers.', array( 'status' => 400 )); + } + if ( ! empty($input['discard_unpushed']) ) { + return new \WP_Error('safe_cleanup_refuses_unpushed_discard', 'Safe workspace cleanup refuses unpushed commit discard. Unpushed worktrees remain blockers.', array( 'status' => 400 )); + } + + $dry_run = ! empty($input['dry_run']); + $limit = isset($input['limit']) ? max(1, min(200, (int) $input['limit'])) : 25; + $passes = isset($input['passes']) ? max(1, min(100, (int) $input['passes'])) : 10; + $cycles = isset($input['cycles']) ? max(1, min(25, (int) $input['cycles'])) : 5; + $source = isset($input['source']) && '' !== trim( (string) $input['source']) ? trim( (string) $input['source']) : self::DEFAULT_SOURCE; + + $cleanup_eligible = $this->resolve_ability('datamachine-code/workspace-worktree-cleanup-eligible-drain'); + if ( is_wp_error($cleanup_eligible) ) { + return $cleanup_eligible; + } + $active_no_signal = $this->resolve_ability('datamachine-code/workspace-worktree-active-no-signal-drain'); + if ( is_wp_error($active_no_signal) ) { + return $active_no_signal; + } + + $result = array( + 'success' => true, + 'mode' => 'safe_workspace_cleanup', + 'applied' => ! $dry_run, + 'destructive' => ! $dry_run, + 'limit' => $limit, + 'passes' => $passes, + 'cycles' => $cycles, + 'generated_at' => gmdate('c'), + 'steps' => array(), + 'summary' => array( + 'cycles' => 0, + 'removed' => 0, + 'would_remove' => 0, + 'marked_cleanup_eligible' => 0, + 'bytes_reclaimed' => 0, + 'lock_files_removed' => 0, + 'blocker_count' => 0, + 'blockers_by_reason' => array(), + ), + 'blockers' => array(), + 'evidence' => array( + 'safety' => $dry_run + ? 'Preview only. Uses DMC safe classifiers/removals and stale lock pruning in dry-run mode.' + : 'Applies only DMC safe classifiers/removals, refuses force and unpushed discard, and prunes stale DMC locks.', + ), + ); + + $lock_start = ( $this->lock_pruner )($dry_run); + if ( is_wp_error($lock_start) ) { + return $lock_start; + } + $result['steps']['lock_prune_start'] = $this->summarize_lock_step($lock_start); + $result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_start']['removed_count'] ?? 0 ); + + $common = array( + 'apply' => ! $dry_run, + 'force' => false, + 'discard_unpushed' => false, + 'limit' => $limit, + 'passes' => $passes, + 'source' => $source, + ); + if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { + $common['until_budget'] = trim( (string) $input['until_budget']); + } + + for ( $cycle = 1; $cycle <= $cycles; ++$cycle ) { + $result['summary']['cycles'] = $cycle; + $cycle_progress = 0; + + $eligible = $this->execute_ability($cleanup_eligible, $common); + if ( is_wp_error($eligible) ) { + return $eligible; + } + $result['steps'][ 'cleanup_eligible_' . $cycle ] = $this->summarize_cleanup_step($eligible); + $cycle_progress += $this->accumulate_cleanup_step($result, $eligible); + + $active = $this->execute_ability($active_no_signal, $common); + if ( is_wp_error($active) ) { + return $active; + } + $result['steps'][ 'active_no_signal_' . $cycle ] = $this->summarize_cleanup_step($active); + $cycle_progress += $this->accumulate_cleanup_step($result, $active); + + if ( $dry_run || 0 === $cycle_progress ) { + break; + } + } + + $lock_end = ( $this->lock_pruner )($dry_run); + if ( is_wp_error($lock_end) ) { + return $lock_end; + } + $result['steps']['lock_prune_end'] = $this->summarize_lock_step($lock_end); + $result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_end']['removed_count'] ?? 0 ); + + $result['blockers'] = $this->compact_blockers($result['blockers']); + $result['summary']['blocker_count'] = array_sum(array_map(static fn( array $row ): int => (int) ( $row['count'] ?? 0 ), $result['blockers'])); + $result['summary']['blockers_by_reason'] = array_column($result['blockers'], 'count', 'reason_code'); + if ( ! $dry_run && $result['summary']['blocker_count'] > 0 ) { + $result['state'] = 'complete_with_blockers'; + } else { + $result['state'] = 'complete'; + } + + return $result; + } + + private function resolve_ability( string $name ): mixed { + $ability = ( $this->ability_resolver )($name); + if ( ! is_object($ability) || ! is_callable(array( $ability, 'execute' )) ) { + return new \WP_Error('safe_cleanup_ability_missing', sprintf('Safe cleanup ability not available: %s', $name), array( 'status' => 500 )); + } + + return $ability; + } + + private function execute_ability( object $ability, array $input ): array|\WP_Error { + $result = $ability->execute($input); + return is_array($result) || is_wp_error($result) ? $result : new \WP_Error('safe_cleanup_invalid_result', 'Safe cleanup child ability returned an invalid result.', array( 'status' => 500 )); + } + + private function prune_locks( bool $dry_run ): array|\WP_Error { + $workspace = new Workspace(); + return WorkspaceMutationLock::prune_stale($workspace->get_path(), $dry_run); + } + + /** @return array */ + private function summarize_cleanup_step( array $step ): array { + $summary = (array) ( $step['summary'] ?? array() ); + return array( + 'mode' => (string) ( $step['mode'] ?? '' ), + 'applied' => ! empty($step['applied']) || ! empty($step['destructive']), + 'passes' => (int) ( $summary['passes'] ?? $step['executed_passes'] ?? 0 ), + 'processed' => (int) ( $summary['processed'] ?? $summary['scanned'] ?? 0 ), + 'removed' => (int) ( $summary['removed'] ?? 0 ), + 'would_remove' => (int) ( $summary['would_remove'] ?? 0 ), + 'marked_cleanup_eligible' => (int) ( $summary['marked_cleanup_eligible'] ?? 0 ), + 'bytes_reclaimed' => (int) ( $summary['bytes_reclaimed'] ?? 0 ), + 'blockers' => $this->extract_blocker_counts($step), + ); + } + + private function accumulate_cleanup_step( array &$result, array $step ): int { + $summary = (array) ( $step['summary'] ?? array() ); + foreach ( array( 'removed', 'would_remove', 'marked_cleanup_eligible', 'bytes_reclaimed' ) as $field ) { + $result['summary'][ $field ] += (int) ( $summary[ $field ] ?? 0 ); + } + + foreach ( $this->extract_blocker_counts($step) as $reason => $count ) { + $result['blockers'][] = array( + 'reason_code' => (string) $reason, + 'count' => (int) $count, + ); + } + + return (int) ( $summary['removed'] ?? 0 ) + (int) ( $summary['marked_cleanup_eligible'] ?? 0 ); + } + + /** @return array */ + private function extract_blocker_counts( array $step ): array { + $counts = array(); + $summary = (array) ( $step['summary'] ?? array() ); + foreach ( (array) ( $summary['blocked_by_reason'] ?? $summary['skipped_by_reason'] ?? array() ) as $reason => $count ) { + $counts[ (string) $reason ] = (int) $count; + } + foreach ( (array) ( $step['pass_results'] ?? array() ) as $pass ) { + if ( ! is_array($pass) ) { + continue; + } + foreach ( (array) ( $pass['skipped_by_reason'] ?? array() ) as $reason => $count ) { + $counts[ (string) $reason ] = (int) ( $counts[ (string) $reason ] ?? 0 ) + (int) $count; + } + } + foreach ( (array) ( $step['remaining_active_no_signal_backlog']['by_actionable_reason'] ?? array() ) as $reason => $row ) { + $counts[ (string) $reason ] = (int) ( $counts[ (string) $reason ] ?? 0 ) + (int) ( is_array($row) ? ( $row['count'] ?? 0 ) : 0 ); + } + + return array_filter($counts, static fn( int $count ): bool => $count > 0); + } + + /** @return array */ + private function summarize_lock_step( array $step ): array { + $status = (array) ( $step['after'] ?? $step ); + $fs = (array) ( $step['filesystem'] ?? $status['filesystem'] ?? array() ); + return array( + 'dry_run' => ! empty($step['dry_run']), + 'active' => (int) ( $status['active'] ?? 0 ), + 'stale' => (int) ( $status['stale'] ?? 0 ), + 'removed_count' => (int) ( $fs['removed_count'] ?? 0 ), + 'skipped_count' => (int) ( $fs['skipped_count'] ?? 0 ), + ); + } + + /** @param array> $rows */ + private function compact_blockers( array $rows ): array { + $blockers = array(); + foreach ( $rows as $row ) { + $reason = (string) ( $row['reason_code'] ?? 'unknown' ); + $blockers[ $reason ] ??= array( + 'reason_code' => $reason, + 'count' => 0, + ); + $blockers[ $reason ]['count'] += (int) ( $row['count'] ?? 0 ); + } + ksort($blockers); + + return array_values($blockers); + } +} diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php new file mode 100644 index 0000000..e5ddc9d --- /dev/null +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -0,0 +1,166 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + + public function get_error_code(): string { + return $this->code; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + +if ( ! function_exists('is_wp_error') ) { + function is_wp_error( mixed $thing ): bool { + return $thing instanceof WP_Error; + } +} + +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php'; + +final class SafeCleanupQueuedAbility { + /** @var array> */ + public array $calls = array(); + + /** @param array> $responses */ + public function __construct( private array $responses ) {} + + /** @return array */ + public function execute( array $input ): array { + $this->calls[] = $input; + return array_shift($this->responses) ?: array( + 'success' => true, + 'mode' => 'empty', + 'summary' => array(), + ); + } +} + +function safe_cleanup_assert( bool $condition, string $label ): void { + if ( ! $condition ) { + fwrite(STDERR, 'failed: ' . $label . PHP_EOL); + exit(1); + } +} + +$empty_ability = new SafeCleanupQueuedAbility(array()); +$orchestrator = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn() => $empty_ability, + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array( 'active' => 0, 'stale' => 0 ), 'filesystem' => array( 'removed_count' => 0 ) ) +); +$force_result = $orchestrator->run(array( 'force' => true )); +safe_cleanup_assert(is_wp_error($force_result), 'force is refused'); +safe_cleanup_assert('safe_cleanup_refuses_force' === $force_result->code, 'force refusal code'); + +$discard_result = $orchestrator->run(array( 'discard_unpushed' => true )); +safe_cleanup_assert(is_wp_error($discard_result), 'discard_unpushed is refused'); +safe_cleanup_assert('safe_cleanup_refuses_unpushed_discard' === $discard_result->code, 'discard refusal code'); + +$cleanup_eligible = new SafeCleanupQueuedAbility( + array( + array( + 'success' => true, + 'mode' => 'cleanup_eligible_drain', + 'summary' => array( + 'removed' => 1, + 'bytes_reclaimed' => 1024, + ), + 'pass_results' => array( + array( 'skipped_by_reason' => array( 'dirty_worktree' => 1 ) ), + ), + ), + array( + 'success' => true, + 'mode' => 'cleanup_eligible_drain', + 'summary' => array( 'removed' => 0 ), + ), + ) +); +$active_no_signal = new SafeCleanupQueuedAbility( + array( + array( + 'success' => true, + 'mode' => 'active_no_signal_drain', + 'summary' => array( + 'marked_cleanup_eligible' => 1, + 'removed' => 1, + 'blocked_by_reason' => array( 'unpushed_commits' => 2 ), + ), + ), + array( + 'success' => true, + 'mode' => 'active_no_signal_drain', + 'summary' => array( 'marked_cleanup_eligible' => 0, 'removed' => 0 ), + 'remaining_active_no_signal_backlog' => array( + 'by_actionable_reason' => array( + 'insufficient_signal' => array( 'count' => 3 ), + ), + ), + ), + ) +); +$lock_calls = array(); +$orchestrator = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => match ( $name ) { + 'datamachine-code/workspace-worktree-cleanup-eligible-drain' => $cleanup_eligible, + 'datamachine-code/workspace-worktree-active-no-signal-drain' => $active_no_signal, + default => null, + }, + static function ( bool $dry_run ) use ( &$lock_calls ): array { + $lock_calls[] = $dry_run; + return array( + 'dry_run' => $dry_run, + 'after' => array( 'active' => 0, 'stale' => 0 ), + 'filesystem' => array( 'removed_count' => $dry_run ? 0 : 1, 'skipped_count' => 0 ), + ); + } +); + +$result = $orchestrator->run(array( 'limit' => 7, 'passes' => 4, 'cycles' => 3 )); +safe_cleanup_assert(! is_wp_error($result), 'safe cleanup succeeds'); +safe_cleanup_assert(true === $result['applied'], 'safe cleanup applies by default'); +safe_cleanup_assert(2 === count($lock_calls), 'stale locks pruned before and after cleanup'); +safe_cleanup_assert(false === $lock_calls[0] && false === $lock_calls[1], 'lock pruning is destructive only in apply mode'); +safe_cleanup_assert(2 === count($cleanup_eligible->calls), 'cleanup eligible drain repeats until no progress'); +safe_cleanup_assert(false === $cleanup_eligible->calls[0]['force'], 'child force is false'); +safe_cleanup_assert(false === $cleanup_eligible->calls[0]['discard_unpushed'], 'child discard_unpushed is false'); +safe_cleanup_assert(2 === ( $result['summary']['removed'] ?? null ), 'removed rows are accumulated'); +safe_cleanup_assert(1 === ( $result['summary']['marked_cleanup_eligible'] ?? null ), 'marked cleanup eligible rows are accumulated'); +safe_cleanup_assert(2 === ( $result['summary']['lock_files_removed'] ?? null ), 'lock removals are accumulated'); +safe_cleanup_assert(6 === ( $result['summary']['blocker_count'] ?? null ), 'compact blockers are counted'); +safe_cleanup_assert(1 === ( $result['summary']['blockers_by_reason']['dirty_worktree'] ?? null ), 'dirty blocker count is preserved'); +safe_cleanup_assert(2 === ( $result['summary']['blockers_by_reason']['unpushed_commits'] ?? null ), 'unpushed blocker count is preserved'); +safe_cleanup_assert(3 === ( $result['summary']['blockers_by_reason']['insufficient_signal'] ?? null ), 'active backlog blocker count is preserved'); + +$preview_lock_calls = array(); +$preview = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn() => new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array( 'would_remove' => 1 ) ) )), + static function ( bool $dry_run ) use ( &$preview_lock_calls ): array { + $preview_lock_calls[] = $dry_run; + return array( 'dry_run' => $dry_run, 'after' => array( 'active' => 0, 'stale' => 1 ), 'filesystem' => array( 'removed_count' => 0 ) ); + } +); +$preview_result = $preview->run(array( 'dry_run' => true, 'cycles' => 3 )); +safe_cleanup_assert(! is_wp_error($preview_result), 'preview succeeds'); +safe_cleanup_assert(false === $preview_result['applied'], 'preview does not apply'); +safe_cleanup_assert(array( true, true ) === $preview_lock_calls, 'preview lock pruning stays dry-run'); +safe_cleanup_assert(1 === ( $preview_result['summary']['cycles'] ?? null ), 'preview runs one cycle'); + +fwrite(STDOUT, "workspace safe cleanup orchestrator test passed\n");