From 2a480b72f7def5e49be0260d0398f53e48c0a4a2 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:59:16 -0400 Subject: [PATCH 1/4] Expose safe cleanup progress run evidence --- inc/Abilities/WorkspaceAbilities.php | 3 + inc/Cli/Commands/WorkspaceCommand.php | 6 + inc/Workspace/CleanupRunService.php | 18 ++- .../WorkspaceSafeCleanupOrchestrator.php | 125 +++++++++++++++++- tests/workspace-safe-cleanup-orchestrator.php | 49 ++++++- 5 files changed, 196 insertions(+), 5 deletions(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 07bd2e35..58f0e0e2 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1697,12 +1697,15 @@ private function registerAbilities(): void { 'properties' => array( 'success' => array( 'type' => 'boolean' ), 'mode' => array( 'type' => 'string' ), + 'run_id' => array( 'type' => 'string' ), 'applied' => array( 'type' => 'boolean' ), 'destructive' => array( 'type' => 'boolean' ), 'summary' => array( 'type' => 'object' ), 'blockers' => array( 'type' => 'array' ), 'evidence' => array( 'type' => 'object' ), 'steps' => array( 'type' => 'object' ), + 'commands' => array( 'type' => 'object' ), + 'continuation' => array( 'type' => 'object' ), 'state' => array( 'type' => 'string' ), ), ), diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 5b3fd4b8..63c6337b 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -827,6 +827,12 @@ private function run_cleanup_safe( array $assoc_args ): void { if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) { $input['until_budget'] = trim( (string) $assoc_args['until-budget']); } + if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) { + $input['progress_callback'] = function ( array $progress ) use ( $assoc_args ): void { + $this->render_cleanup_safe_result($progress, $assoc_args); + $this->flush_cli_output(); + }; + } $ability = wp_get_ability('datamachine-code/workspace-cleanup-safe'); if ( ! $ability ) { diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index 04cfa6cd..fc8f0a88 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -626,9 +626,10 @@ private function run_progress( array $run, array $items, array $summary ): array 'applying_rows' => count($applying), 'applying_examples' => $examples, 'pending_or_failed' => (int) ( $summary['pending_or_failed'] ?? 0 ), + 'safe_cleanup' => $run['summary']['safe_cleanup_progress'] ?? null, 'started_at' => $started_at, 'age_seconds' => $age, - 'resumable' => $resumable, + 'resumable' => $resumable || ( 'safe_workspace_cleanup' === (string) ( $run['mode'] ?? '' ) && 'applying' === $run_status ), 'note' => count($applying) > 0 ? 'Rows marked applying are safe to retry with workspace cleanup resume if the previous apply process was interrupted.' : '', ); } @@ -643,6 +644,21 @@ private function run_progress( array $run, array $items, array $summary ): array */ private function remaining_work_summary( string $run_id, array $items, array $progress ): array { $summary = CleanupRemainingWorkSummary::from_items($items); + $safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array(); + $safe_commands = is_array($safe_cleanup['commands'] ?? null) ? (array) $safe_cleanup['commands'] : array(); + if ( isset($safe_commands['status'], $safe_commands['resume']) ) { + $resume_command = array( + 'bucket' => 'safe_cleanup_continuation', + 'command' => (string) $safe_commands['status'], + 'apply' => (string) $safe_commands['resume'], + 'destructive' => false, + 'apply_destructive' => true, + 'why' => 'Inspect or continue the DMC safe cleanup run from durable progress evidence.', + ); + array_unshift($summary['recommended_commands'], $resume_command); + array_unshift($summary['next_commands'], (string) $resume_command['command'], (string) $resume_command['apply']); + $summary['next_commands'] = array_values(array_unique($summary['next_commands'])); + } if ( ! empty($progress['resumable']) ) { $resume_command = array( 'bucket' => 'current_run_resume', diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 07903df1..60d039fe 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -22,13 +22,18 @@ class WorkspaceSafeCleanupOrchestrator { /** @var callable */ private $lock_pruner; + /** @var object|null */ + private $run_repository; + /** * @param callable|null $ability_resolver Resolver receiving an ability name. * @param callable|null $lock_pruner Callback receiving a dry-run bool. + * @param object|null $run_repository Cleanup run repository override for tests. */ - public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null ) { + public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null, ?object $run_repository = null ) { $this->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' ); + $this->run_repository = $run_repository; } /** @@ -50,6 +55,7 @@ public function run( array $input ): array|\WP_Error { $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; + $progress_callback = isset($input['progress_callback']) && is_callable($input['progress_callback']) ? $input['progress_callback'] : null; $cleanup_eligible = $this->resolve_ability('datamachine-code/workspace-worktree-cleanup-eligible-drain'); if ( is_wp_error($cleanup_eligible) ) { @@ -88,12 +94,31 @@ public function run( array $input ): array|\WP_Error { ), ); + $run_id = $this->create_progress_run($result, $dry_run, $limit, $passes, $cycles, $source); + if ( $run_id instanceof \WP_Error ) { + return $run_id; + } + $result['run_id'] = $run_id; + $result['commands'] = $this->progress_commands($run_id, $dry_run, $limit, $passes, $cycles, $input); + $result['continuation'] = array( + 'run_id' => $run_id, + 'status_command' => $result['commands']['status'], + 'evidence_command' => $result['commands']['evidence'], + 'resume_command' => $result['commands']['resume'], + 'note' => 'If the client disconnects, inspect this run_id and rerun the resume command. Safe cleanup remains bounded and preserves dirty/unpushed blockers.', + ); + $this->checkpoint_progress($run_id, $result, 'applying'); + if ( null !== $progress_callback ) { + $progress_callback($this->early_progress_result($result)); + } + $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 ); + $this->checkpoint_progress($run_id, $result, 'applying'); $common = array( 'apply' => ! $dry_run, @@ -117,6 +142,7 @@ public function run( array $input ): array|\WP_Error { } $result['steps'][ 'cleanup_eligible_' . $cycle ] = $this->summarize_cleanup_step($eligible); $cycle_progress += $this->accumulate_cleanup_step($result, $eligible); + $this->checkpoint_progress($run_id, $result, 'applying'); $active = $this->execute_ability($active_no_signal, $common); if ( is_wp_error($active) ) { @@ -124,6 +150,7 @@ public function run( array $input ): array|\WP_Error { } $result['steps'][ 'active_no_signal_' . $cycle ] = $this->summarize_cleanup_step($active); $cycle_progress += $this->accumulate_cleanup_step($result, $active); + $this->checkpoint_progress($run_id, $result, 'applying'); if ( $dry_run || 0 === $cycle_progress ) { break; @@ -136,6 +163,7 @@ public function run( array $input ): array|\WP_Error { } $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 ); + $this->checkpoint_progress($run_id, $result, 'applying'); $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'])); @@ -145,10 +173,105 @@ public function run( array $input ): array|\WP_Error { } else { $result['state'] = 'complete'; } + $this->checkpoint_progress($run_id, $result, $result['state']); return $result; } + /** @return string|\WP_Error */ + private function create_progress_run( array $result, bool $dry_run, int $limit, int $passes, int $cycles, string $source ): string|\WP_Error { + $repository = $this->progress_repository(); + $run_id = $repository->create_run( + array( + 'mode' => 'safe_workspace_cleanup', + 'status' => 'applying', + 'started_at' => gmdate('Y-m-d H:i:s'), + 'policy' => array( + 'dry_run' => $dry_run, + 'force' => false, + 'discard_unpushed' => false, + 'limit' => $limit, + 'passes' => $passes, + 'cycles' => $cycles, + 'source' => $source, + ), + 'summary' => $this->progress_summary($result), + ) + ); + + return $run_id; + } + + private function checkpoint_progress( string $run_id, array $result, string $status ): void { + $repository = $this->progress_repository(); + $fields = array( + 'status' => $status, + 'summary' => $this->progress_summary($result), + ); + if ( in_array($status, array( 'complete', 'complete_with_blockers' ), true) ) { + $fields['completed_at'] = gmdate('Y-m-d H:i:s'); + } + $repository->update_run($run_id, $fields); + } + + private function progress_repository(): object { + if ( null === $this->run_repository ) { + $this->run_repository = new \DataMachineCode\Storage\CleanupRunRepository(); + } + + return $this->run_repository; + } + + /** @return array */ + private function progress_summary( array $result ): array { + return array( + 'safe_cleanup_progress' => array( + 'generated_at' => gmdate('c'), + 'state' => (string) ( $result['state'] ?? 'applying' ), + 'applied' => (bool) ( $result['applied'] ?? false ), + 'destructive' => (bool) ( $result['destructive'] ?? false ), + 'summary' => (array) ( $result['summary'] ?? array() ), + 'blockers' => (array) ( $result['blockers'] ?? array() ), + 'steps' => (array) ( $result['steps'] ?? array() ), + 'commands' => (array) ( $result['commands'] ?? array() ), + 'continuation' => (array) ( $result['continuation'] ?? array() ), + ), + ); + } + + /** @return array */ + private function progress_commands( string $run_id, bool $dry_run, int $limit, int $passes, int $cycles, array $input ): array { + $resume = sprintf('studio wp datamachine-code workspace cleanup safe --limit=%d --passes=%d --cycles=%d', $limit, $passes, $cycles); + if ( $dry_run ) { + $resume .= ' --dry-run'; + } + if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { + $resume .= ' --until-budget=' . trim( (string) $input['until_budget']); + } + + return array( + 'status' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), + 'evidence' => sprintf('studio wp datamachine-code workspace cleanup evidence %s --format=json', $run_id), + 'resume' => $resume . ' --format=json', + ); + } + + /** @return array */ + private function early_progress_result( array $result ): array { + return array( + 'success' => true, + 'mode' => (string) ( $result['mode'] ?? 'safe_workspace_cleanup' ), + 'state' => 'applying', + 'run_id' => (string) ( $result['run_id'] ?? '' ), + 'applied' => (bool) ( $result['applied'] ?? false ), + 'destructive' => (bool) ( $result['destructive'] ?? false ), + 'generated_at' => gmdate('c'), + 'summary' => (array) ( $result['summary'] ?? array() ), + 'commands' => (array) ( $result['commands'] ?? array() ), + 'continuation' => (array) ( $result['continuation'] ?? array() ), + ); + } + private function resolve_ability( string $name ): mixed { $ability = ( $this->ability_resolver )($name); if ( ! is_object($ability) || ! is_callable(array( $ability, 'execute' )) ) { diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php index 94ea4467..613903cc 100644 --- a/tests/workspace-safe-cleanup-orchestrator.php +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -76,6 +76,26 @@ public function execute( array $input ): array { } } +final class SafeCleanupFakeRunRepository { + /** @var array> */ + public array $runs = array(); + + /** @var array> */ + public array $updates = array(); + + public function create_run( array $run ): string { + $run_id = 'cleanup-run-safe-test'; + $this->runs[ $run_id ] = $run + array( 'run_id' => $run_id ); + return $run_id; + } + + public function update_run( string $run_id, array $fields ): bool { + $this->updates[] = array( 'run_id' => $run_id, 'fields' => $fields ); + $this->runs[ $run_id ] = array_merge($this->runs[ $run_id ] ?? array( 'run_id' => $run_id ), $fields); + return true; + } +} + function safe_cleanup_assert( bool $condition, string $label ): void { if ( ! $condition ) { fwrite(STDERR, 'failed: ' . $label . PHP_EOL); @@ -92,6 +112,8 @@ function safe_cleanup_assert( bool $condition, string $label ): void { safe_cleanup_assert(isset($safe_cleanup_ability['input_schema']['properties']['discard_unpushed']), 'safe cleanup ability documents discard refusal'); safe_cleanup_assert(isset($safe_cleanup_ability['output_schema']['properties']['summary']), 'safe cleanup ability documents summary output'); safe_cleanup_assert(isset($safe_cleanup_ability['output_schema']['properties']['blockers']), 'safe cleanup ability documents blockers output'); +safe_cleanup_assert(isset($safe_cleanup_ability['output_schema']['properties']['run_id']), 'safe cleanup ability documents run_id output'); +safe_cleanup_assert(isset($safe_cleanup_ability['output_schema']['properties']['continuation']), 'safe cleanup ability documents continuation output'); $ability_force_result = DataMachineCode\Abilities\WorkspaceAbilities::workspaceCleanupSafe(array( 'force' => true )); safe_cleanup_assert(is_wp_error($ability_force_result), 'safe cleanup ability callback executes orchestrator refusal'); @@ -154,6 +176,8 @@ function safe_cleanup_assert( bool $condition, string $label ): void { ) ); $lock_calls = array(); +$run_repository = new SafeCleanupFakeRunRepository(); +$progress_envelopes = array(); $orchestrator = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( static fn( string $name ) => match ( $name ) { 'datamachine-code/workspace-worktree-cleanup-eligible-drain' => $cleanup_eligible, @@ -167,12 +191,27 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { 'after' => array( 'active' => 0, 'stale' => 0 ), 'filesystem' => array( 'removed_count' => $dry_run ? 0 : 1, 'skipped_count' => 0 ), ); - } + }, + $run_repository ); -$result = $orchestrator->run(array( 'limit' => 7, 'passes' => 4, 'cycles' => 3 )); +$result = $orchestrator->run( + array( + 'limit' => 7, + 'passes' => 4, + 'cycles' => 3, + 'progress_callback' => static function ( array $progress ) use ( &$progress_envelopes ): void { + $progress_envelopes[] = $progress; + }, + ) +); safe_cleanup_assert(! is_wp_error($result), 'safe cleanup succeeds'); safe_cleanup_assert(true === $result['applied'], 'safe cleanup applies by default'); +safe_cleanup_assert('cleanup-run-safe-test' === ( $result['run_id'] ?? null ), 'safe cleanup returns durable run id'); +safe_cleanup_assert('cleanup-run-safe-test' === ( $progress_envelopes[0]['run_id'] ?? null ), 'safe cleanup emits early run id before long child work'); +safe_cleanup_assert('applying' === ( $progress_envelopes[0]['state'] ?? null ), 'early progress reports applying state'); +safe_cleanup_assert(str_contains((string) ( $result['continuation']['status_command'] ?? '' ), 'workspace cleanup status cleanup-run-safe-test'), 'continuation exposes status command'); +safe_cleanup_assert(str_contains((string) ( $result['continuation']['resume_command'] ?? '' ), 'workspace cleanup safe --limit=7 --passes=4 --cycles=3'), 'continuation exposes safe resume command'); 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'); @@ -185,6 +224,9 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { 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'); +safe_cleanup_assert(count($run_repository->updates) >= 5, 'safe cleanup checkpoints progress repeatedly'); +safe_cleanup_assert('complete_with_blockers' === ( $run_repository->runs['cleanup-run-safe-test']['status'] ?? null ), 'safe cleanup persists final run state'); +safe_cleanup_assert(2 === ( $run_repository->runs['cleanup-run-safe-test']['summary']['safe_cleanup_progress']['summary']['removed'] ?? null ), 'safe cleanup persists reclaimed progress summary'); $preview_lock_calls = array(); $preview = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( @@ -192,7 +234,8 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { 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 ) ); - } + }, + new SafeCleanupFakeRunRepository() ); $preview_result = $preview->run(array( 'dry_run' => true, 'cycles' => 3 )); safe_cleanup_assert(! is_wp_error($preview_result), 'preview succeeds'); From d20fa6579a47120be4177d4a38bb25db762001bd Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:48:36 -0400 Subject: [PATCH 2/4] Fix safe cleanup lint issues --- inc/Abilities/WorkspaceAbilities.php | 22 +++---- inc/Storage/CleanupRunRepository.php | 15 ++++- inc/Workspace/CleanupRunService.php | 4 +- .../WorkspaceSafeCleanupOrchestrator.php | 58 +++++++++---------- tests/workspace-safe-cleanup-orchestrator.php | 3 +- 5 files changed, 58 insertions(+), 44 deletions(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 58f0e0e2..70218523 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1695,18 +1695,18 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'mode' => array( 'type' => 'string' ), - 'run_id' => array( 'type' => 'string' ), - 'applied' => array( 'type' => 'boolean' ), - 'destructive' => array( 'type' => 'boolean' ), - 'summary' => array( 'type' => 'object' ), - 'blockers' => array( 'type' => 'array' ), - 'evidence' => array( 'type' => 'object' ), - 'steps' => array( 'type' => 'object' ), - 'commands' => array( 'type' => 'object' ), + 'success' => array( 'type' => 'boolean' ), + 'mode' => array( 'type' => 'string' ), + 'run_id' => array( 'type' => 'string' ), + 'applied' => array( 'type' => 'boolean' ), + 'destructive' => array( 'type' => 'boolean' ), + 'summary' => array( 'type' => 'object' ), + 'blockers' => array( 'type' => 'array' ), + 'evidence' => array( 'type' => 'object' ), + 'steps' => array( 'type' => 'object' ), + 'commands' => array( 'type' => 'object' ), 'continuation' => array( 'type' => 'object' ), - 'state' => array( 'type' => 'string' ), + 'state' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( self::class, 'workspaceCleanupSafe' ), diff --git a/inc/Storage/CleanupRunRepository.php b/inc/Storage/CleanupRunRepository.php index 4313d4b0..ecff783a 100644 --- a/inc/Storage/CleanupRunRepository.php +++ b/inc/Storage/CleanupRunRepository.php @@ -15,9 +15,22 @@ require_once dirname(__DIR__) . '/Support/JsonCodec.php'; } -class CleanupRunRepository { +interface CleanupRunRepositoryInterface { + /** + * @param array $run Run fields. + * @return string|\WP_Error + */ + public function create_run( array $run ): string|\WP_Error; + + /** + * @param array $fields Run fields. + */ + public function update_run( string $run_id, array $fields ): bool; + +} +class CleanupRunRepository implements CleanupRunRepositoryInterface { /** * Create a cleanup run. diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index fc8f0a88..df6cc7d5 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -643,8 +643,8 @@ private function run_progress( array $run, array $items, array $summary ): array * @return array */ private function remaining_work_summary( string $run_id, array $items, array $progress ): array { - $summary = CleanupRemainingWorkSummary::from_items($items); - $safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array(); + $summary = CleanupRemainingWorkSummary::from_items($items); + $safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array(); $safe_commands = is_array($safe_cleanup['commands'] ?? null) ? (array) $safe_cleanup['commands'] : array(); if ( isset($safe_commands['status'], $safe_commands['resume']) ) { $resume_command = array( diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 60d039fe..72943b5b 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -22,15 +22,15 @@ class WorkspaceSafeCleanupOrchestrator { /** @var callable */ private $lock_pruner; - /** @var object|null */ + /** @var \DataMachineCode\Storage\CleanupRunRepositoryInterface|null */ private $run_repository; /** * @param callable|null $ability_resolver Resolver receiving an ability name. * @param callable|null $lock_pruner Callback receiving a dry-run bool. - * @param object|null $run_repository Cleanup run repository override for tests. + * @param \DataMachineCode\Storage\CleanupRunRepositoryInterface|null $run_repository Cleanup run repository override for tests. */ - public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null, ?object $run_repository = null ) { + public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null, ?\DataMachineCode\Storage\CleanupRunRepositoryInterface $run_repository = null ) { $this->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' ); $this->run_repository = $run_repository; @@ -50,11 +50,11 @@ public function run( array $input ): array|\WP_Error { 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; + $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; $progress_callback = isset($input['progress_callback']) && is_callable($input['progress_callback']) ? $input['progress_callback'] : null; $cleanup_eligible = $this->resolve_ability('datamachine-code/workspace-worktree-cleanup-eligible-drain'); @@ -116,7 +116,7 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($lock_start) ) { return $lock_start; } - $result['steps']['lock_prune_start'] = $this->summarize_lock_step($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 ); $this->checkpoint_progress($run_id, $result, 'applying'); @@ -134,14 +134,14 @@ public function run( array $input ): array|\WP_Error { for ( $cycle = 1; $cycle <= $cycles; ++$cycle ) { $result['summary']['cycles'] = $cycle; - $cycle_progress = 0; + $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); + $cycle_progress += $this->accumulate_cleanup_step($result, $eligible); $this->checkpoint_progress($run_id, $result, 'applying'); $active = $this->execute_ability($active_no_signal, $common); @@ -149,7 +149,7 @@ public function run( array $input ): array|\WP_Error { return $active; } $result['steps'][ 'active_no_signal_' . $cycle ] = $this->summarize_cleanup_step($active); - $cycle_progress += $this->accumulate_cleanup_step($result, $active); + $cycle_progress += $this->accumulate_cleanup_step($result, $active); $this->checkpoint_progress($run_id, $result, 'applying'); if ( $dry_run || 0 === $cycle_progress ) { @@ -161,12 +161,12 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($lock_end) ) { return $lock_end; } - $result['steps']['lock_prune_end'] = $this->summarize_lock_step($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 ); $this->checkpoint_progress($run_id, $result, 'applying'); - $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['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'; @@ -214,7 +214,7 @@ private function checkpoint_progress( string $run_id, array $result, string $sta $repository->update_run($run_id, $fields); } - private function progress_repository(): object { + private function progress_repository(): \DataMachineCode\Storage\CleanupRunRepositoryInterface { if ( null === $this->run_repository ) { $this->run_repository = new \DataMachineCode\Storage\CleanupRunRepository(); } @@ -226,15 +226,15 @@ private function progress_repository(): object { private function progress_summary( array $result ): array { return array( 'safe_cleanup_progress' => array( - 'generated_at' => gmdate('c'), - 'state' => (string) ( $result['state'] ?? 'applying' ), - 'applied' => (bool) ( $result['applied'] ?? false ), - 'destructive' => (bool) ( $result['destructive'] ?? false ), - 'summary' => (array) ( $result['summary'] ?? array() ), - 'blockers' => (array) ( $result['blockers'] ?? array() ), - 'steps' => (array) ( $result['steps'] ?? array() ), - 'commands' => (array) ( $result['commands'] ?? array() ), - 'continuation' => (array) ( $result['continuation'] ?? array() ), + 'generated_at' => gmdate('c'), + 'state' => (string) ( $result['state'] ?? 'applying' ), + 'applied' => (bool) ( $result['applied'] ?? false ), + 'destructive' => (bool) ( $result['destructive'] ?? false ), + 'summary' => (array) ( $result['summary'] ?? array() ), + 'blockers' => (array) ( $result['blockers'] ?? array() ), + 'steps' => (array) ( $result['steps'] ?? array() ), + 'commands' => (array) ( $result['commands'] ?? array() ), + 'continuation' => (array) ( $result['continuation'] ?? array() ), ), ); } @@ -282,11 +282,11 @@ private function resolve_ability( string $name ): mixed { } private function execute_ability( object $ability, array $input ): array|\WP_Error { - $result = $ability->execute($input); + $result = call_user_func(array( $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 { + private function prune_locks( bool $dry_run ): array { $workspace = new Workspace(); return WorkspaceMutationLock::prune_stale($workspace->get_path(), $dry_run); } @@ -325,7 +325,7 @@ private function accumulate_cleanup_step( array &$result, array $step ): int { /** @return array */ private function extract_blocker_counts( array $step ): array { - $counts = 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; @@ -362,7 +362,7 @@ private function summarize_lock_step( array $step ): array { private function compact_blockers( array $rows ): array { $blockers = array(); foreach ( $rows as $row ) { - $reason = (string) ( $row['reason_code'] ?? 'unknown' ); + $reason = (string) ( $row['reason_code'] ?? 'unknown' ); $blockers[ $reason ] ??= array( 'reason_code' => $reason, 'count' => 0, diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php index 613903cc..6d6c0bf4 100644 --- a/tests/workspace-safe-cleanup-orchestrator.php +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -55,6 +55,7 @@ function add_action( string $hook, callable $callback ): void { } } +require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepository.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php'; require_once dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'; @@ -76,7 +77,7 @@ public function execute( array $input ): array { } } -final class SafeCleanupFakeRunRepository { +final class SafeCleanupFakeRunRepository implements \DataMachineCode\Storage\CleanupRunRepositoryInterface { /** @var array> */ public array $runs = array(); From 0b8afb70857dfcc8f119fb90ada750d77242440b Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:54:17 -0400 Subject: [PATCH 3/4] Fix safe cleanup repository contract lint --- inc/Storage/CleanupRunRepository.php | 17 ++--------- inc/Storage/CleanupRunRepositoryInterface.php | 30 +++++++++++++++++++ .../WorkspaceSafeCleanupOrchestrator.php | 18 ++++++----- tests/workspace-safe-cleanup-orchestrator.php | 2 +- 4 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 inc/Storage/CleanupRunRepositoryInterface.php diff --git a/inc/Storage/CleanupRunRepository.php b/inc/Storage/CleanupRunRepository.php index ecff783a..85251f7e 100644 --- a/inc/Storage/CleanupRunRepository.php +++ b/inc/Storage/CleanupRunRepository.php @@ -15,19 +15,8 @@ require_once dirname(__DIR__) . '/Support/JsonCodec.php'; } -interface CleanupRunRepositoryInterface { - - /** - * @param array $run Run fields. - * @return string|\WP_Error - */ - public function create_run( array $run ): string|\WP_Error; - - /** - * @param array $fields Run fields. - */ - public function update_run( string $run_id, array $fields ): bool; - +if ( ! interface_exists(CleanupRunRepositoryInterface::class) ) { + require_once __DIR__ . '/CleanupRunRepositoryInterface.php'; } class CleanupRunRepository implements CleanupRunRepositoryInterface { @@ -73,7 +62,7 @@ public function create_run( array $run ): string|\WP_Error { * Insert planned cleanup items. * * @param string $run_id Run ID. - * @param array $items Items. + * @param array $items Items. * @return int|\WP_Error */ public function add_items( string $run_id, array $items ): int|\WP_Error { diff --git a/inc/Storage/CleanupRunRepositoryInterface.php b/inc/Storage/CleanupRunRepositoryInterface.php new file mode 100644 index 00000000..d27c4af1 --- /dev/null +++ b/inc/Storage/CleanupRunRepositoryInterface.php @@ -0,0 +1,30 @@ + $run Run fields. + * @return string|\WP_Error + */ + public function create_run( array $run ): string|\WP_Error; + + /** + * Update a cleanup run. + * + * @param string $run_id Run ID. + * @param array $fields Run fields. + */ + public function update_run( string $run_id, array $fields ): bool; + +} diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 72943b5b..92dc460f 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -116,7 +116,7 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($lock_start) ) { return $lock_start; } - $result['steps']['lock_prune_start'] = $this->summarize_lock_step($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 ); $this->checkpoint_progress($run_id, $result, 'applying'); @@ -141,7 +141,7 @@ public function run( array $input ): array|\WP_Error { return $eligible; } $result['steps'][ 'cleanup_eligible_' . $cycle ] = $this->summarize_cleanup_step($eligible); - $cycle_progress += $this->accumulate_cleanup_step($result, $eligible); + $cycle_progress += $this->accumulate_cleanup_step($result, $eligible); $this->checkpoint_progress($run_id, $result, 'applying'); $active = $this->execute_ability($active_no_signal, $common); @@ -149,7 +149,7 @@ public function run( array $input ): array|\WP_Error { return $active; } $result['steps'][ 'active_no_signal_' . $cycle ] = $this->summarize_cleanup_step($active); - $cycle_progress += $this->accumulate_cleanup_step($result, $active); + $cycle_progress += $this->accumulate_cleanup_step($result, $active); $this->checkpoint_progress($run_id, $result, 'applying'); if ( $dry_run || 0 === $cycle_progress ) { @@ -161,7 +161,7 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($lock_end) ) { return $lock_end; } - $result['steps']['lock_prune_end'] = $this->summarize_lock_step($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 ); $this->checkpoint_progress($run_id, $result, 'applying'); @@ -282,7 +282,11 @@ private function resolve_ability( string $name ): mixed { } private function execute_ability( object $ability, array $input ): array|\WP_Error { - $result = call_user_func(array( $ability, 'execute' ), $input); + $executor = array( $ability, 'execute' ); + if ( ! is_callable($executor) ) { + return new \WP_Error('safe_cleanup_ability_missing', 'Safe cleanup ability is not executable.', array( 'status' => 500 )); + } + $result = $executor($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 )); } @@ -362,8 +366,8 @@ private function summarize_lock_step( array $step ): array { private function compact_blockers( array $rows ): array { $blockers = array(); foreach ( $rows as $row ) { - $reason = (string) ( $row['reason_code'] ?? 'unknown' ); - $blockers[ $reason ] ??= array( + $reason = (string) ( $row['reason_code'] ?? 'unknown' ); + $blockers[ $reason ] ??= array( 'reason_code' => $reason, 'count' => 0, ); diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php index 6d6c0bf4..c95949df 100644 --- a/tests/workspace-safe-cleanup-orchestrator.php +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -55,7 +55,7 @@ function add_action( string $hook, callable $callback ): void { } } -require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepository.php'; +require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepositoryInterface.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php'; require_once dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'; From 85728437d7badf8c2aabeefde87d9a48af36391a Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:57:22 -0400 Subject: [PATCH 4/4] Fix final safe cleanup lint nits --- inc/Storage/CleanupRunRepositoryInterface.php | 1 - inc/Workspace/WorkspaceSafeCleanupOrchestrator.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/inc/Storage/CleanupRunRepositoryInterface.php b/inc/Storage/CleanupRunRepositoryInterface.php index d27c4af1..7ca0eb69 100644 --- a/inc/Storage/CleanupRunRepositoryInterface.php +++ b/inc/Storage/CleanupRunRepositoryInterface.php @@ -26,5 +26,4 @@ public function create_run( array $run ): string|\WP_Error; * @param array $fields Run fields. */ public function update_run( string $run_id, array $fields ): bool; - } diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 92dc460f..0545927b 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -367,7 +367,7 @@ private function compact_blockers( array $rows ): array { $blockers = array(); foreach ( $rows as $row ) { $reason = (string) ( $row['reason_code'] ?? 'unknown' ); - $blockers[ $reason ] ??= array( + $blockers[ $reason ] ??= array( 'reason_code' => $reason, 'count' => 0, );