diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 0156f0c..5b2f52b 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2690,6 +2690,7 @@ private function registerAbilities(): void { 'full_workspace' => array( 'type' => 'boolean' ), 'worktree_older_than' => array( 'type' => 'string' ), 'worktree_sort' => array( 'type' => 'string' ), + 'artifact_sort' => array( 'type' => 'string' ), 'worktree_stale_only' => array( 'type' => 'boolean' ), 'plan' => array( 'type' => 'object' ), ), @@ -4669,6 +4670,9 @@ public static function workspaceCleanupPlan( array $input ): array|\WP_Error { if ( isset($input['worktree_sort']) && '' !== trim( (string) $input['worktree_sort']) ) { $opts['worktree_sort'] = trim( (string) $input['worktree_sort']); } + if ( isset($input['artifact_sort']) && '' !== trim( (string) $input['artifact_sort']) ) { + $opts['artifact_sort'] = trim( (string) $input['artifact_sort']); + } if ( isset($input['plan']) && is_array($input['plan']) ) { $opts['plan'] = $input['plan']; } diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index de7ce03..342293c 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -125,7 +125,8 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error } $items = $this->repository->get_items($run_id); - $force_artifact_cleanup = ! empty( (array) ( $run['policy'] ?? array() )['force_artifact_cleanup'] ); + $policy = (array) ( $run['policy'] ?? array() ); + $force_artifact_cleanup = ! empty($policy['force_artifact_cleanup']); $artifact_rows = $this->pending_rows_of_type($items, 'artifact_cleanup'); $worktree_rows = $this->pending_rows_of_type($items, 'worktree_removal'); $stale_worktrees_only = 'stale-worktrees' === (string) ( $run['mode'] ?? '' ); @@ -835,6 +836,7 @@ private function terminal_empty_safe_cleanup_state( array $run, array $summary ) */ private function remaining_work_summary( array $run, string $run_id, array $items, array $progress ): array { $summary = CleanupRemainingWorkSummary::from_items($items); + $policy = (array) ( $run['policy'] ?? array() ); $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 ( ! empty($progress['resumable']) && isset($safe_commands['status'], $safe_commands['resume']) ) { @@ -854,7 +856,7 @@ private function remaining_work_summary( array $run, string $run_id, array $item $resume_command = array( 'bucket' => 'current_run_resume', 'command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), - 'apply' => $this->cleanup_run_resume_command($run_id, self::DEFAULT_APPLY_LIMIT, ! empty( (array) ( $run['policy'] ?? array() )['force_artifact_cleanup'] )), + 'apply' => $this->cleanup_run_resume_command($run_id, self::DEFAULT_APPLY_LIMIT, ! empty($policy['force_artifact_cleanup'])), 'destructive' => false, 'apply_destructive' => true, 'why' => 'Resume the reviewed DB-backed cleanup run from persisted pending/failed/applying rows.', diff --git a/inc/Workspace/WorkspaceArtifactCleanup.php b/inc/Workspace/WorkspaceArtifactCleanup.php index 9dd1287..a81769f 100644 --- a/inc/Workspace/WorkspaceArtifactCleanup.php +++ b/inc/Workspace/WorkspaceArtifactCleanup.php @@ -116,20 +116,25 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E } if ( $rank_by_size ) { - usort($candidates, fn( $a, $b ) => (int) ( $b['artifact_size_bytes'] ?? 0 ) <=> (int) ( $a['artifact_size_bytes'] ?? 0 )); + usort($candidates, function ( $a, $b ): int { + $size = (int) ( $b['artifact_size_bytes'] ?? 0 ) <=> (int) ( $a['artifact_size_bytes'] ?? 0 ); + return 0 !== $size ? $size : strcmp( (string) ( $a['handle'] ?? '' ), (string) ( $b['handle'] ?? '' )); + }); $total_ranked = count($candidates); + $rank_offset = min($offset, $total_ranked); if ( $limit > 0 ) { - $candidates = array_slice($candidates, 0, $limit); + $candidates = array_slice($candidates, $rank_offset, $limit); } + $rank_end = $limit > 0 ? min($rank_offset + $limit, $total_ranked) : $total_ranked; $pagination = array( 'mode' => 'ranked_inventory', 'limit' => $limit, - 'offset' => 0, + 'offset' => $rank_offset, 'scanned' => (int) ( $pagination['scanned'] ?? 0 ), 'total' => (int) ( $pagination['total'] ?? 0 ), - 'complete' => true, - 'partial' => false, - 'next_offset' => null, + 'complete' => $rank_end >= $total_ranked, + 'partial' => $rank_end < $total_ranked, + 'next_offset' => $rank_end < $total_ranked ? $rank_end : null, 'safety_probes' => $safety_probes, 'sort' => 'size', 'ranked_total' => $total_ranked, @@ -432,15 +437,19 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts } } if ( $dirty_count > 0 && ! $force ) { - $skipped[] = array_merge( - $base_row, array( - 'reason_code' => 'dirty_worktree', - 'reason' => sprintf('working tree dirty (%d files) - pass force=true to override artifact cleanup only', $dirty_count), - 'dirty' => $dirty_count, - 'artifacts' => $artifacts, - ) - ); - continue; + $artifact_dirty = $this->classify_artifact_only_dirty_worktree($repo, $wt_path); + if ( null === $artifact_dirty ) { + $skipped[] = array_merge( + $base_row, array( + 'reason_code' => 'dirty_worktree', + 'reason' => sprintf('working tree contains source changes (%d dirty paths) - leaving artifacts in place', $dirty_count), + 'dirty' => $dirty_count, + 'artifacts' => $artifacts, + ) + ); + continue; + } + $base_row['artifact_dirty_paths'] = $artifact_dirty['paths']; } if ( null === $stale_marker_recovery ) { @@ -549,7 +558,7 @@ private function build_worktree_artifact_cleanup_summary( array $candidates, arr foreach ( $removed as $row ) { foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) { - $removed_bytes += max(0, (int) ( is_array($artifact) ? ( $artifact['size_bytes'] ?? 0 ) : 0 )); + $removed_bytes += max(0, (int) ( is_array($artifact) ? ( $artifact['removal']['bytes_reclaimed'] ?? $artifact['size_bytes'] ?? 0 ) : 0 )); ++$removed_count; } } @@ -696,6 +705,11 @@ private function scope_worktree_artifact_cleanup_to_plan( array $planned_candida $mismatches[] = 'artifact:' . $relative; continue; } + if ( is_array($planned_artifact) && array_key_exists('size_bytes', $planned_artifact) + && (int) $planned_artifact['size_bytes'] !== (int) ( $current_artifacts[ $relative ]['size_bytes'] ?? -1 ) ) { + $mismatches[] = 'artifact_size:' . $relative; + continue; + } $artifacts[] = $current_artifacts[ $relative ]; } @@ -758,6 +772,7 @@ private function remove_worktree_artifact_path( string $worktree_path, string $r if ( ! $artifact_validation['valid'] || '' === $artifact_real || $artifact_real === $worktree_real ) { return new \WP_Error('artifact_path_outside_worktree', sprintf('Refusing artifact cleanup for %s: %s', $relative, $artifact_validation['message'] ?? ''), array( 'status' => 403 )); } + $bytes_reclaimed = max(0, $this->estimate_path_size_bytes($artifact_real)); $output = array(); $exit = 0; @@ -778,10 +793,11 @@ private function remove_worktree_artifact_path( string $worktree_path, string $r } return array( - 'resolved_path' => $artifact_real, - 'exit_code' => $exit, - 'exists_after' => false, - 'verified_at' => gmdate('c'), + 'resolved_path' => $artifact_real, + 'bytes_reclaimed' => $bytes_reclaimed, + 'exit_code' => $exit, + 'exists_after' => false, + 'verified_at' => gmdate('c'), ); } diff --git a/inc/Workspace/WorkspaceCleanupPlan.php b/inc/Workspace/WorkspaceCleanupPlan.php index 96d570d..00f0886 100644 --- a/inc/Workspace/WorkspaceCleanupPlan.php +++ b/inc/Workspace/WorkspaceCleanupPlan.php @@ -34,7 +34,9 @@ trait WorkspaceCleanupPlan { * @return array|\WP_Error */ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error { + $mode = (string) ( $opts['mode'] ?? 'cleanup_plan' ); $inputs = array( + 'mode' => $mode, 'force_artifact_cleanup' => ! empty($opts['force_artifact_cleanup']), 'include_artifacts' => array_key_exists('include_artifacts', $opts) ? (bool) $opts['include_artifacts'] : true, 'include_worktrees' => array_key_exists('include_worktrees', $opts) ? (bool) $opts['include_worktrees'] : true, @@ -46,7 +48,7 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'full_workspace' => ! empty($opts['full_workspace']), 'worktree_older_than' => isset($opts['worktree_older_than']) ? trim( (string) $opts['worktree_older_than']) : '', 'worktree_sort' => isset($opts['worktree_sort']) && '' !== trim( (string) $opts['worktree_sort']) ? trim( (string) $opts['worktree_sort']) : '', - 'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : '', + 'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : ( 'artifacts' === $mode ? 'size' : '' ), 'worktree_stale_only' => ! empty($opts['worktree_stale_only']), ); @@ -244,7 +246,10 @@ public function workspace_cleanup_plan_chunks( array $opts = array() ): array|\W */ private function prepare_cleanup_plan_rows( string $type, array $rows, string $safety_class ): array { $result = array(); - usort($rows, fn( $a, $b ) => $this->cleanup_plan_reclaimable_bytes( (array) $b) <=> $this->cleanup_plan_reclaimable_bytes( (array) $a)); + usort($rows, function ( $a, $b ): int { + $size = $this->cleanup_plan_reclaimable_bytes( (array) $b) <=> $this->cleanup_plan_reclaimable_bytes( (array) $a); + return 0 !== $size ? $size : strcmp( (string) ( $a['handle'] ?? '' ), (string) ( $b['handle'] ?? '' )); + }); foreach ( $rows as $row ) { if ( ! is_array($row) ) { continue; @@ -467,6 +472,7 @@ private function build_cleanup_plan_continuation( array $artifact_plan, array $w } $complete = null === $next_offset; + $mode = (string) ( $inputs['mode'] ?? 'retention' ); return array( 'bounded' => empty($inputs['full_workspace']), 'complete' => $complete, @@ -475,8 +481,8 @@ private function build_cleanup_plan_continuation( array $artifact_plan, array $w 'offset' => $offset, 'next_offset' => $next_offset, 'lanes' => $lanes, - 'next_command' => null === $next_offset ? null : sprintf('studio wp datamachine-code workspace cleanup plan --mode=retention --limit=%d --offset=%d --format=json', $limit, $next_offset), - 'full_audit_command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --exhaustive --format=json', + 'next_command' => null === $next_offset ? null : sprintf('studio wp datamachine-code workspace cleanup plan --mode=%s --limit=%d --offset=%d --format=json', $mode, $limit, $next_offset), + 'full_audit_command' => sprintf('studio wp datamachine-code workspace cleanup plan --mode=%s --exhaustive --format=json', $mode), 'operator_note' => empty($inputs['full_workspace']) ? 'Default cleanup planning is bounded for large workspaces; review/apply this page or continue with next_command for the next page.' : 'Full-workspace cleanup audit requested explicitly.', ); } diff --git a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php index 0545927..8d77934 100644 --- a/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceSafeCleanupOrchestrator.php @@ -65,6 +65,10 @@ public function run( array $input ): array|\WP_Error { if ( is_wp_error($active_no_signal) ) { return $active_no_signal; } + $artifact_cleanup = $this->resolve_ability($dry_run ? 'datamachine-code/workspace-cleanup-plan' : 'datamachine-code/workspace-cleanup-until-empty'); + if ( is_wp_error($artifact_cleanup) ) { + return $artifact_cleanup; + } $result = array( 'success' => true, @@ -78,6 +82,10 @@ public function run( array $input ): array|\WP_Error { 'steps' => array(), 'summary' => array( 'cycles' => 0, + 'planned' => 0, + 'applied_rows' => 0, + 'skipped_rows' => 0, + 'would_reclaim_bytes' => 0, 'removed' => 0, 'would_remove' => 0, 'marked_cleanup_eligible' => 0, @@ -120,6 +128,28 @@ public function run( array $input ): array|\WP_Error { $result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_start']['removed_count'] ?? 0 ); $this->checkpoint_progress($run_id, $result, 'applying'); + $artifact_input = $dry_run + ? array( + 'mode' => 'artifacts', + 'include_artifacts' => true, + 'include_worktrees' => false, + 'include_resolvers' => false, + 'limit' => $limit, + ) + : array( + 'mode' => 'artifacts', + 'force' => false, + 'limit' => $limit, + 'max_passes' => $passes, + ); + $artifacts = $this->execute_ability($artifact_cleanup, $artifact_input); + if ( is_wp_error($artifacts) ) { + return $artifacts; + } + $result['steps']['artifact_cleanup'] = $this->summarize_artifact_step($artifacts, $dry_run); + $this->accumulate_artifact_step($result, $result['steps']['artifact_cleanup']); + $this->checkpoint_progress($run_id, $result, 'applying'); + $common = array( 'apply' => ! $dry_run, 'force' => false, @@ -311,6 +341,54 @@ private function summarize_cleanup_step( array $step ): array { ); } + /** @return array */ + private function summarize_artifact_step( array $step, bool $dry_run ): array { + $rows = (array) ( $step['rows']['artifact_cleanup'] ?? array() ); + $blocked = (array) ( $step['blocked']['artifact_cleanup'] ?? array() ); + $passes = (array) ( $step['passes'] ?? array() ); + $planned = $dry_run ? count($rows) : array_sum(array_map(static fn( array $pass ): int => (int) ( $pass['planned_rows'] ?? 0 ), $passes)); + $blockers = array(); + if ( $dry_run ) { + foreach ( $blocked as $row ) { + $reason = (string) ( is_array($row) ? ( $row['reason_code'] ?? 'unknown' ) : 'unknown' ); + $blockers[ $reason ] = ( $blockers[ $reason ] ?? 0 ) + 1; + } + } else { + foreach ( (array) ( $step['remaining_blocked_reasons'] ?? array() ) as $reason => $bucket ) { + $blockers[ (string) $reason ] = max(0, (int) ( is_array($bucket) ? ( $bucket['count'] ?? 0 ) : $bucket )); + } + } + + return array( + 'mode' => 'artifacts', + 'applied' => ! $dry_run, + 'planned' => $planned, + 'applied_rows' => $dry_run ? 0 : (int) ( $step['applied'] ?? 0 ), + 'skipped_rows' => $dry_run ? count($blocked) : (int) ( $step['skipped'] ?? 0 ), + 'bytes_reclaimed' => $dry_run ? 0 : (int) ( $step['bytes_reclaimed'] ?? 0 ), + 'would_reclaim' => $dry_run ? (int) ( $step['summary']['total_reclaimable_bytes'] ?? 0 ) : 0, + 'blockers' => array_filter($blockers), + 'state' => (string) ( $step['state'] ?? $step['status'] ?? 'planned' ), + ); + } + + /** @param array $step */ + private function accumulate_artifact_step( array &$result, array $step ): void { + $result['summary']['planned'] += (int) ( $step['planned'] ?? 0 ); + $result['summary']['applied_rows'] += (int) ( $step['applied_rows'] ?? 0 ); + $result['summary']['skipped_rows'] += (int) ( $step['skipped_rows'] ?? 0 ); + $result['summary']['would_reclaim_bytes'] += (int) ( $step['would_reclaim'] ?? 0 ); + $result['summary']['removed'] += (int) ( $step['applied_rows'] ?? 0 ); + $result['summary']['would_remove'] += (int) ( $step['planned'] ?? 0 ); + $result['summary']['bytes_reclaimed'] += (int) ( $step['bytes_reclaimed'] ?? 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/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 111dc5d..74ad163 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -2784,7 +2784,7 @@ private function detect_worktree_artifacts( string $repo, string $path ): array * @param string $path Worktree path. * @return array{paths: array, artifacts: array>, artifact_size_bytes: int}|null Artifact-only classification. */ - private function classify_artifact_only_dirty_worktree( string $repo, string $path ): ?array { + protected function classify_artifact_only_dirty_worktree( string $repo, string $path ): ?array { if ( '' === $repo || '' === $path || ! is_dir($path) ) { return null; } diff --git a/tests/artifact-cleanup-reviewed-run.php b/tests/artifact-cleanup-reviewed-run.php new file mode 100644 index 0000000..30a3554 --- /dev/null +++ b/tests/artifact-cleanup-reviewed-run.php @@ -0,0 +1,251 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } + } + + function is_wp_error(mixed $value): bool { return $value instanceof WP_Error; } + function wp_json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false { return json_encode($value, $flags, $depth); } + function wp_generate_password(int $length = 12, bool $special = true, bool $extra = false): string { return substr(str_repeat('a', $length), 0, $length); } + function apply_filters(string $hook, mixed $value, mixed ...$args): mixed { return $value; } +} + +namespace DataMachineCode\Workspace { + require_once dirname(__DIR__) . '/inc/Support/JsonCodec.php'; + require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupClassifier.php'; + require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceArtifactCleanup.php'; + require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCleanupPlan.php'; + require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php'; + + final class Workspace { + use WorkspaceArtifactCleanup; + use WorkspaceCleanupPlan; + use WorkspaceWorktreeCleanupEngine; + + public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100; + public const CLEANUP_PLAN_DEFAULT_LIMIT = 100; + public const CLEANUP_PLAN_DEFAULT_BUDGET = '30s'; + public const METADATA_RECONCILE_DEFAULT_LIMIT = 25; + public const METADATA_RECONCILE_DEFAULT_BUDGET = '30s'; + protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; + protected const CLEANUP_GIT_REMOVE_TIMEOUT = 60; + protected const CLEANUP_GITHUB_TIMEOUT = 5; + protected const CLEANUP_GITHUB_MAX_PAGES = 3; + protected const CLEANUP_SUMMARY_TOP_LIMIT = 10; + + public array $rows = array(); + public array $dirty = array(); + public array $unpushed = array(); + public string $workspace_path; + + public function __construct(string $workspace_path) { $this->workspace_path = $workspace_path; } + + public function worktree_cleanup_merged(array $opts = array()): array { + return array('candidates' => array(), 'removed' => array(), 'skipped' => array(), 'summary' => array()); + } + + private function build_workspace_inventory_rows(): array { return $this->rows; } + private function resolve_worktree_branch_from_head_file(string $path): ?string { + foreach ($this->rows as $row) { + if (($row['path'] ?? '') === $path) { + return (string) ($row['branch'] ?? ''); + } + } + return null; + } + private function probe_worktree_dirty_count(string $path, int $timeout = 0): int|\WP_Error { + unset($timeout); + $status = trim((string) ($this->dirty[$path] ?? '')); + return '' === $status ? 0 : count(explode("\n", $status)); + } + private function count_unpushed_commits(string $path, int $timeout = 0): int|\WP_Error { + unset($timeout); + return (int) ($this->unpushed[$path] ?? 0); + } + private function run_git(string $path, string $command, int $timeout = 0): array|\WP_Error { + unset($timeout); + return array('output' => str_starts_with($command, 'status --porcelain') ? (string) ($this->dirty[$path] ?? '') : ''); + } + private function classify_worktree_git_probe_failure(string $handle, string $repo, string $path, \WP_Error $error, string $probe, string $action): array { + return array( + 'handle' => $handle, + 'repo' => $repo, + 'path' => $path, + 'reason_code' => $error->get_error_code(), + 'reason' => sprintf('%s failed; %s: %s', $probe, $action, $error->get_error_message()), + ); + } + public function validate_containment(string $target, string $container): array { + $real_target = realpath($target); + $real_root = realpath($container); + $valid = is_string($real_target) && is_string($real_root) + && ($real_target === $real_root || str_starts_with($real_target, rtrim($real_root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)); + return array('valid' => $valid, 'real_path' => $valid ? $real_target : null, 'message' => $valid ? '' : 'outside fixture workspace'); + } + } +} + +namespace { + require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepositoryInterface.php'; + require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepository.php'; + require_once dirname(__DIR__) . '/inc/Cleanup/CleanupRemainingWorkSummary.php'; + require_once dirname(__DIR__) . '/inc/Workspace/CleanupRunService.php'; + + final class ReviewedArtifactRepository extends DataMachineCode\Storage\CleanupRunRepository { + public array $runs = array(); + public array $items = array(); + private int $next_id = 1; + + public function create_run(array $run): string|WP_Error { + $id = 'cleanup-run-reviewed'; + $this->runs[$id] = $run + array('run_id' => $id); + return $id; + } + public function add_items(string $run_id, array $items): int|WP_Error { + foreach ($items as $item) { + $this->items[$run_id][] = $item + array('id' => $this->next_id++); + } + return count($items); + } + public function get_run(string $run_id): ?array { return $this->runs[$run_id] ?? null; } + public function get_items(string $run_id): array { return $this->items[$run_id] ?? array(); } + public function update_run(string $run_id, array $fields): bool { + $this->runs[$run_id] = array_merge($this->runs[$run_id], $fields); + return true; + } + public function update_item(int $id, array $fields): bool { + foreach ($this->items as &$items) { + foreach ($items as &$item) { + if ($id === $item['id']) { + $item = array_merge($item, $fields); + return true; + } + } + } + return false; + } + } + + function reviewed_artifact_assert(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true)); + } + } + + function reviewed_artifact_remove_tree(string $path): void { + if (!is_dir($path)) { + return; + } + foreach (array_diff(scandir($path) ?: array(), array('.', '..')) as $entry) { + $child = $path . '/' . $entry; + is_dir($child) ? reviewed_artifact_remove_tree($child) : unlink($child); + } + rmdir($path); + } + + $root = sys_get_temp_dir() . '/dmc-reviewed-artifact-' . getmypid(); + mkdir($root, 0777, true); + $workspace = new DataMachineCode\Workspace\Workspace($root); + + $specs = array( + 'a-empty' => 0, + 'b-empty' => 0, + 'v-changed' => 70000, + 'w-unpushed' => 60000, + 'x-source' => 50000, + 'y-artifact-dirty' => 40000, + 'z-clean' => 30000, + ); + foreach ($specs as $slug => $bytes) { + $path = $root . '/repo@' . $slug; + mkdir($path, 0777, true); + if ($bytes > 0) { + file_put_contents($path . '/composer.json', '{}'); + mkdir($path . '/vendor', 0777, true); + file_put_contents($path . '/vendor/generated.bin', str_repeat('x', $bytes)); + } + $workspace->rows[] = array( + 'handle' => 'repo@' . $slug, + 'repo' => 'repo', + 'branch' => 'test/' . $slug, + 'path' => $path, + 'is_worktree' => true, + 'is_primary' => false, + ); + } + + $preview = $workspace->worktree_cleanup_artifacts(array('dry_run' => true, 'sort' => 'size', 'limit' => 2)); + $plan_page = $workspace->workspace_cleanup_plan(array('mode' => 'artifacts', 'include_worktrees' => false, 'limit' => 2)); + reviewed_artifact_assert( + array_column($preview['candidates'], 'artifact_size_bytes', 'handle'), + array_column($plan_page['rows']['artifact_cleanup'], 'artifact_size_bytes', 'handle'), + 'size-ranked preview and high-level plan must preserve identical candidate identities and sizes' + ); + reviewed_artifact_assert(2, $plan_page['continuation']['next_offset'] ?? null, 'ranked candidate pagination should continue at the next candidate'); + $page_two = $workspace->workspace_cleanup_plan(array('mode' => 'artifacts', 'include_worktrees' => false, 'limit' => 2, 'offset' => 2)); + reviewed_artifact_assert( + array('repo@x-source', 'repo@y-artifact-dirty'), + array_column($page_two['rows']['artifact_cleanup'], 'handle'), + 'second ranked page should contain the next artifact candidates, not the next raw worktree handles' + ); + + $workspace->dirty[$root . '/repo@y-artifact-dirty'] = "?? vendor/\n"; + $workspace->dirty[$root . '/repo@x-source'] = " M source.php\n?? vendor/\n"; + $workspace->unpushed[$root . '/repo@w-unpushed'] = 1; + file_put_contents($root . '/repo@x-source/source.php', 'plan(array( + 'mode' => 'artifacts', + 'include_artifacts' => true, + 'include_worktrees' => false, + 'include_resolvers' => false, + 'limit' => 10, + )); + reviewed_artifact_assert(5, $reviewed['cleanup_storage']['item_count'] ?? null, 'all ranked artifact candidates should persist as reviewed cleanup rows'); + + file_put_contents($root . '/repo@v-changed/vendor/generated.bin', str_repeat('y', 140000)); + $applied = $service->apply('cleanup-run-reviewed', array('limit' => 10)); + reviewed_artifact_assert(2, $applied['applied'] ?? null, 'clean and artifact-only dirty rows should apply: ' . json_encode($applied['results'] ?? array())); + reviewed_artifact_assert(3, $applied['skipped'] ?? null, 'changed, source-dirty, and unpushed rows should be skipped'); + reviewed_artifact_assert(false, is_dir($root . '/repo@z-clean/vendor'), 'clean reconstructable artifact should be removed'); + reviewed_artifact_assert(false, is_dir($root . '/repo@y-artifact-dirty/vendor'), 'artifact-only dirty path should be removed'); + reviewed_artifact_assert(true, is_file($root . '/repo@x-source/source.php'), 'dirty source file must be preserved'); + reviewed_artifact_assert(true, is_dir($root . '/repo@x-source/vendor'), 'source-dirty worktree artifact must remain blocked'); + reviewed_artifact_assert(true, is_dir($root . '/repo@w-unpushed/vendor'), 'unpushed worktree artifact must remain blocked'); + reviewed_artifact_assert(true, is_dir($root . '/repo@v-changed/vendor'), 'artifact changed after review must remain blocked'); + + $items = $repository->get_items('cleanup-run-reviewed'); + $reasons = array_column($items, 'reason_code', 'handle'); + reviewed_artifact_assert('artifact_plan_mismatch', $reasons['repo@v-changed'] ?? null, 'changed artifact size should fail reviewed snapshot revalidation'); + reviewed_artifact_assert('dirty_worktree', $reasons['repo@x-source'] ?? null, 'source dirt should retain the dirty-worktree protection'); + reviewed_artifact_assert('unpushed_commits', $reasons['repo@w-unpushed'] ?? null, 'unpushed commits should retain their protection'); + reviewed_artifact_assert( + (int) ($applied['results']['artifact_cleanup']['summary']['removed_size_bytes'] ?? 0), + (int) ($applied['summary']['bytes_reclaimed'] ?? 0), + 'persisted status bytes should match measured artifact removal bytes' + ); + reviewed_artifact_assert(5, $applied['cleanup_items']['planned_rows'] ?? null, 'status should report persisted planned rows'); + reviewed_artifact_assert(2, $applied['cleanup_items']['applied_rows'] ?? null, 'status should report applied rows'); + reviewed_artifact_assert(3, $applied['cleanup_items']['skipped_rows'] ?? null, 'status should report skipped rows'); + + $drained = $service->apply('cleanup-run-reviewed', array('limit' => 10)); + reviewed_artifact_assert('completed', $drained['state'] ?? null, 'repeated drain/apply should remain terminal after all reviewed rows are recorded'); + reviewed_artifact_assert(0, $drained['processed'] ?? null, 'terminal drain/apply should process no duplicate rows'); + + reviewed_artifact_remove_tree($root); + fwrite(STDOUT, "artifact-cleanup-reviewed-run ok\n"); +} diff --git a/tests/workspace-safe-cleanup-orchestrator.php b/tests/workspace-safe-cleanup-orchestrator.php index c95949d..1fb5079 100644 --- a/tests/workspace-safe-cleanup-orchestrator.php +++ b/tests/workspace-safe-cleanup-orchestrator.php @@ -176,6 +176,19 @@ function safe_cleanup_assert( bool $condition, string $label ): void { ), ) ); +$artifact_cleanup = new SafeCleanupQueuedAbility( + array( + array( + 'success' => true, + 'state' => 'completed', + 'applied' => 2, + 'skipped' => 1, + 'bytes_reclaimed' => 2048, + 'remaining_blocked_reasons' => array( 'artifact_plan_mismatch' => array( 'count' => 1 ) ), + 'passes' => array( array( 'planned_rows' => 3 ) ), + ), + ) +); $lock_calls = array(); $run_repository = new SafeCleanupFakeRunRepository(); $progress_envelopes = array(); @@ -183,6 +196,7 @@ function safe_cleanup_assert( bool $condition, string $label ): void { static fn( string $name ) => match ( $name ) { 'datamachine-code/workspace-worktree-cleanup-eligible-drain' => $cleanup_eligible, 'datamachine-code/workspace-worktree-active-no-signal-drain' => $active_no_signal, + 'datamachine-code/workspace-cleanup-until-empty' => $artifact_cleanup, default => null, }, static function ( bool $dry_run ) use ( &$lock_calls ): array { @@ -218,16 +232,21 @@ static function ( bool $dry_run ) use ( &$lock_calls ): array { safe_cleanup_assert(2 === count($cleanup_eligible->calls), 'cleanup eligible drain repeats until no progress'); safe_cleanup_assert(false === $cleanup_eligible->calls[0]['force'], 'child force is false'); safe_cleanup_assert(false === $cleanup_eligible->calls[0]['discard_unpushed'], 'child discard_unpushed is false'); -safe_cleanup_assert(2 === ( $result['summary']['removed'] ?? null ), 'removed rows are accumulated'); +safe_cleanup_assert(4 === ( $result['summary']['removed'] ?? null ), 'artifact and worktree removals are accumulated'); +safe_cleanup_assert(3 === ( $result['summary']['planned'] ?? null ), 'artifact reviewed rows are counted'); +safe_cleanup_assert(2 === ( $result['summary']['applied_rows'] ?? null ), 'artifact applied rows are counted'); +safe_cleanup_assert(1 === ( $result['summary']['skipped_rows'] ?? null ), 'artifact skipped rows are counted'); +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(6 === ( $result['summary']['blocker_count'] ?? null ), 'compact blockers are counted'); +safe_cleanup_assert(7 === ( $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(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'); +safe_cleanup_assert(4 === ( $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(