diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index a9d11129..2f80432b 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1547,9 +1547,9 @@ private function registerAbilities(): void { 'description' => 'Captured session identifiers in a runtime-agnostic envelope. `primary_id` is the single renderer-friendly identifier downstream surfaces display. `ids` is a free-form map keyed by runtime ID (a string the integration layer chooses, e.g. via the `datamachine_code_worktree_runtime_signatures` filter); each entry is a string-map of subkeys (e.g. session_id, thread_id, thread_url, run_id) the integration chose to capture. DMC enumerates no runtime IDs and no subkeys.', 'properties' => array( 'primary_id' => array( 'type' => array( 'string', 'null' ) ), - 'ids' => array( - 'type' => 'object', - 'description' => 'Map of runtime-id => { subkey => string|null }. Keys are opaque; DMC does not validate against a closed set.', + 'ids' => array( + 'type' => 'object', + 'description' => 'Map of runtime-id => { subkey => string|null }. Keys are opaque; DMC does not validate against a closed set.', 'additionalProperties' => array( 'type' => 'object', 'additionalProperties' => array( 'type' => array( 'string', 'null' ) ), @@ -2874,6 +2874,17 @@ public static function workspaceCleanupRun( array $input ): array|\WP_Error { if ( isset( $input['older_than'] ) && '' !== trim( (string) $input['older_than'] ) ) { $params['worktree_older_than'] = trim( (string) $input['older_than'] ); } + if ( 'artifacts' === $mode ) { + if ( isset( $input['limit'] ) ) { + $params['limit'] = (int) $input['limit']; + } + if ( isset( $input['offset'] ) ) { + $params['offset'] = (int) $input['offset']; + } + if ( ! empty( $input['exhaustive'] ) ) { + $params['exhaustive'] = true; + } + } $context = array(); if ( isset( $input['user_id'] ) ) { diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 63c1926d..78270c20 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -307,14 +307,14 @@ 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 --dry-run` page. - * Defaults to 100 — keeps dry-run bounded on workspaces with hundreds of - * worktrees. Use 0 to disable the cap (combine with --exhaustive for a - * full audit). + * : 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). * * [--offset=] - * : Pagination offset (0-indexed) for `--mode=artifacts --dry-run`. Walk - * huge workspaces by feeding the previous response's + * : Pagination offset (0-indexed) for `--mode=artifacts` dry-run and apply + * pages. Walk huge workspaces by feeding the previous response's * `pagination.next_offset` until `pagination.complete` is true. * * [--exhaustive] @@ -465,6 +465,17 @@ private function cleanup_run_input( string $mode, array $assoc_args ): array { if ( isset( $assoc_args['older-than'] ) && '' !== trim( (string) $assoc_args['older-than'] ) ) { $input['older_than'] = trim( (string) $assoc_args['older-than'] ); } + if ( 'artifacts' === $mode ) { + if ( isset( $assoc_args['limit'] ) ) { + $input['limit'] = (int) $assoc_args['limit']; + } + if ( isset( $assoc_args['offset'] ) ) { + $input['offset'] = (int) $assoc_args['offset']; + } + if ( ! empty( $assoc_args['exhaustive'] ) ) { + $input['exhaustive'] = true; + } + } return $input; } diff --git a/inc/Tasks/WorkspaceRetentionCleanupTask.php b/inc/Tasks/WorkspaceRetentionCleanupTask.php index 91cee01e..7aedb7df 100644 --- a/inc/Tasks/WorkspaceRetentionCleanupTask.php +++ b/inc/Tasks/WorkspaceRetentionCleanupTask.php @@ -131,8 +131,8 @@ public function executeTask( int $jobId, array $params ): void { * @return array|\WP_Error */ private function schedule_job_backed_cleanup( int $jobId, Workspace $workspace, array $opts, array $params ): array|\WP_Error { - $started_at = microtime( true ); - $chunk_rows = $this->build_cleanup_chunk_rows( $workspace, $opts, $params ); + $started_at = microtime( true ); + $chunk_rows = $this->build_cleanup_chunk_rows( $workspace, $opts, $params ); if ( $chunk_rows instanceof \WP_Error ) { return $chunk_rows; } @@ -146,22 +146,22 @@ private function schedule_job_backed_cleanup( int $jobId, Workspace $workspace, if ( array() === $item_params ) { return array( - 'success' => true, - 'dry_run' => false, - 'destructive' => false, - 'job_backed' => true, - 'generated_at' => gmdate( 'c' ), - 'workspace_path' => $workspace->get_path(), - 'chunk_row_counts' => $chunk_row_counts, - 'chunks' => array(), - 'report' => array( - 'removed_count' => 0, - 'bytes_reclaimed' => 0, - 'freed_human' => '0 B', - 'skipped_dirty_unpushed_count' => 0, - 'remaining_disk_budget_human' => 'unknown disk', + 'success' => true, + 'dry_run' => false, + 'destructive' => false, + 'job_backed' => true, + 'generated_at' => gmdate( 'c' ), + 'workspace_path' => $workspace->get_path(), + 'chunk_row_counts' => $chunk_row_counts, + 'chunks' => array(), + 'report' => array( + 'removed_count' => 0, + 'bytes_reclaimed' => 0, + 'freed_human' => '0 B', + 'skipped_dirty_unpushed_count' => 0, + 'remaining_disk_budget_human' => 'unknown disk', ), - 'evidence' => array( + 'evidence' => array( 'elapsed_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ), 'note' => 'No cleanup chunks were eligible after plan generation.', ), @@ -188,34 +188,34 @@ private function schedule_job_backed_cleanup( int $jobId, Workspace $workspace, } return array( - 'success' => true, - 'dry_run' => false, - 'destructive' => true, - 'job_backed' => true, - 'generated_at' => gmdate( 'c' ), - 'workspace_path' => $workspace->get_path(), - 'policy' => array( + 'success' => true, + 'dry_run' => false, + 'destructive' => true, + 'job_backed' => true, + 'generated_at' => gmdate( 'c' ), + 'workspace_path' => $workspace->get_path(), + 'policy' => array( 'worktree_cleanup' => (bool) $opts['worktree_cleanup'], 'artifact_cleanup' => (bool) $opts['artifact_cleanup'], 'worktree_older_than' => (string) ( $opts['worktree_older_than'] ?? '14d' ), 'skip_github' => (bool) $opts['skip_github'], 'force' => (bool) $opts['force'], ), - 'chunk_row_counts' => $chunk_row_counts, - 'chunks' => $batch, - 'report' => array( + 'chunk_row_counts' => $chunk_row_counts, + 'chunks' => $batch, + 'report' => array( 'removed_count' => 0, 'bytes_reclaimed' => 0, 'freed_human' => 'pending child jobs', 'skipped_dirty_unpushed_count' => 0, 'remaining_disk_budget_human' => 'pending child jobs', ), - 'evidence' => array( - 'elapsed_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ), - 'planned_chunks' => count( $item_params ), - 'planned_handles' => $this->cleanup_chunk_handles( $chunk_rows ), - 'batch_job_id' => (int) ( $batch['batch_job_id'] ?? 0 ), - 'direct_job_ids' => $batch['job_ids'] ?? array(), + 'evidence' => array( + 'elapsed_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ), + 'planned_chunks' => count( $item_params ), + 'planned_handles' => $this->cleanup_chunk_handles( $chunk_rows ), + 'batch_job_id' => (int) ( $batch['batch_job_id'] ?? 0 ), + 'direct_job_ids' => $batch['job_ids'] ?? array(), ), ); } @@ -236,25 +236,21 @@ private function build_cleanup_chunk_rows( Workspace $workspace, array $opts, ar ); if ( ! empty( $opts['artifact_cleanup'] ) ) { - $page_size = max( 1, (int) ( $params['artifact_chunk_size'] ?? 10 ) ); - $artifact_page = $workspace->worktree_cleanup_artifacts( + $artifact_limit = isset( $params['limit'] ) ? max( 0, (int) $params['limit'] ) : 100; + $artifact_page = $workspace->worktree_cleanup_artifacts( array( - 'dry_run' => true, - 'force' => ! empty( $opts['force'] ), - 'limit' => 1, - 'offset' => 0, + 'dry_run' => true, + 'force' => ! empty( $opts['force'] ), + 'limit' => $artifact_limit, + 'offset' => isset( $params['offset'] ) ? max( 0, (int) $params['offset'] ) : 0, + 'exhaustive' => ! empty( $params['exhaustive'] ), + 'safety_probes' => true, ) ); if ( $artifact_page instanceof \WP_Error ) { return $artifact_page; } - $total = max( 0, (int) ( $artifact_page['pagination']['total'] ?? $artifact_page['summary']['pagination']['total'] ?? 0 ) ); - for ( $offset = 0; $offset < $total; $offset += $page_size ) { - $rows['artifact_discovery'][] = array( - 'offset' => $offset, - 'limit' => $page_size, - ); - } + $rows['artifacts'] = array_values( (array) $artifact_page['candidates'] ); } if ( ! empty( $opts['worktree_cleanup'] ) ) { @@ -270,7 +266,7 @@ private function build_cleanup_chunk_rows( Workspace $workspace, array $opts, ar if ( $worktree_plan instanceof \WP_Error ) { return $worktree_plan; } - $rows['worktrees'] = array_values( (array) ( $worktree_plan['candidates'] ?? array() ) ); + $rows['worktrees'] = array_values( (array) $worktree_plan['candidates'] ); } return $rows; diff --git a/tests/smoke-workspace-retention-task.php b/tests/smoke-workspace-retention-task.php index 900c5238..52fa336c 100644 --- a/tests/smoke-workspace-retention-task.php +++ b/tests/smoke-workspace-retention-task.php @@ -97,7 +97,15 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array { return array( 'success' => true, 'dry_run' => true, - 'candidates' => array(), + 'candidates' => array( + array( + 'handle' => 'repo@active', + 'repo' => 'repo', + 'branch' => 'active', + 'path' => '/tmp/dmc-retention-task-workspace/repo@active', + 'artifacts' => array( array( 'path' => 'vendor', 'size_bytes' => 1024 ) ), + ), + ), 'skipped' => array(), 'summary' => array( 'pagination' => array( @@ -180,7 +188,7 @@ function datamachine_code_retention_task_assert( bool $condition, string $messag datamachine_code_retention_task_assert( empty( $completed[0][1]['skipped'] ), 'explicit CLI run bypasses disabled recurring schedule' ); datamachine_code_retention_task_assert( true === (bool) ( $completed[0][1]['dry_run'] ?? false ), 'explicit CLI run forwards task params' ); - echo "\n[4] Artifact cleanup schedules bounded discovery chunks\n"; + echo "\n[4] Artifact cleanup freezes bounded candidates before scheduling chunks\n"; \DataMachine\Engine\Tasks\TaskScheduler::$batches = array(); \DataMachineCode\Workspace\Workspace::$artifact_opts = array(); $task = new \DataMachineCode\Tasks\WorkspaceRetentionCleanupTask(); @@ -191,16 +199,19 @@ function datamachine_code_retention_task_assert( bool $condition, string $messag 'artifact_cleanup' => true, 'worktree_cleanup' => false, 'artifact_chunk_size' => 10, + 'limit' => 100, ) ); $completed = $task->{$completed_prop}; $batch = \DataMachine\Engine\Tasks\TaskScheduler::$batches[0] ?? array(); datamachine_code_retention_task_assert( 'worktree_cleanup_chunk' === ( $batch['task_type'] ?? '' ), 'retention task schedules cleanup chunk batch' ); - datamachine_code_retention_task_assert( 3 === count( $batch['items'] ?? array() ), 'artifact inventory total fans out into bounded discovery pages' ); - datamachine_code_retention_task_assert( 'artifact_discovery' === ( $batch['items'][0]['chunk_type'] ?? '' ), 'artifact cleanup uses discovery chunks instead of prebuilt artifact rows' ); - datamachine_code_retention_task_assert( array( 0, 10, 20 ) === array_column( $batch['items'], 'offset' ), 'discovery chunks carry stable offsets' ); - datamachine_code_retention_task_assert( empty( \DataMachineCode\Workspace\Workspace::$artifact_opts[0]['exhaustive'] ), 'parent does not run exhaustive artifact dry-run' ); - datamachine_code_retention_task_assert( 3 === (int) ( $completed[0][1]['chunk_row_counts']['artifact_discovery'] ?? 0 ), 'completion report exposes discovery chunk count' ); + datamachine_code_retention_task_assert( 1 === count( $batch['items'] ?? array() ), 'artifact candidates fan out proportionally to eligible rows' ); + datamachine_code_retention_task_assert( 'artifacts' === ( $batch['items'][0]['chunk_type'] ?? '' ), 'artifact cleanup uses frozen candidate chunks instead of discovery pages' ); + datamachine_code_retention_task_assert( 'repo@active' === ( $batch['items'][0]['rows'][0]['handle'] ?? '' ), 'artifact chunk carries reviewed candidate rows' ); + datamachine_code_retention_task_assert( 100 === (int) ( \DataMachineCode\Workspace\Workspace::$artifact_opts[0]['limit'] ?? 0 ), 'parent forwards artifact scan limit' ); + datamachine_code_retention_task_assert( true === ( \DataMachineCode\Workspace\Workspace::$artifact_opts[0]['safety_probes'] ?? false ), 'parent runs safety probes before scheduling artifact apply chunks' ); + datamachine_code_retention_task_assert( 0 === (int) ( $completed[0][1]['chunk_row_counts']['artifact_discovery'] ?? -1 ), 'completion report shows no discovery chunks' ); + datamachine_code_retention_task_assert( 1 === (int) ( $completed[0][1]['chunk_row_counts']['artifacts'] ?? 0 ), 'completion report exposes artifact candidate chunk count' ); echo "\nAll workspace retention task smoke tests passed.\n"; } diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 892bc650..eba03f77 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -622,6 +622,7 @@ public function execute( array $input ): array { datamachine_code_cleanup_assert( str_contains( $cleanup_doc_comment, 'Control task-backed workspace cleanup runs.' ), 'workspace cleanup command documents task-backed controller surface' ); datamachine_code_cleanup_assert( str_contains( $cleanup_doc_comment, '' ), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations' ); datamachine_code_cleanup_assert( str_contains( $cleanup_doc_comment, '[--dry-run]' ), 'task-backed cleanup synopsis keeps synchronous dry-run review' ); + datamachine_code_cleanup_assert( str_contains( $cleanup_doc_comment, 'apply runs freeze eligible candidates' ), 'workspace cleanup limit help clarifies artifact apply scoping' ); datamachine_code_cleanup_assert( str_contains( $doc_comment, 'Daily cleanup path: DB-backed plan, then apply only those rows after revalidation' ), 'worktree examples point daily cleanup to DB-backed run_id controller path' ); datamachine_code_cleanup_assert( str_contains( $doc_comment, 'workspace cleanup plan --mode=retention' ), 'worktree examples include DB-backed cleanup plan' ); datamachine_code_cleanup_assert( str_contains( $doc_comment, 'workspace cleanup run --mode=retention' ), 'worktree examples include task-backed cleanup run' ); @@ -640,11 +641,19 @@ public function execute( array $input ): array { datamachine_code_cleanup_assert( 'retention' === ( $cleanup_run_ability->last_input['mode'] ?? '' ), 'cleanup run ability receives mode' ); datamachine_code_cleanup_assert( 'workspace_cleanup_cli' === ( $cleanup_run_ability->last_input['source'] ?? '' ), 'cleanup run ability identifies explicit CLI source' ); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->cleanup( array( 'run' ), array( 'mode' => 'artifacts', 'limit' => 25, 'offset' => 50, 'format' => 'json' ) ); + datamachine_code_cleanup_assert( 'artifacts' === ( $cleanup_run_ability->last_input['mode'] ?? '' ), 'cleanup run can schedule artifact mode' ); + datamachine_code_cleanup_assert( 25 === (int) ( $cleanup_run_ability->last_input['limit'] ?? 0 ), 'cleanup run forwards artifact apply limit' ); + datamachine_code_cleanup_assert( 50 === (int) ( $cleanup_run_ability->last_input['offset'] ?? 0 ), 'cleanup run forwards artifact apply offset' ); + $last_scheduled_cleanup_run = $cleanup_run_ability->last_input; + WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->cleanup( array( 'run' ), array( 'mode' => 'artifacts', 'dry-run' => true, 'format' => 'json' ) ); datamachine_code_cleanup_assert( true === ( $artifact_ability->last_input['dry_run'] ?? false ), 'cleanup run --dry-run uses artifact cleanup ability directly' ); - datamachine_code_cleanup_assert( 'retention' === ( $cleanup_run_ability->last_input['mode'] ?? '' ), 'cleanup run --dry-run does not schedule cleanup run ability' ); + datamachine_code_cleanup_assert( $last_scheduled_cleanup_run === $cleanup_run_ability->last_input, 'cleanup run --dry-run does not schedule cleanup run ability' ); WP_CLI::$logs = array(); WP_CLI::$successes = array();