diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 7042a533..ed165c87 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1717,6 +1717,9 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Allow pruning rows with unpushed_count > 0 or a non-empty pr_url. Default false.', ), + 'limit' => array( 'type' => 'integer', 'description' => 'Maximum missing inventory rows to inspect. Default 25, maximum 200.' ), + 'after_handle' => array( 'type' => 'string', 'description' => 'Last processed handle for a stable missing-inventory keyset continuation.' ), + 'until_budget' => array( 'type' => 'string', 'description' => 'Optional compact wall-clock budget such as 30s.' ), ), ), 'output_schema' => array( @@ -1728,6 +1731,7 @@ private function registerAbilities(): void { 'deleted' => array( 'type' => 'array' ), 'skipped' => array( 'type' => 'array' ), 'summary' => array( 'type' => 'object' ), + 'continuation' => array( 'type' => 'object' ), ), ), 'execute_callback' => array( self::class, 'worktreeInventoryPruneMissing' ), @@ -1795,14 +1799,14 @@ private function registerAbilities(): void { 'datamachine-code/workspace-cleanup-safe', array( 'label' => 'Run Safe Workspace Cleanup', - 'description' => 'Run the canonical DMC safe workspace cleanup flow. Uses DMC safe classifiers/removals, refuses force and unpushed discard, and reports remaining blockers.', + 'description' => 'Run the canonical DMC safe workspace cleanup flow. Uses DMC safe classifiers/removals and missing-inventory pruning with fresh path revalidation, refuses force and unpushed discard, and reports remaining blockers.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'dry_run' => array( 'type' => 'boolean', - 'description' => 'Preview safe cleanup without removing worktrees or stale DMC lock files.', + 'description' => 'Preview safe cleanup without removing worktrees, inventory rows, or stale DMC lock files.', ), 'limit' => array( 'type' => 'integer', @@ -1816,6 +1820,7 @@ private function registerAbilities(): void { 'type' => 'integer', 'description' => 'Maximum safe cleanup cycles before stopping. Clamped by the orchestrator.', ), + 'inventory_after' => array( 'type' => 'string', 'description' => 'Last processed missing-inventory handle for a safe-cleanup keyset continuation.' ), 'until_budget' => array( 'type' => 'string', 'description' => 'Optional child-drain time budget such as 30s.', @@ -4248,12 +4253,21 @@ public static function worktreeInventoryRefresh( array $input ): array|\WP_Error */ public static function worktreeInventoryPruneMissing( array $input ): array|\WP_Error { $workspace = new Workspace(); - return $workspace->worktree_inventory_prune_missing( - array( - 'dry_run' => ! empty($input['dry_run']), - 'force' => ! empty($input['force']), - ) + $opts = array( + 'dry_run' => ! empty($input['dry_run']), + 'force' => ! empty($input['force']), ); + if ( isset($input['limit']) ) { + $opts['limit'] = (int) $input['limit']; + } + if ( isset($input['after_handle']) ) { + $opts['after_handle'] = (string) $input['after_handle']; + } + if ( isset($input['until_budget']) ) { + $opts['until_budget'] = (string) $input['until_budget']; + } + + return $workspace->worktree_inventory_prune_missing($opts); } /** diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 9ec80ca9..06eb2d24 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -897,6 +897,9 @@ private function run_cleanup_safe( array $assoc_args ): void { $input[ $key ] = (int) $assoc_args[ $key ]; } } + if ( isset($assoc_args['inventory-after']) ) { + $input['inventory_after'] = (string) $assoc_args['inventory-after']; + } if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) { $input['until_budget'] = trim( (string) $assoc_args['until-budget']); } @@ -960,6 +963,9 @@ private function render_cleanup_safe_result( array $result, array $assoc_args ): 'metric' => 'marked_cleanup_eligible', 'value' => (string) ( $summary['marked_cleanup_eligible'] ?? 0 ), ), + array( 'metric' => 'inventory_rows_pruned', 'value' => (string) ( $summary['inventory_rows_pruned'] ?? 0 ) ), + array( 'metric' => 'inventory_rows_planned', 'value' => (string) ( $summary['inventory_rows_planned'] ?? 0 ) ), + array( 'metric' => 'inventory_rows_skipped', 'value' => (string) ( $summary['inventory_rows_skipped'] ?? 0 ) ), array( 'metric' => 'bytes_reclaimed', 'value' => $this->format_bytes( (int) ( $summary['bytes_reclaimed'] ?? 0 ) ), @@ -2472,6 +2478,15 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign * [--force] * : (prune-missing) Allow pruning rows with unpushed commits or an open PR. * + * [--limit=] + * : (prune-missing) Maximum rows to inspect. Default 25, maximum 200. + * + * [--after-handle=] + * : (prune-missing) Last processed handle for bounded keyset continuation. + * + * [--until-budget=] + * : (prune-missing) Optional compact wall-clock budget such as 30s. + * * [--format=] * : Output format. * --- @@ -2543,6 +2558,16 @@ private function inventory_refresh( array $assoc_args ): void { private function inventory_prune_missing( array $assoc_args ): void { $dry_run = ! empty($assoc_args['dry-run']); $force = ! empty($assoc_args['force']); + $opts = array( 'dry_run' => $dry_run, 'force' => $force ); + if ( isset($assoc_args['limit']) ) { + $opts['limit'] = (int) $assoc_args['limit']; + } + if ( isset($assoc_args['after-handle']) ) { + $opts['after_handle'] = (string) $assoc_args['after-handle']; + } + if ( isset($assoc_args['until-budget']) ) { + $opts['until_budget'] = (string) $assoc_args['until-budget']; + } $ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-prune-missing'); if ( ! $ability ) { @@ -2552,7 +2577,7 @@ private function inventory_prune_missing( array $assoc_args ): void { // A dry-run preview never mutates, so it does not need confirmation. if ( ! $dry_run && empty($assoc_args['yes']) ) { - $preview = $ability->execute(array( 'dry_run' => true )); + $preview = $ability->execute(array_merge($opts, array( 'dry_run' => true ))); if ( is_wp_error($preview) ) { WP_CLI::error($preview->get_error_message()); return; @@ -2569,12 +2594,7 @@ private function inventory_prune_missing( array $assoc_args ): void { WP_CLI::confirm(sprintf('Prune %d missing_path inventory row(s) (%d skipped)? Pass --yes to skip this prompt.', $would_delete, $would_skip)); } - $result = $ability->execute( - array( - 'dry_run' => $dry_run, - 'force' => $force, - ) - ); + $result = $ability->execute($opts); if ( is_wp_error($result) ) { WP_CLI::error($result->get_error_message()); return; diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index 7b83bf54..79ff0969 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -258,24 +258,55 @@ public function mark_missing( string $handle ): bool { * STILL absent (a stale missing_path flag alone is not trusted). * - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url * unless 'force' is true; such rows are reported as skipped. + * - Preserves rows with creator/owner provenance and malformed paths because + * their absent local path is not sufficient ownership evidence. * - * @param array{dry_run?: bool, force?: bool} $opts Options. + * @param array{dry_run?: bool, force?: bool, limit?: int, after_handle?: string, until_budget?: string, lock_callback?: callable, workspace_root?: string} $opts Options. * @return array Result with deleted/skipped lists and summary. */ public function pruneMissing( array $opts = array() ): array { - $dry_run = ! empty($opts['dry_run']); - $force = ! empty($opts['force']); - - $rows = $this->missing_path_rows(); + $dry_run = ! empty($opts['dry_run']); + $force = ! empty($opts['force']); + $limit = isset($opts['limit']) ? max(1, min(200, (int) $opts['limit'])) : 25; + $after_handle = isset($opts['after_handle']) ? trim( (string) $opts['after_handle'] ) : ''; + $deadline = $this->prune_deadline($opts['until_budget'] ?? null); + + $rows = $this->missing_path_rows($limit + 1, $after_handle); + $has_more = count($rows) > $limit; + $rows = array_slice($rows, 0, $limit); $deleted = array(); $skipped = array(); + $last_handle = $after_handle; foreach ( $rows as $row ) { + if ( null !== $deadline && microtime(true) >= $deadline ) { + $has_more = true; + break; + } $handle = (string) ( $row['handle'] ?? '' ); - $path = (string) ( $row['path'] ?? '' ); + $last_handle = $handle; + $path = trim( (string) ( $row['path'] ?? '' ) ); + + if ( ! $this->is_prunable_path($path, $opts['workspace_root'] ?? null) ) { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'invalid_path', + ); + continue; + } + + if ( $this->has_owner_managed_provenance($row) ) { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'owner_managed', + ); + continue; + } // Re-probe the disk: only reap when the path is STILL absent. - if ( '' !== $path && is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir + if ( is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir $skipped[] = array( 'handle' => $handle, 'path' => $path, @@ -319,7 +350,42 @@ public function pruneMissing( array $opts = array() ): array { continue; } - if ( $this->delete($handle) ) { + $mutation = function () use ( $handle, $path, $opts, $force ): array { + $current = $this->get($handle); + if ( ! is_array($current) || ! $this->is_prunable_path($path, $opts['workspace_root'] ?? null) ) { + return array( 'deleted' => false, 'reason' => 'conditional_delete_mismatch' ); + } + if ( $this->has_owner_managed_provenance($current) ) { + return array( 'deleted' => false, 'reason' => 'owner_managed' ); + } + if ( ! $force && (int) ( $current['unpushed_count'] ?? 0 ) > 0 ) { + return array( 'deleted' => false, 'reason' => 'unpushed_count' ); + } + if ( ! $force && '' !== trim( (string) ( $current['pr_url'] ?? '' ) ) ) { + return array( 'deleted' => false, 'reason' => 'pr_url' ); + } + // Recheck while holding the lifecycle mutation lock before conditional deletion. + if ( is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir + return array( 'deleted' => false, 'reason' => 'path_present_on_disk' ); + } + + return $this->delete_missing_if_current($handle, $path, $force) + ? array( 'deleted' => true ) + : array( 'deleted' => false, 'reason' => 'conditional_delete_mismatch' ); + }; + $mutation_result = isset($opts['lock_callback']) + ? $opts['lock_callback']($row, $mutation) + : $mutation(); + if ( $mutation_result instanceof \WP_Error ) { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'workspace_lock_unavailable', + ); + continue; + } + + if ( ! empty($mutation_result['deleted']) ) { $deleted[] = array( 'handle' => $handle, 'path' => $path, @@ -329,12 +395,13 @@ public function pruneMissing( array $opts = array() ): array { $skipped[] = array( 'handle' => $handle, 'path' => $path, - 'reason' => 'delete_failed', + 'reason' => is_array($mutation_result) ? (string) ( $mutation_result['reason'] ?? 'delete_failed' ) : 'delete_failed', ); } } - return array( + $processed = count($deleted) + count($skipped); + $result = array( 'success' => true, 'pruned_at' => gmdate('c'), 'dry_run' => $dry_run, @@ -343,9 +410,19 @@ public function pruneMissing( array $opts = array() ): array { 'summary' => array( 'deleted' => count($deleted), 'skipped' => count($skipped), - 'total' => count($rows), + 'total' => $processed, + 'limit' => $limit, + 'after_handle' => $after_handle, ), ); + if ( $has_more ) { + $result['continuation'] = array( + 'reason' => null !== $deadline && microtime(true) >= $deadline ? 'budget_exhausted' : 'limit_reached', + 'next_after_handle' => $last_handle, + ); + } + + return $result; } /** @@ -353,7 +430,7 @@ public function pruneMissing( array $opts = array() ): array { * * @return array> */ - private function missing_path_rows(): array { + private function missing_path_rows( int $limit, string $after_handle ): array { global $wpdb; if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { @@ -362,7 +439,9 @@ private function missing_path_rows(): array { $table = self::table_name(); // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - $sql = "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC"; + $sql = '' === $after_handle + ? "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC LIMIT " . (int) $limit + : $wpdb->prepare("SELECT * FROM {$table} WHERE missing_path = 1 AND handle > %s ORDER BY handle ASC LIMIT " . (int) $limit, $after_handle); // phpcs:enable WordPress.DB.PreparedSQL // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input. @@ -370,6 +449,75 @@ private function missing_path_rows(): array { return is_array($rows) ? array_map(array( $this, 'decode_row' ), $rows) : array(); } + private function is_prunable_path( string $path, mixed $workspace_root ): bool { + if ( '' === $path || ! str_starts_with($path, '/') || str_contains($path, "\0") || ! is_string($workspace_root) || '' === trim($workspace_root) ) { + return false; + } + $root = realpath($workspace_root); + $parent = realpath(dirname($path)); + return false !== $root && false !== $parent && str_starts_with($parent . '/', rtrim($root, '/') . '/'); + } + + /** @param array $row */ + private function has_owner_managed_provenance( array $row ): bool { + $metadata = is_array($row['metadata'] ?? null) ? $row['metadata'] : array(); + foreach ( array( 'origin_site', 'origin_agent', 'origin_session', 'owner_run_ref', 'cleanup_policy', 'task_url', 'task_ref' ) as $field ) { + if ( '' !== trim( (string) ( $row[ $field ] ?? $metadata[ $field ] ?? '' ) ) ) { + return true; + } + } + + return false; + } + + private function delete_missing_if_current( string $handle, string $path, bool $force ): bool { + global $wpdb; + $this->last_error = null; + if ( ! isset($wpdb) || ! method_exists($wpdb, 'query') || ! method_exists($wpdb, 'prepare') ) { + return false; + } + + $table = self::table_name(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derives from $wpdb->prefix; values are prepared. + $sql = "DELETE FROM {$table} WHERE handle = %s AND path = %s AND missing_path = 1 AND last_probe_status = %s" + . " AND TRIM(COALESCE(origin_site, '')) = ''" + . " AND TRIM(COALESCE(origin_agent, '')) = ''" + . " AND TRIM(COALESCE(origin_session, '')) = ''" + . " AND TRIM(COALESCE(owner_run_ref, '')) = ''" + . " AND TRIM(COALESCE(cleanup_policy, '')) = ''" + . " AND TRIM(COALESCE(task_url, '')) = ''" + . " AND TRIM(COALESCE(task_ref, '')) = ''"; + if ( ! $force ) { + $sql .= " AND COALESCE(unpushed_count, 0) <= 0 AND TRIM(COALESCE(pr_url, '')) = ''"; + } + $sql = $wpdb->prepare($sql, $handle, $path, 'missing_path'); + $result = SqliteBusyRetry::run('worktree_inventory_delete_missing_if_current', fn() => $wpdb->query($sql)); + if ( $result instanceof \WP_Error ) { + $this->last_error = $result; + return false; + } + + return 1 === (int) $result; + } + + private function prune_deadline( mixed $duration ): ?float { + $duration = trim( (string) $duration ); + if ( '' === $duration || ! preg_match('/^(\d+)([smh])$/', $duration, $matches) ) { + return null; + } + $seconds = (int) $matches[1]; + if ( $seconds < 1 ) { + return null; + } + + $seconds *= match ( $matches[2] ) { + 'h' => 3600, + 'm' => 60, + default => 1, + }; + return microtime(true) + $seconds; + } + /** * Fetch all rows, optionally filtered by repo. * diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 2560ee2b..a71f416c 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -54,6 +54,7 @@ public function run( array $input ): array|\WP_Error { $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; + $inventory_after = isset($input['inventory_after']) ? trim( (string) $input['inventory_after'] ) : ''; $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; @@ -69,6 +70,10 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($artifact_cleanup) ) { return $artifact_cleanup; } + $inventory_prune = $this->resolve_ability('datamachine-code/workspace-worktree-inventory-prune-missing'); + if ( is_wp_error($inventory_prune) ) { + return $inventory_prune; + } $result = array( 'success' => true, @@ -91,6 +96,9 @@ public function run( array $input ): array|\WP_Error { 'marked_cleanup_eligible' => 0, 'bytes_reclaimed' => 0, 'lock_files_removed' => 0, + 'inventory_rows_pruned' => 0, + 'inventory_rows_planned' => 0, + 'inventory_rows_skipped' => 0, 'blocker_count' => 0, 'blockers_by_reason' => array(), ), @@ -116,6 +124,7 @@ public function run( array $input ): array|\WP_Error { '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.', + 'pending_stages' => array(), ); $this->checkpoint_progress($run_id, $result, 'applying'); if ( null !== $progress_callback ) { @@ -200,6 +209,7 @@ public function run( array $input ): array|\WP_Error { $cycle_progress += $this->accumulate_cleanup_step($result, $active); if ( is_array($active['continuation'] ?? null) && array() !== $active['continuation'] ) { $result['continuation']['active_no_signal'] = $active['continuation']; + $result['continuation']['pending_stages']['active_no_signal'] = $active['continuation']; if ( ! empty($active['continuation']['next_command']) ) { $result['continuation']['next_command'] = (string) $active['continuation']['next_command']; $result['continuation']['reason'] = (string) ( $active['continuation']['reason'] ?? 'active_no_signal_page_incomplete' ); @@ -212,6 +222,40 @@ public function run( array $input ): array|\WP_Error { } } + // This owning-layer primitive rechecks each path immediately before deletion. + $inventory_input = array( + 'dry_run' => $dry_run, + 'force' => false, + 'limit' => $limit, + 'after_handle' => $inventory_after, + ); + if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { + $inventory_input['until_budget'] = trim( (string) $input['until_budget']); + } + $inventory = $this->execute_ability($inventory_prune, $inventory_input); + if ( is_wp_error($inventory) ) { + return $inventory; + } + $result['steps']['inventory_prune_missing'] = $this->summarize_inventory_prune_step($inventory, $dry_run); + $result['blockers_by_stage']['inventory_prune_missing'] = (array) ( $result['steps']['inventory_prune_missing']['blockers'] ?? array() ); + $this->accumulate_inventory_prune_step($result, $result['steps']['inventory_prune_missing']); + if ( isset($inventory['continuation']['next_after_handle']) ) { + $next_after = (string) $inventory['continuation']['next_after_handle']; + $result['continuation']['inventory_after'] = $next_after; + $inventory_continuation = array( + 'reason' => (string) ( $inventory['continuation']['reason'] ?? 'inventory_prune_incomplete' ), + 'after_handle' => $next_after, + 'next_command' => $this->progress_commands($run_id, $dry_run, $limit, $passes, $cycles, array_merge($input, array( 'inventory_after' => $next_after )))['resume'], + ); + $result['continuation']['pending_stages']['inventory_prune_missing'] = $inventory_continuation; + if ( empty($result['continuation']['next_command']) ) { + $result['continuation']['reason'] = $inventory_continuation['reason']; + $result['continuation']['next_command'] = $inventory_continuation['next_command']; + } + } + ksort($result['continuation']['pending_stages']); + $this->checkpoint_progress($run_id, $result, 'applying'); + $lock_end = ( $this->lock_pruner )($dry_run); if ( is_wp_error($lock_end) ) { return $lock_end; @@ -311,6 +355,9 @@ private function progress_commands( string $run_id, bool $dry_run, int $limit, i if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $resume .= ' --until-budget=' . trim( (string) $input['until_budget']); } + if ( isset($input['inventory_after']) && '' !== trim( (string) $input['inventory_after']) ) { + $resume .= ' --inventory-after=' . escapeshellarg(trim( (string) $input['inventory_after'])); + } return array( 'status' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), @@ -423,6 +470,65 @@ private function accumulate_artifact_step( array &$result, array $step ): void { } } + /** @return array */ + private function summarize_inventory_prune_step( array $step, bool $dry_run ): array { + $summary = (array) ( $step['summary'] ?? array() ); + $deleted = (array) ( $step['deleted'] ?? array() ); + $skipped = (array) ( $step['skipped'] ?? array() ); + $blockers = array(); + foreach ( $skipped as $row ) { + if ( ! is_array($row) ) { + continue; + } + $reason = (string) ( $row['reason'] ?? 'unknown' ); + $blockers[ $reason ] = ( $blockers[ $reason ] ?? 0 ) + 1; + } + + return array( + 'mode' => 'inventory_prune_missing', + 'dry_run' => $dry_run, + 'planned_rows' => $dry_run ? (int) ( $summary['deleted'] ?? count($deleted) ) : 0, + 'pruned_rows' => $dry_run ? 0 : (int) ( $summary['deleted'] ?? count($deleted) ), + 'skipped_rows' => (int) ( $summary['skipped'] ?? count($skipped) ), + 'candidate_rows' => (int) ( $summary['total'] ?? ( count($deleted) + count($skipped) ) ), + 'continuation' => (array) ( $step['continuation'] ?? array() ), + 'blockers' => $blockers, + 'pruned_examples' => $this->inventory_prune_examples($deleted), + 'skipped_examples' => $this->inventory_prune_examples($skipped), + ); + } + + /** @param array $rows @return array> */ + private function inventory_prune_examples( array $rows ): array { + $examples = array(); + foreach ( array_slice($rows, 0, 10) as $row ) { + if ( ! is_array($row) ) { + continue; + } + $examples[] = array_filter( + array( + 'handle' => isset($row['handle']) ? (string) $row['handle'] : '', + 'reason' => isset($row['reason']) ? (string) $row['reason'] : '', + ), + static fn( string $value ): bool => '' !== $value + ); + } + + return $examples; + } + + private function accumulate_inventory_prune_step( array &$result, array $step ): void { + $result['summary']['inventory_rows_pruned'] += (int) ( $step['pruned_rows'] ?? 0 ); + $result['summary']['inventory_rows_planned'] += (int) ( $step['planned_rows'] ?? 0 ); + $result['summary']['inventory_rows_skipped'] += (int) ( $step['skipped_rows'] ?? 0 ); + foreach ( (array) ( $step['blockers'] ?? array() ) as $reason => $count ) { + $result['blockers'][] = array( + 'reason_code' => (string) $reason, + 'count' => (int) $count, + ); + } + } + 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 ) { diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 74b4471f..256bf532 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1740,10 +1740,19 @@ public function worktree_inventory_refresh(): array|\WP_Error { * Re-probes each candidate on disk, protects rows with unpushed work or an * open PR unless forced, and deletes the confirmed-absent survivors. * - * @param array{dry_run?: bool, force?: bool} $opts Options. + * @param array{dry_run?: bool, force?: bool, limit?: int, after_handle?: string, until_budget?: string} $opts Options. * @return array|\WP_Error */ public function worktree_inventory_prune_missing( array $opts = array() ): array|\WP_Error { + $opts['lock_callback'] = function ( array $row, callable $mutation ): mixed { + $repo = trim( (string) ( $row['repo'] ?? '' ) ); + if ( '' === $repo ) { + return new \WP_Error('workspace_lock_invalid_target', 'Missing repository handle for inventory pruning.', array( 'status' => 400 )); + } + + return WorkspaceMutationLock::with_repo($this->workspace_path, $repo, $mutation); + }; + $opts['workspace_root'] = $this->workspace_path; return $this->worktree_inventory()->pruneMissing($opts); } diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php index e6ff6905..2aa62a65 100644 --- a/tests/workspace-safe-cleanup-orchestrator.php +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -4,6 +4,7 @@ */ define('ABSPATH', dirname(__DIR__)); +define('ARRAY_A', 'ARRAY_A'); if ( ! class_exists('WP_Error') ) { class WP_Error { @@ -56,9 +57,47 @@ function add_action( string $hook, callable $callback ): void { } require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepositoryInterface.php'; +require_once dirname(__DIR__) . '/inc/Support/JsonCodec.php'; +require_once dirname(__DIR__) . '/inc/Storage/WorktreeInventoryRepository.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php'; require_once dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'; +final class SafeCleanupInventoryWpdb { + public string $prefix = 'wp_'; + + /** @var array> */ + public array $rows = array(); + + public function get_results( string $sql, string $output = ARRAY_A ): array { + $rows = array_values(array_filter($this->rows, static fn( array $row ): bool => ! str_contains($sql, 'missing_path = 1') || ! empty($row['missing_path']))); + usort($rows, static fn( array $a, array $b ): int => strcmp((string) $a['handle'], (string) $b['handle'])); + if ( preg_match('/LIMIT (\d+) OFFSET (\d+)/', $sql, $matches) ) { + return array_slice($rows, (int) $matches[2], (int) $matches[1]); + } + return $rows; + } + + public function prepare( string $query, mixed ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace('/%s/', "'" . addslashes((string) $arg) . "'", $query, 1) ?? $query; + } + return $query; + } + + public function query( string $sql ): int|false { + if ( ! preg_match("/handle = '([^']*)' AND path = '([^']*)' AND missing_path = 1 AND last_probe_status = 'missing_path'/", $sql, $matches) ) { + return false; + } + $handle = stripslashes($matches[1]); + $path = stripslashes($matches[2]); + if ( ! isset($this->rows[ $handle ]) || $path !== (string) $this->rows[ $handle ]['path'] || empty($this->rows[ $handle ]['missing_path']) || 'missing_path' !== $this->rows[ $handle ]['last_probe_status'] ) { + return 0; + } + unset($this->rows[ $handle ]); + return 1; + } +} + final class SafeCleanupQueuedAbility { /** @var array> */ public array $calls = array(); @@ -77,6 +116,35 @@ public function execute( array $input ): array { } } +final class SafeCleanupSchemaValidatedAbility { + /** @var array> */ + public array $calls = array(); + + /** @param array $response */ + public function __construct( private array $response ) {} + + public function execute( array $input ): array|\WP_Error { + foreach ( array( 'limit' => 'integer', 'after_handle' => 'string', 'until_budget' => 'string' ) as $key => $type ) { + if ( array_key_exists($key, $input) && gettype($input[ $key ]) !== $type ) { + return new WP_Error('ability_invalid_input', sprintf('%s must be a %s.', $key, $type)); + } + } + $this->calls[] = $input; + return $this->response; + } +} + +final class SafeCleanupRealInventoryAbility { + /** @var array> */ + public array $calls = array(); + + public function execute( array $input ): array { + $this->calls[] = $input; + $input['workspace_root'] = sys_get_temp_dir(); + return ( new DataMachineCode\Storage\WorktreeInventoryRepository() )->pruneMissing($input); + } +} + final class SafeCleanupFakeRunRepository implements \DataMachineCode\Storage\CleanupRunRepositoryInterface { /** @var array> */ public array $runs = array(); @@ -106,6 +174,7 @@ function safe_cleanup_assert( bool $condition, string $label ): void { new DataMachineCode\Abilities\WorkspaceAbilities(); $safe_cleanup_ability = $GLOBALS['safe_cleanup_registered_abilities']['datamachine-code/workspace-cleanup-safe'] ?? null; +$inventory_prune_ability = $GLOBALS['safe_cleanup_registered_abilities']['datamachine-code/workspace-worktree-inventory-prune-missing'] ?? null; safe_cleanup_assert(is_array($safe_cleanup_ability), 'safe cleanup ability is registered'); safe_cleanup_assert(array( DataMachineCode\Abilities\WorkspaceAbilities::class, 'workspaceCleanupSafe' ) === $safe_cleanup_ability['execute_callback'], 'safe cleanup ability uses canonical callback'); safe_cleanup_assert(isset($safe_cleanup_ability['input_schema']['properties']['dry_run']), 'safe cleanup ability accepts dry_run'); @@ -116,6 +185,9 @@ function safe_cleanup_assert( bool $condition, string $label ): void { safe_cleanup_assert(isset($safe_cleanup_ability['output_schema']['properties']['current_blockers']), 'safe cleanup ability documents final current 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'); +safe_cleanup_assert(is_array($inventory_prune_ability), 'inventory prune ability is registered'); +safe_cleanup_assert(isset($inventory_prune_ability['input_schema']['properties']['after_handle']), 'registered inventory ability accepts the keyset cursor'); +safe_cleanup_assert(array( DataMachineCode\Abilities\WorkspaceAbilities::class, 'worktreeInventoryPruneMissing' ) === $inventory_prune_ability['execute_callback'], 'registered inventory ability uses the canonical lifecycle callback'); $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'); @@ -194,6 +266,19 @@ function safe_cleanup_assert( bool $condition, string $label ): void { ), ) ); +$inventory_wpdb = new SafeCleanupInventoryWpdb(); +$inventory_absent = sys_get_temp_dir() . '/dmc-safe-cleanup-absent-' . getmypid(); +$inventory_present = sys_get_temp_dir() . '/dmc-safe-cleanup-present-' . getmypid(); +@rmdir($inventory_absent); +@rmdir($inventory_present); +mkdir($inventory_present, 0777, true); +$inventory_wpdb->rows = array( + 'confirmed-absent' => array( 'handle' => 'confirmed-absent', 'repo' => 'repo', 'path' => $inventory_absent, 'missing_path' => 1, 'last_probe_status' => 'missing_path', 'metadata' => null ), + 'recreated-primary' => array( 'handle' => 'recreated-primary', 'repo' => 'repo', 'path' => $inventory_present, 'missing_path' => 1, 'last_probe_status' => 'missing_path', 'metadata' => null ), + 'protected-pr' => array( 'handle' => 'protected-pr', 'repo' => 'repo', 'path' => $inventory_absent, 'missing_path' => 1, 'last_probe_status' => 'missing_path', 'pr_url' => 'https://example.test/pr/1', 'metadata' => null ), +); +$GLOBALS['wpdb'] = $inventory_wpdb; +$inventory_prune = new SafeCleanupRealInventoryAbility(); $lock_calls = array(); $run_repository = new SafeCleanupFakeRunRepository(); $progress_envelopes = array(); @@ -202,6 +287,7 @@ function safe_cleanup_assert( bool $condition, string $label ): void { 'datamachine-code/workspace-worktree-cleanup-eligible-drain' => $cleanup_eligible, 'datamachine-code/workspace-worktree-active-no-signal-drain' => $active_no_signal, 'datamachine-code/workspace-cleanup-until-empty' => $artifact_cleanup, + 'datamachine-code/workspace-worktree-inventory-prune-missing' => $inventory_prune, default => null, }, static function ( bool $dry_run ) use ( &$lock_calls ): array { @@ -244,11 +330,23 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { safe_cleanup_assert(3072 === ( $result['summary']['bytes_reclaimed'] ?? null ), 'measured artifact and worktree bytes 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(7 === ( $result['summary']['blocker_count'] ?? null ), 'compact blockers are counted'); +safe_cleanup_assert(1 === ( $result['summary']['inventory_rows_pruned'] ?? null ), 'confirmed missing inventory rows are reported separately from removed worktrees'); +safe_cleanup_assert(0 === ( $result['summary']['inventory_rows_planned'] ?? null ), 'apply does not report inventory rows as planned'); +safe_cleanup_assert(2 === ( $result['summary']['inventory_rows_skipped'] ?? null ), 'recreated and protected inventory rows are reported separately'); +safe_cleanup_assert(false === $inventory_prune->calls[0]['dry_run'], 'apply invokes inventory pruning in apply mode'); +safe_cleanup_assert(false === $inventory_prune->calls[0]['force'], 'safe cleanup preserves inventory prune force protections'); +safe_cleanup_assert(7 === $inventory_prune->calls[0]['limit'], 'safe cleanup bounds inventory pruning to its cleanup limit'); +safe_cleanup_assert(array( array( 'handle' => 'confirmed-absent' ) ) === ( $result['steps']['inventory_prune_missing']['pruned_examples'] ?? null ), 'inventory evidence is bounded to compact examples'); +safe_cleanup_assert(! isset($inventory_wpdb->rows['confirmed-absent']), 'safe cleanup uses the real primitive to delete confirmed-absent rows'); +safe_cleanup_assert(isset($inventory_wpdb->rows['recreated-primary']), 'safe cleanup real primitive preserves recreated paths'); +safe_cleanup_assert(isset($inventory_wpdb->rows['protected-pr']), 'safe cleanup real primitive preserves PR-protected rows'); +safe_cleanup_assert(9 === ( $result['summary']['blocker_count'] ?? null ), 'compact blockers are counted'); safe_cleanup_assert(1 === ( $result['summary']['blockers_by_reason']['artifact_plan_mismatch'] ?? null ), 'artifact blocker count is preserved'); 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(1 === ( $result['summary']['blockers_by_reason']['path_present_on_disk'] ?? null ), 'recreated paths remain inventory prune skips'); +safe_cleanup_assert(1 === ( $result['summary']['blockers_by_reason']['pr_url'] ?? null ), 'protected inventory rows remain inventory prune skips'); safe_cleanup_assert('sum_of_per_reason_maximum_observations_across_stages' === ( $result['summary']['blocker_count_scope'] ?? null ), 'aggregate blocker counts document their historical aggregation scope'); safe_cleanup_assert(4 === ( $result['summary']['current_blocker_count'] ?? null ), 'current blocker count reports the final-cycle artifact and active backlog observations'); safe_cleanup_assert(3 === ( $result['summary']['current_blockers_by_reason']['insufficient_signal'] ?? null ), 'current blocker buckets expose final-cycle active backlog observations'); @@ -257,6 +355,66 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { 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(4 === ( $run_repository->runs['cleanup-run-safe-test']['summary']['safe_cleanup_progress']['summary']['removed'] ?? null ), 'safe cleanup persists reclaimed progress summary'); +rmdir($inventory_present); + +$bounded_inventory = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => 'datamachine-code/workspace-worktree-inventory-prune-missing' === $name ? $inventory_prune : new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array() ) )), + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array(), 'filesystem' => array() ), + new SafeCleanupFakeRunRepository() +); +$bounded_inventory_result = $bounded_inventory->run(array( 'dry_run' => true, 'limit' => 1 )); +safe_cleanup_assert(! is_wp_error($bounded_inventory_result), 'bounded real inventory prune succeeds through safe cleanup'); +safe_cleanup_assert('protected-pr' === ( $bounded_inventory_result['continuation']['inventory_after'] ?? null ), 'safe cleanup returns a bounded inventory keyset cursor'); +safe_cleanup_assert(str_contains((string) ( $bounded_inventory_result['continuation']['next_command'] ?? '' ), '--inventory-after='), 'safe cleanup continuation resumes the next inventory keyset page'); + +$schema_validated_inventory = new SafeCleanupSchemaValidatedAbility(array( 'success' => true, 'summary' => array() )); +$schema_validated_cleanup = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => 'datamachine-code/workspace-worktree-inventory-prune-missing' === $name ? $schema_validated_inventory : new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array() ) )), + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array(), 'filesystem' => array() ), + new SafeCleanupFakeRunRepository() +); +$schema_validated_result = $schema_validated_cleanup->run(array( 'dry_run' => true )); +safe_cleanup_assert(! is_wp_error($schema_validated_result), 'safe cleanup omits unset optional inputs accepted by the inventory Ability schema'); +safe_cleanup_assert(! array_key_exists('until_budget', $schema_validated_inventory->calls[0]), 'safe cleanup does not pass a null until_budget to the inventory Ability'); + +$cursor_inventory = new SafeCleanupQueuedAbility(array( array( + 'success' => true, + 'summary' => array(), + 'continuation' => array( 'reason' => 'limit_reached', 'next_after_handle' => 'next-cursor' ), +) )); +$cursor_cleanup = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => 'datamachine-code/workspace-worktree-inventory-prune-missing' === $name ? $cursor_inventory : new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array() ) )), + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array(), 'filesystem' => array() ), + new SafeCleanupFakeRunRepository() +); +$cursor_result = $cursor_cleanup->run(array( 'dry_run' => true, 'inventory_after' => 'previous-cursor' )); +$next_command = (string) ( $cursor_result['continuation']['next_command'] ?? '' ); +safe_cleanup_assert(1 === substr_count($next_command, '--inventory-after='), 'inventory continuation replaces rather than duplicates its cursor flag'); +safe_cleanup_assert(str_contains($next_command, "--inventory-after='next-cursor'"), 'inventory continuation uses the replacement cursor'); + +$both_active = new SafeCleanupQueuedAbility(array( array( + 'success' => true, + 'summary' => array(), + 'continuation' => array( 'reason' => 'page_incomplete', 'next_command' => 'active-resume' ), +) )); +$both_inventory = new SafeCleanupQueuedAbility(array( array( + 'success' => true, + 'summary' => array(), + 'continuation' => array( 'reason' => 'limit_reached', 'next_after_handle' => 'inventory-handle' ), +) )); +$both_pending = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => match ( $name ) { + 'datamachine-code/workspace-worktree-active-no-signal-drain' => $both_active, + 'datamachine-code/workspace-worktree-inventory-prune-missing' => $both_inventory, + default => new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array() ) )), + }, + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array(), 'filesystem' => array() ), + new SafeCleanupFakeRunRepository() +); +$both_pending_result = $both_pending->run(array( 'dry_run' => true )); +safe_cleanup_assert('active-resume' === ( $both_pending_result['continuation']['next_command'] ?? null ), 'active/no-signal remains the primary continuation when multiple stages are incomplete'); +safe_cleanup_assert(isset($both_pending_result['continuation']['pending_stages']['active_no_signal']), 'active/no-signal continuation remains visible'); +safe_cleanup_assert(isset($both_pending_result['continuation']['pending_stages']['inventory_prune_missing']), 'inventory continuation remains visible alongside active/no-signal'); $preview_lock_calls = array(); $preview = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( @@ -273,6 +431,31 @@ static function ( bool $dry_run ) use ( &$preview_lock_calls ): array { 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'); +$preview_prune = new SafeCleanupQueuedAbility( + array( + array( + 'success' => true, + 'dry_run' => true, + 'deleted' => array( array( 'handle' => 'missing-preview' ) ), + 'skipped' => array( array( 'handle' => 'protected-preview', 'reason' => 'unpushed_count' ) ), + 'summary' => array( 'deleted' => 1, 'skipped' => 1, 'total' => 2 ), + ), + ) +); +$dry_run_ability = new SafeCleanupQueuedAbility(array( array( 'success' => true, 'summary' => array() ) )); +$inventory_preview = new DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator( + static fn( string $name ) => 'datamachine-code/workspace-worktree-inventory-prune-missing' === $name ? $preview_prune : $dry_run_ability, + static fn( bool $dry_run ) => array( 'dry_run' => $dry_run, 'after' => array(), 'filesystem' => array() ), + new SafeCleanupFakeRunRepository() +); +$inventory_preview_result = $inventory_preview->run(array( 'dry_run' => true )); +safe_cleanup_assert(! is_wp_error($inventory_preview_result), 'inventory prune preview succeeds'); +safe_cleanup_assert(true === $preview_prune->calls[0]['dry_run'], 'preview invokes inventory pruning in dry-run mode'); +safe_cleanup_assert(false === $preview_prune->calls[0]['force'], 'preview retains inventory prune force protections'); +safe_cleanup_assert(1 === ( $inventory_preview_result['summary']['inventory_rows_planned'] ?? null ), 'preview reports missing inventory rows planned for pruning'); +safe_cleanup_assert(0 === ( $inventory_preview_result['summary']['inventory_rows_pruned'] ?? null ), 'preview does not report inventory rows as pruned'); +safe_cleanup_assert(1 === ( $inventory_preview_result['summary']['inventory_rows_skipped'] ?? null ), 'preview reports protected inventory rows as skipped'); + $duplicate_blocker_ability = new SafeCleanupQueuedAbility( array( array( 'success' => true, 'summary' => array( 'skipped_by_reason' => array( 'lifecycle_reconciliation_candidate' => 145 ) ) ), diff --git a/tests/worktree-inventory-prune-missing.php b/tests/worktree-inventory-prune-missing.php index 3832d9ff..7818cbcf 100644 --- a/tests/worktree-inventory-prune-missing.php +++ b/tests/worktree-inventory-prune-missing.php @@ -41,6 +41,9 @@ final class Prune_Test_Wpdb { /** @var array> handle => row */ public array $rows = array(); + /** @var callable|null */ + public $before_query = null; + public function get_charset_collate(): string { return ''; } @@ -53,9 +56,44 @@ public function get_results( string $sql, string $output = ARRAY_A ): array { } $out[] = $row; } + usort($out, static fn( array $a, array $b ): int => strcmp((string) $a['handle'], (string) $b['handle'])); + if ( preg_match("/handle > '([^']*)'/", $sql, $matches) ) { + $out = array_values(array_filter($out, static fn( array $row ): bool => strcmp((string) $row['handle'], stripslashes($matches[1])) > 0)); + } + if ( preg_match('/LIMIT (\d+)/', $sql, $matches) ) { + return array_slice($out, 0, (int) $matches[1]); + } return $out; } + public function query( string $sql ): int|false { + if ( is_callable($this->before_query) ) { + ( $this->before_query )($this, $sql); + } + if ( ! preg_match("/handle = '([^']*)' AND path = '([^']*)' AND missing_path = 1 AND last_probe_status = 'missing_path'/", $sql, $matches) ) { + return false; + } + $handle = stripslashes($matches[1]); + $path = stripslashes($matches[2]); + if ( ! isset($this->rows[ $handle ]) || $path !== (string) $this->rows[ $handle ]['path'] || empty($this->rows[ $handle ]['missing_path']) || 'missing_path' !== (string) $this->rows[ $handle ]['last_probe_status'] ) { + return 0; + } + $row = $this->rows[ $handle ]; + foreach ( array( 'origin_site', 'origin_agent', 'origin_session', 'owner_run_ref', 'cleanup_policy', 'task_url', 'task_ref' ) as $field ) { + if ( '' !== trim((string) ( $row[ $field ] ?? '' )) && str_contains($sql, "TRIM(COALESCE({$field}, '')) = ''") ) { + return 0; + } + } + if ( str_contains($sql, "TRIM(COALESCE(pr_url, '')) = ''") && '' !== trim((string) ( $row['pr_url'] ?? '' )) ) { + return 0; + } + if ( str_contains($sql, 'COALESCE(unpushed_count, 0) <= 0') && (int) ( $row['unpushed_count'] ?? 0 ) > 0 ) { + return 0; + } + unset($this->rows[ $handle ]); + return 1; + } + public function delete( string $table, array $where ): int|false { $handle = (string) ( $where['handle'] ?? '' ); if ( ! isset($this->rows[ $handle ]) ) { @@ -67,7 +105,7 @@ public function delete( string $table, array $where ): int|false { public function prepare( string $query, mixed ...$args ): string { foreach ( $args as $arg ) { - $query = preg_replace('/%s/', addslashes((string) $arg), $query, 1) ?? $query; + $query = preg_replace('/%s/', "'" . addslashes((string) $arg) . "'", $query, 1) ?? $query; } return $query; } @@ -161,7 +199,7 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { $GLOBALS['wpdb'] = $wpdb; $repo = new WorktreeInventoryRepository(); - $result = $repo->pruneMissing(array( 'dry_run' => true )); + $result = $repo->pruneMissing(array( 'dry_run' => true, 'workspace_root' => sys_get_temp_dir() )); assert_true(! empty($result['success']), 'dry-run returns success'); assert_true(! empty($result['dry_run']), 'dry-run result flags dry_run'); @@ -172,6 +210,93 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { ++$GLOBALS['passed']; }; +/* + * Test 6: bounded pages retain a continuation and only mutate the current page. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-g-' . getmypid(); + seed($wpdb, array( + make_row(array( 'handle' => 'a', 'path' => $absent )), + make_row(array( 'handle' => 'b', 'path' => $absent, 'pr_url' => 'https://example.test/pr/1' )), + make_row(array( 'handle' => 'c', 'path' => $absent )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $result = ( new WorktreeInventoryRepository() )->pruneMissing(array( 'limit' => 2, 'workspace_root' => sys_get_temp_dir() )); + assert_same(1, $result['summary']['deleted'], 'bounded page deletes only its configured limit after a protected row'); + assert_same(1, $result['summary']['skipped'], 'bounded page retains protected rows for later reconciliation'); + assert_same('b', $result['continuation']['next_after_handle'] ?? null, 'bounded page reports its keyset continuation cursor'); + assert_true(isset($wpdb->rows['b']) && isset($wpdb->rows['c']), 'protected and later rows remain for continuation'); + $result = ( new WorktreeInventoryRepository() )->pruneMissing(array( 'after_handle' => 'b', 'workspace_root' => sys_get_temp_dir() )); + assert_same(1, $result['summary']['deleted'], 'keyset continuation processes only rows after its cursor'); + assert_true(isset($wpdb->rows['b']) && ! isset($wpdb->rows['c']), 'keyset continuation retains the earlier protected row and deletes the survivor'); + ++$GLOBALS['passed']; +}; + +/* + * Test 7: malformed and owner-managed rows are ambiguous and never pruned. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-h-' . getmypid(); + seed($wpdb, array( + make_row(array( 'handle' => 'relative-path', 'path' => 'github://owner/repo' )), + make_row(array( 'handle' => 'owner-managed', 'path' => $absent, 'origin_site' => 'remote-site' )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $result = ( new WorktreeInventoryRepository() )->pruneMissing(array( 'force' => true, 'workspace_root' => sys_get_temp_dir() )); + $reasons = array_column($result['skipped'], 'reason', 'handle'); + assert_same('invalid_path', $reasons['relative-path'] ?? null, 'malformed remote path is preserved'); + assert_same('owner_managed', $reasons['owner-managed'] ?? null, 'owner-managed row is preserved even under force'); + assert_true(isset($wpdb->rows['relative-path']) && isset($wpdb->rows['owner-managed']), 'ambiguous rows remain in inventory'); + ++$GLOBALS['passed']; +}; + +/* + * Test 8: final SQL conditions preserve evidence added after the locked read. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-i-' . getmypid(); + seed($wpdb, array( make_row(array( 'handle' => 'updated-pr', 'path' => $absent )) )); + $wpdb->before_query = static function ( Prune_Test_Wpdb $database ): void { + $database->rows['updated-pr']['pr_url'] = 'https://example.test/pr/2'; + }; + $GLOBALS['wpdb'] = $wpdb; + + $result = ( new WorktreeInventoryRepository() )->pruneMissing(array( 'workspace_root' => sys_get_temp_dir() )); + assert_same(0, $result['summary']['deleted'], 'final SQL predicate preserves a row that gains PR evidence'); + assert_same('conditional_delete_mismatch', $result['skipped'][0]['reason'] ?? null, 'concurrent protection update reports a conditional delete mismatch'); + assert_true(isset($wpdb->rows['updated-pr']), 'concurrently protected row remains in inventory'); + ++$GLOBALS['passed']; +}; + +/* + * Test 9: the mutation callback recreates the path before the locked final probe. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $path = sys_get_temp_dir() . '/dmc-prune-smoke-race-' . getmypid(); + @rmdir($path); + seed($wpdb, array( make_row(array( 'handle' => 'recreated', 'path' => $path )) )); + $GLOBALS['wpdb'] = $wpdb; + + $result = ( new WorktreeInventoryRepository() )->pruneMissing(array( + 'workspace_root' => sys_get_temp_dir(), + 'lock_callback' => static function ( array $row, callable $mutation ) use ( $path ): array { + mkdir($path, 0777, true); + return $mutation(); + }, + )); + assert_same(0, $result['summary']['deleted'], 'final locked path probe prevents deletion after recreation'); + assert_same('path_present_on_disk', $result['skipped'][0]['reason'] ?? null, 'recreated path reports final revalidation skip'); + assert_true(isset($wpdb->rows['recreated']), 'recreated row remains in inventory'); + rmdir($path); + ++$GLOBALS['passed']; +}; + /* * Test 2: re-probe guard skips rows whose path is present on disk * (stale missing_path flag must not be trusted). @@ -193,7 +318,7 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { $GLOBALS['wpdb'] = $wpdb; $repo = new WorktreeInventoryRepository(); - $result = $repo->pruneMissing(array()); + $result = $repo->pruneMissing(array( 'workspace_root' => sys_get_temp_dir() )); assert_same(1, $result['summary']['deleted'], 'only the truly-absent row is deleted'); assert_same(1, $result['summary']['skipped'], 'the present-on-disk row is skipped'); @@ -223,7 +348,7 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { $GLOBALS['wpdb'] = $wpdb; $repo = new WorktreeInventoryRepository(); - $result = $repo->pruneMissing(array()); + $result = $repo->pruneMissing(array( 'workspace_root' => sys_get_temp_dir() )); assert_same(1, $result['summary']['deleted'], 'only the clean ghost is deleted'); assert_same(2, $result['summary']['skipped'], 'unpushed + PR rows are skipped'); @@ -255,7 +380,7 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { $GLOBALS['wpdb'] = $wpdb; $repo = new WorktreeInventoryRepository(); - $result = $repo->pruneMissing(array( 'force' => true )); + $result = $repo->pruneMissing(array( 'force' => true, 'workspace_root' => sys_get_temp_dir() )); assert_same(2, $result['summary']['deleted'], 'force deletes the protected rows'); assert_same(0, $result['summary']['skipped'], 'force leaves nothing skipped'); @@ -280,7 +405,7 @@ function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { $GLOBALS['wpdb'] = $wpdb; $repo = new WorktreeInventoryRepository(); - $result = $repo->pruneMissing(array()); + $result = $repo->pruneMissing(array( 'workspace_root' => sys_get_temp_dir() )); assert_same(1, $result['summary']['total'], 'only missing_path=1 rows are candidates'); assert_same(1, $result['summary']['deleted'], 'only the ghost is deleted');