diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index f90263b..13b8729 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2228,6 +2228,7 @@ private function registerAbilities(): void { 'properties' => array( 'run_id' => array( 'type' => 'string' ), 'force' => array( 'type' => 'boolean' ), + 'limit' => array( 'type' => 'integer' ), ), ), 'output_schema' => array( 'type' => 'object' ), @@ -2250,6 +2251,7 @@ private function registerAbilities(): void { 'properties' => array( 'run_id' => array( 'type' => 'string' ), 'force' => array( 'type' => 'boolean' ), + 'limit' => array( 'type' => 'integer' ), ), ), 'output_schema' => array( 'type' => 'object' ), @@ -3489,7 +3491,7 @@ public static function workspaceCleanupPlan( array $input ): array|\WP_Error { * @return array|\WP_Error */ public static function workspaceCleanupApply( array $input ): array|\WP_Error { - return ( new CleanupRunService() )->apply( (string) ( $input['run_id'] ?? '' ), array( 'force' => ! empty($input['force']) )); + return ( new CleanupRunService() )->apply( (string) ( $input['run_id'] ?? '' ), self::cleanupRunApplyOptions($input)); } /** @@ -3519,7 +3521,21 @@ public static function workspaceCleanupEvidence( array $input ): array|\WP_Error * @return array|\WP_Error */ public static function workspaceCleanupResume( array $input ): array|\WP_Error { - return ( new CleanupRunService() )->resume( (string) ( $input['run_id'] ?? '' ), array( 'force' => ! empty($input['force']) )); + return ( new CleanupRunService() )->resume( (string) ( $input['run_id'] ?? '' ), self::cleanupRunApplyOptions($input)); + } + + /** + * Normalize bounded cleanup apply/resume options. + * + * @param array $input Ability input. + * @return array + */ + private static function cleanupRunApplyOptions( array $input ): array { + $options = array( 'force' => ! empty($input['force']) ); + if ( isset($input['limit']) ) { + $options['limit'] = (int) $input['limit']; + } + return $options; } /** diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 2fc47c9..cc7191f 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -327,10 +327,12 @@ public function adopt_repo( array $args, array $assoc_args ): void { * : Pass an age gate such as 7d or 24h into cleanup task params. * * [--limit=] - * : Maximum worktrees to scan in a `--mode=artifacts` page. Dry-run reviews - * scan this bounded page synchronously; apply runs freeze eligible candidates - * from the same bounded page and schedule only those candidates. Defaults to - * 100. Use 0 to disable the cap (combine with --exhaustive for a full audit). + * : For DB-backed `apply` / `resume`, maximum pending rows to process in this + * invocation (default 25, max 100). For `--mode=artifacts` pages, maximum + * worktrees to scan; dry-run reviews scan this bounded page synchronously, + * and apply runs freeze eligible candidates from the same bounded page. + * Artifact page scans default to 100. Use 0 to disable the artifact scan cap + * (combine with --exhaustive for a full audit). * * [--offset=] * : Pagination offset (0-indexed) for `--mode=artifacts` dry-run and apply @@ -547,7 +549,7 @@ private function run_cleanup_control_ability( string $operation, string $run_id, array( 'run_id' => $run_id, 'force' => ! empty($assoc_args['force']), - ) + ) + ( isset($assoc_args['limit']) ? array( 'limit' => (int) $assoc_args['limit'] ) : array() ) ); if ( is_wp_error($result) ) { WP_CLI::error($result->get_error_message()); diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index fa58e49..201a896 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -14,6 +14,9 @@ class CleanupRunService { + private const DEFAULT_APPLY_LIMIT = 25; + private const MAX_APPLY_LIMIT = 100; + public function __construct( @@ -80,6 +83,8 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error return new \WP_Error('cleanup_run_not_found', sprintf('Cleanup run not found: %s', $run_id), array( 'status' => 404 )); } + $limit = $this->apply_limit($opts); + $this->repository->update_run( $run_id, array( 'status' => 'applying', @@ -87,47 +92,80 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error ) ); - $items = $this->repository->get_items($run_id); - $artifact_rows = $this->pending_rows_of_type($items, 'artifact_cleanup'); - $worktree_rows = $this->pending_rows_of_type($items, 'worktree_removal'); - $results = array(); + $items = $this->repository->get_items($run_id); + $artifact_rows = $this->pending_rows_of_type($items, 'artifact_cleanup'); + $worktree_rows = $this->pending_rows_of_type($items, 'worktree_removal'); + $batch_type = ''; + $processed_rows = 0; + $remaining_rows = max(0, count($artifact_rows) + count($worktree_rows)); + $results = array(); if ( array() !== $artifact_rows ) { + $artifact_batch = array_slice($artifact_rows, 0, $limit); + $processed_rows += count($artifact_batch); + $batch_type = 'artifact_cleanup'; $results['artifact_cleanup'] = $this->workspace->worktree_cleanup_artifacts( array( - 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $artifact_rows) ), + 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $artifact_batch) ), 'force' => ! empty($opts['force']), - 'limit' => count($artifact_rows), + 'limit' => count($artifact_batch), ) ); - $this->record_apply_result($artifact_rows, $results['artifact_cleanup'], 'removed'); + $this->record_apply_result($artifact_batch, $results['artifact_cleanup'], 'removed'); } - if ( array() !== $worktree_rows ) { + $remaining_capacity = max(0, $limit - $processed_rows); + if ( $remaining_capacity > 0 && array() !== $worktree_rows ) { + $worktree_batch = array_slice($worktree_rows, 0, $remaining_capacity); + $processed_rows += count($worktree_batch); + $batch_type = '' === $batch_type ? 'worktree_removal' : 'mixed'; $results['worktree_removal'] = $this->workspace->worktree_cleanup_merged( array( - 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $worktree_rows) ), + 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $worktree_batch) ), 'skip_github' => true, ) ); - $this->record_apply_result($worktree_rows, $results['worktree_removal'], 'removed'); + $this->record_apply_result($worktree_batch, $results['worktree_removal'], 'removed'); } + $status = $this->status($run_id); + $summary = $status instanceof \WP_Error ? array() : ( $status['summary'] ?? array() ); + $pending_or_fail = (int) ( $summary['pending_or_failed'] ?? 0 ); + $next_status = $pending_or_fail > 0 ? 'needs_resume' : 'completed'; + $completed_at = 'completed' === $next_status ? gmdate('Y-m-d H:i:s') : null; + $this->repository->update_run( $run_id, array( - 'status' => 'completed', - 'completed_at' => gmdate('Y-m-d H:i:s'), - 'summary' => $this->status($run_id)['summary'] ?? array(), + 'status' => $next_status, + 'completed_at' => $completed_at, + 'summary' => $summary, ) ); + $status = $this->status($run_id); + if ( $status instanceof \WP_Error ) { + return $status; + } + return array( 'success' => true, - 'state' => 'completed', + 'state' => $next_status, 'run_id' => $run_id, - 'status' => 'completed', - 'results' => $results, - 'summary' => $this->status($run_id)['summary'] ?? array(), + 'status' => $next_status, + 'batch' => array( + 'type' => $batch_type, + 'limit' => $limit, + 'processed_rows' => $processed_rows, + 'remaining_before' => $remaining_rows, + 'remaining_after' => $pending_or_fail, + ), + 'results' => $results, + 'summary' => $status['summary'] ?? array(), + 'remaining_work_summary' => $status['remaining_work_summary'] ?? array(), + 'next' => $pending_or_fail > 0 ? array( + 'resume_command' => sprintf('studio wp datamachine-code workspace cleanup resume %s --limit=%d', $run_id, $limit), + 'pending_rows' => $pending_or_fail, + ) : null, ); } @@ -250,6 +288,11 @@ private function pending_rows_of_type( array $items, string $type ): array { return array_values(array_filter($items, fn( $item ) => (string) ( $item['item_type'] ?? '' ) === $type && in_array( (string) ( $item['status'] ?? '' ), array( 'pending', 'failed' ), true))); } + private function apply_limit( array $opts ): int { + $limit = isset($opts['limit']) ? (int) $opts['limit'] : self::DEFAULT_APPLY_LIMIT; + return max(1, min(self::MAX_APPLY_LIMIT, $limit)); + } + private function record_apply_result( array $items, mixed $result, string $applied_key ): void { if ( $result instanceof \WP_Error ) { foreach ( $items as $item ) { diff --git a/tests/smoke-cleanup-run-storage.php b/tests/smoke-cleanup-run-storage.php index 508228c..43ee1d4 100644 --- a/tests/smoke-cleanup-run-storage.php +++ b/tests/smoke-cleanup-run-storage.php @@ -50,7 +50,9 @@ function wp_json_encode( mixed $value, int $flags = 0 ): string|false if (! function_exists('wp_generate_password') ) { function wp_generate_password( int $length = 12 ): string { - return substr(str_repeat('a', $length), 0, $length); + static $counter = 0; + ++$counter; + return substr(str_repeat((string) $counter, $length), 0, $length); } } @@ -125,6 +127,9 @@ public function get_results( string $query, string $output = ARRAY_A ): array class DataMachineCodeCleanupRunFakeWorkspace extends \DataMachineCode\Workspace\Workspace { + public array $artifact_calls = array(); + public array $worktree_calls = array(); + public function __construct() { } @@ -156,17 +161,41 @@ public function workspace_cleanup_plan( array $opts = array() ): array|WP_Error } public function worktree_cleanup_artifacts( array $opts = array() ): array|WP_Error { + $this->artifact_calls[] = $opts; + $candidates = (array) ( $opts['apply_plan']['candidates'] ?? array() ); + $removed = array(); + $skipped = array(); + foreach ( $candidates as $candidate ) { + $handle = (string) ( is_array($candidate) ? ( $candidate['handle'] ?? '' ) : '' ); + if ('demo@old' === $handle ) { + $removed[] = array( 'handle' => 'demo@old', 'artifact_size_bytes' => 15 ); + } elseif ('demo@stale-artifact' === $handle ) { + $skipped[] = array( 'handle' => 'demo@stale-artifact', 'reason_code' => 'plan_mismatch', 'reason' => 'artifact plan no longer matches', 'artifact_size_bytes' => 9 ); + } + } return array( - 'removed' => array( array( 'handle' => 'demo@old', 'artifact_size_bytes' => 15 ) ), - 'skipped' => array( array( 'handle' => 'demo@stale-artifact', 'reason_code' => 'plan_mismatch', 'reason' => 'artifact plan no longer matches', 'artifact_size_bytes' => 9 ) ), + 'removed' => $removed, + 'skipped' => $skipped, 'summary' => array(), ); } public function worktree_cleanup_merged( array $opts = array() ): array|WP_Error { + $this->worktree_calls[] = $opts; + $candidates = (array) ( $opts['apply_plan']['candidates'] ?? array() ); + $removed = array(); + $skipped = array(); + foreach ( $candidates as $candidate ) { + $handle = (string) ( is_array($candidate) ? ( $candidate['handle'] ?? '' ) : '' ); + if ('demo@merged' === $handle ) { + $removed[] = array( 'handle' => 'demo@merged', 'size_bytes' => 20 ); + } elseif ('demo@dirty' === $handle ) { + $skipped[] = array( 'handle' => 'demo@dirty', 'reason_code' => 'dirty_worktree', 'reason' => 'working tree is dirty' ); + } + } return array( - 'removed' => array( array( 'handle' => 'demo@merged', 'size_bytes' => 20 ) ), - 'skipped' => array( array( 'handle' => 'demo@dirty', 'reason_code' => 'dirty_worktree', 'reason' => 'working tree is dirty' ) ), + 'removed' => $removed, + 'skipped' => $skipped, 'summary' => array(), ); } @@ -212,4 +241,23 @@ function datamachine_code_cleanup_run_assert( bool $condition, string $message ) datamachine_code_cleanup_run_assert(4 === (int) ( $evidence['remaining_work_summary']['blocked_resolvers_by_reason']['needs_metadata_reconcile']['count'] ?? 0 ), 'evidence keeps blocked resolver bucket'); datamachine_code_cleanup_run_assert(str_contains((string) wp_json_encode($evidence['remaining_work_summary']['recommended_commands']), 'workspace worktree reconcile-metadata'), 'summary recommends next DMC commands'); +$bounded_workspace = new DataMachineCodeCleanupRunFakeWorkspace(); +$bounded_service = new \DataMachineCode\Workspace\CleanupRunService($repo, $bounded_workspace); +$bounded_plan = $bounded_service->plan(array( 'mode' => 'retention' )); +datamachine_code_cleanup_run_assert(! is_wp_error($bounded_plan), 'bounded plan succeeds'); + +$bounded_apply = $bounded_service->apply($bounded_plan['run_id'], array( 'limit' => 1 )); +datamachine_code_cleanup_run_assert('needs_resume' === (string) ( $bounded_apply['status'] ?? '' ), 'bounded apply pauses when rows remain'); +datamachine_code_cleanup_run_assert(1 === (int) ( $bounded_apply['batch']['processed_rows'] ?? 0 ), 'bounded apply processes one row'); +datamachine_code_cleanup_run_assert(str_contains((string) ( $bounded_apply['next']['resume_command'] ?? '' ), 'workspace cleanup resume'), 'bounded apply returns resume command'); +datamachine_code_cleanup_run_assert(1 === count($bounded_workspace->artifact_calls), 'bounded apply only runs artifact cleanup first'); +datamachine_code_cleanup_run_assert(0 === count($bounded_workspace->worktree_calls), 'bounded apply defers worktree cleanup until artifacts drain'); + +$bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); +$bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); +$bounded_done = $bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); +datamachine_code_cleanup_run_assert('completed' === (string) ( $bounded_done['status'] ?? '' ), 'bounded resume completes final batch'); +datamachine_code_cleanup_run_assert(2 === count($bounded_workspace->artifact_calls), 'bounded resume drains artifact batches before worktrees'); +datamachine_code_cleanup_run_assert(2 === count($bounded_workspace->worktree_calls), 'bounded resume drains worktree batches after artifacts'); + echo "DB-backed cleanup run storage smoke passed.\n"; diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 81b417d..b297ce4 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -723,7 +723,9 @@ public function execute( array $input ): array $fail_job_ability = new FakeFailJobAbility(); $GLOBALS['__abilities'] = array( 'datamachine-code/workspace-cleanup-run' => $cleanup_run_ability, + 'datamachine-code/workspace-cleanup-apply' => $cleanup_status_ability, 'datamachine-code/workspace-cleanup-status' => $cleanup_status_ability, + 'datamachine-code/workspace-cleanup-resume' => $cleanup_status_ability, 'datamachine-code/workspace-hygiene-report' => $hygiene_ability, 'datamachine-code/workspace-worktree-cleanup' => $ability, 'datamachine-code/workspace-worktree-cleanup-artifacts' => $artifact_ability, @@ -802,6 +804,16 @@ public function execute( array $input ): array datamachine_code_cleanup_assert('cleanup-run-20260504193024-abc123' === ( $cleanup_status_ability->last_input['run_id'] ?? '' ), 'DB cleanup run IDs are routed to cleanup status ability'); datamachine_code_cleanup_assert('planned' === ( $db_status_json['state'] ?? '' ), 'DB cleanup run status does not route to job-backed status parser'); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->cleanup(array( 'apply', 'cleanup-run-20260504193024-abc123' ), array( 'limit' => 7, 'format' => 'json' )); + datamachine_code_cleanup_assert(7 === (int) ( $cleanup_status_ability->last_input['limit'] ?? 0 ), 'DB cleanup apply forwards bounded limit'); + + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->cleanup(array( 'resume', 'cleanup-run-20260504193024-abc123' ), array( 'limit' => 9, 'format' => 'json' )); + datamachine_code_cleanup_assert(9 === (int) ( $cleanup_status_ability->last_input['limit'] ?? 0 ), 'DB cleanup resume forwards bounded limit'); + WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->cleanup(array( 'status', 'cleanup-run-123' ), array( 'format' => 'json' ));