diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index f299dda..6c2ba1a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2357,6 +2357,10 @@ private function registerAbilities(): void { 'include_worktrees' => array( 'type' => 'boolean' ), 'include_resolvers' => array( 'type' => 'boolean' ), 'force_artifact_cleanup' => array( 'type' => 'boolean' ), + 'limit' => array( 'type' => 'integer' ), + 'offset' => array( 'type' => 'integer' ), + 'until_budget' => array( 'type' => 'string' ), + 'full_workspace' => array( 'type' => 'boolean' ), 'worktree_older_than' => array( 'type' => 'string' ), 'worktree_sort' => array( 'type' => 'string' ), 'worktree_stale_only' => array( 'type' => 'boolean' ), @@ -4182,11 +4186,19 @@ public static function workspaceCleanupPlan( array $input ): array|\WP_Error { 'mode' => (string) ( $input['mode'] ?? 'cleanup_plan' ), 'worktree_stale_only' => ! empty($input['worktree_stale_only']), ); - foreach ( array( 'include_artifacts', 'include_worktrees' ) as $key ) { + foreach ( array( 'include_artifacts', 'include_worktrees', 'full_workspace' ) as $key ) { if ( array_key_exists($key, $input) ) { $opts[ $key ] = (bool) $input[ $key ]; } } + foreach ( array( 'limit', 'offset' ) as $key ) { + if ( isset($input[ $key ]) ) { + $opts[ $key ] = (int) $input[ $key ]; + } + } + if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { + $opts['until_budget'] = trim( (string) $input['until_budget']); + } if ( isset($input['worktree_older_than']) && '' !== trim( (string) $input['worktree_older_than']) ) { $opts['worktree_older_than'] = trim( (string) $input['worktree_older_than']); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 4bd5d50..9957cdf 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -593,12 +593,12 @@ public function adopt_repo( array $args, array $assoc_args ): void { * [--force] * : Pass force=true into the cleanup task params for modes that support it. * - * [--include-artifacts] - * : For `plan --mode=retention`, include artifact cleanup rows. Retention - * planning includes a full-workspace artifact inventory by default; this flag - * remains accepted for explicitness and `--mode=artifacts` still creates an - * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless - * this flag is passed. + * [--include-artifacts] + * : For `plan --mode=retention`, include artifact cleanup rows. Retention + * planning includes a bounded artifact inventory page by default; this flag + * remains accepted for explicitness and `--mode=artifacts` still creates an + * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless + * this flag is passed. * * [--older-than=] * : Pass an age gate such as 7d or 24h into cleanup task params. @@ -607,23 +607,22 @@ public function adopt_repo( array $args, array $assoc_args ): void { * : For `plan`, number of largest reclaimable paths to show in the upfront * summary. Defaults to 10. * - * [--limit=] - * : For DB-backed `apply` / `resume`, maximum pending rows to process in this - * invocation (default 25, max 100). For `--mode=artifacts` pages, maximum - * worktrees to scan; dry-run reviews scan this bounded page synchronously, - * and apply runs freeze eligible candidates from the same bounded page. - * Artifact page scans default to 100. Use 0 to disable the artifact scan cap - * (combine with --exhaustive for a full audit). + * [--limit=] + * : For DB-backed `apply` / `resume`, maximum pending rows to process in this + * invocation (default 25, max 100). For `plan`, maximum worktrees to scan in + * each cleanup lane page. Plan pages default to 100 so huge workspaces return + * actionable JSON quickly. Use --exhaustive for a full audit. * - * [--offset=] - * : 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. + * [--offset=] + * : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run + * pages. Walk huge workspaces by feeding the previous response's + * `continuation.next_offset` until `continuation.complete` is true. * - * [--exhaustive] - * : For `--mode=artifacts --dry-run`, scan every worktree AND run per-worktree - * git status / unpushed-commit safety probes. Slow on huge workspaces; use - * sparingly for full audits. + * [--exhaustive] + * : For `plan`, request a full unbounded audit instead of the default bounded + * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree + * AND run per-worktree git status / unpushed-commit safety probes. Slow on + * huge workspaces; use sparingly for full audits. * * [--safety-probes] * : For `--mode=artifacts --dry-run`, run the per-worktree git safety probes @@ -1066,6 +1065,15 @@ private function cleanup_plan_input( string $mode, array $assoc_args ): array { $input['artifact_sort'] = $sort; $input['worktree_sort'] = $sort; } + 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['full_workspace'] = true; + } if ( 'stale-worktrees' === $mode ) { $input['worktree_stale_only'] = true; if ( empty($input['worktree_older_than']) ) { diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 5d7b474..7cf8a70 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -80,6 +80,16 @@ class Workspace { */ public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100; + /** + * Default cleanup plan page size for high-level retention planning. + */ + public const CLEANUP_PLAN_DEFAULT_LIMIT = 100; + + /** + * Default wall-clock budget for high-level worktree retention review pages. + */ + public const CLEANUP_PLAN_DEFAULT_BUDGET = '30s'; + /** * Default cap on top-level workspace entries sized by hygiene reports. */ diff --git a/inc/Workspace/WorkspaceCleanupPlan.php b/inc/Workspace/WorkspaceCleanupPlan.php index c60e9da..c3ad37e 100644 --- a/inc/Workspace/WorkspaceCleanupPlan.php +++ b/inc/Workspace/WorkspaceCleanupPlan.php @@ -34,9 +34,13 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'include_worktrees' => array_key_exists('include_worktrees', $opts) ? (bool) $opts['include_worktrees'] : true, 'include_resolvers' => ! empty($opts['include_resolvers']), 'top_n' => isset($opts['top_n']) ? max(1, min(50, (int) $opts['top_n'])) : 10, + 'limit' => isset($opts['limit']) ? max(1, (int) $opts['limit']) : self::CLEANUP_PLAN_DEFAULT_LIMIT, + 'offset' => isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0, + 'until_budget' => isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ? trim( (string) $opts['until_budget']) : self::CLEANUP_PLAN_DEFAULT_BUDGET, + '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']) : 'size', - 'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : 'size', + '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']) : '', 'worktree_stale_only' => ! empty($opts['worktree_stale_only']), ); @@ -46,14 +50,13 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'summary' => array(), ); if ( $inputs['include_artifacts'] ) { - // Workspace cleanup plan is the source-of-truth orchestrator that later - // chunks/jobs consume. Use whole-workspace inventory planning so hundreds - // of worktrees are normal; apply still revalidates every row before delete. $artifact_plan = $this->worktree_cleanup_artifacts( array( 'dry_run' => true, 'force' => $inputs['force_artifact_cleanup'], - 'full_workspace' => true, + 'full_workspace' => $inputs['full_workspace'], + 'limit' => $inputs['limit'], + 'offset' => $inputs['offset'], 'sort' => $inputs['artifact_sort'], ) ); @@ -68,15 +71,20 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'summary' => array(), ); if ( $inputs['include_worktrees'] ) { - $worktree_plan = $this->worktree_cleanup_merged( - array( - 'dry_run' => true, - 'skip_github' => true, - 'older_than' => $inputs['worktree_older_than'], - 'sort' => $inputs['worktree_sort'], - 'stale_liveness_only' => $inputs['worktree_stale_only'], - ) + $worktree_args = array( + 'dry_run' => true, + 'skip_github' => true, + 'inventory_only' => ! $inputs['full_workspace'], + 'older_than' => $inputs['worktree_older_than'], + 'sort' => $inputs['worktree_sort'], + 'stale_liveness_only' => $inputs['worktree_stale_only'], ); + if ( ! $inputs['full_workspace'] ) { + $worktree_args['limit'] = $inputs['limit']; + $worktree_args['offset'] = $inputs['offset']; + $worktree_args['until_budget'] = $inputs['until_budget']; + } + $worktree_plan = $this->worktree_cleanup_merged($worktree_args); if ( $worktree_plan instanceof \WP_Error ) { return $worktree_plan; } @@ -98,12 +106,16 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'resolve_signal' => $rows['resolver'], ); + $continuation = $this->build_cleanup_plan_continuation($artifact_plan, $worktree_plan, $inputs); $summary = $this->build_cleanup_plan_summary($rows, $blocked, $artifact_plan, $worktree_plan, $inputs); $summary['rows_by_action'] = array( 'remove_artifacts' => count($action_rows['remove_artifacts']), 'remove_worktree' => count($action_rows['remove_worktree']), 'resolve_signal' => count($action_rows['resolve_signal']), ); + if ( array() !== $continuation ) { + $summary['continuation'] = $continuation; + } $plan = array( 'success' => true, @@ -127,6 +139,9 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error 'action_rows' => $action_rows, 'summary' => $summary, ); + if ( array() !== $continuation ) { + $plan['continuation'] = $continuation; + } $plan['plan_id'] = $this->stable_cleanup_hash( array( @@ -369,6 +384,66 @@ private function build_cleanup_plan_summary( array $rows, array $blocked = array ); } + /** + * Build operator continuation evidence from bounded child cleanup plans. + * + * @param array $artifact_plan Artifact cleanup child plan. + * @param array $worktree_plan Worktree cleanup child plan. + * @param array $inputs Normalized plan inputs. + * @return array + */ + private function build_cleanup_plan_continuation( array $artifact_plan, array $worktree_plan, array $inputs ): array { + $limit = max(1, (int) ( $inputs['limit'] ?? self::CLEANUP_PLAN_DEFAULT_LIMIT )); + $offset = max(0, (int) ( $inputs['offset'] ?? 0 )); + $next_offset = null; + $lanes = array(); + + $plans = array( + 'artifact_cleanup' => $artifact_plan, + 'worktree_removal' => $worktree_plan, + ); + + foreach ( $plans as $lane => $plan ) { + $pagination = is_array($plan['pagination'] ?? null) ? $plan['pagination'] : ( is_array($plan['summary']['pagination'] ?? null) ? $plan['summary']['pagination'] : null ); + if ( null === $pagination ) { + continue; + } + + $lane_next = $pagination['next_offset'] ?? null; + $lanes[ $lane ] = array( + 'complete' => ! empty($pagination['complete']), + 'partial' => ! empty($pagination['partial']), + 'offset' => (int) ( $pagination['offset'] ?? $offset ), + 'limit' => isset($pagination['limit']) ? (int) $pagination['limit'] : $limit, + 'scanned' => (int) ( $pagination['scanned'] ?? 0 ), + 'total' => (int) ( $pagination['total'] ?? 0 ), + 'next_offset' => null === $lane_next ? null : (int) $lane_next, + 'budget_stopped' => ! empty($pagination['budget_stopped']), + ); + if ( null !== $lane_next ) { + $next_offset = null === $next_offset ? (int) $lane_next : min($next_offset, (int) $lane_next); + } + } + + if ( array() === $lanes ) { + return array(); + } + + $complete = null === $next_offset; + return array( + 'bounded' => empty($inputs['full_workspace']), + 'complete' => $complete, + 'partial' => ! $complete, + 'limit' => $limit, + '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', + '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.', + ); + } + /** * Return the bytes a cleanup row is expected to reclaim. * @@ -584,8 +659,8 @@ private function cleanup_plan_recommended_commands( array $inputs ): array { array( 'label' => 'inspect_full_plan_json', 'risk' => 'none', - 'command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --format=json', - 'when' => 'export the full plan for review or archival', + 'command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --exhaustive --format=json', + 'when' => 'operator explicitly wants a full unbounded audit for review or archival', ), array( 'label' => 'resolve_metadata_blockers', @@ -596,8 +671,8 @@ private function cleanup_plan_recommended_commands( array $inputs ): array { array( 'label' => 'refresh_merge_signals', 'risk' => 'none', - 'command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --format=json', - 'when' => 'active or lifecycle rows need full merge/PR signal review', + 'command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --limit=100 --offset=0 --until-budget=30s --format=json', + 'when' => 'active or lifecycle rows need deeper merge/PR signal review after the cheap inventory pass', ), ); diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 89b6985..4aa0b24 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -97,7 +97,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro return new \WP_Error('inventory_cleanup_apply_plan_unsupported', 'Inventory-only cleanup cannot apply a plan because it intentionally skips full safety revalidation.', array( 'status' => 400 )); } - return $this->worktree_cleanup_inventory_only($older_than, $sort, $include_repaired_metadata); + return $this->worktree_cleanup_inventory_only($older_than, $sort, $include_repaired_metadata, $limit, $offset); } $planned_candidates = null; diff --git a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php index dbc4fa7..1e6ca3e 100644 --- a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php +++ b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php @@ -21,11 +21,14 @@ trait WorkspaceWorktreeInventoryCleanup { * Only explicit lifecycle cleanup signals become candidates; every ambiguous * worktree is skipped with stable reason codes for review. * - * @param string $older_than Optional age filter duration. - * @param string $sort Optional candidate sort. + * @param string $older_than Optional age filter duration. + * @param string $sort Optional candidate sort. + * @param bool $include_repaired_metadata Whether repaired metadata rows can be candidates. + * @param int|null $limit Optional worktree page size. + * @param int $offset Optional worktree page offset. * @return array|\WP_Error */ - private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false ): array|\WP_Error { + private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false, ?int $limit = null, int $offset = 0 ): array|\WP_Error { $age_filter = null; if ( '' !== $older_than ) { $duration_seconds = $this->parse_worktree_cleanup_duration($older_than); @@ -48,7 +51,14 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so $candidates = array(); $skipped = array(); - foreach ( $this->build_workspace_inventory_rows() as $wt ) { + $inventory_rows = array_values(array_filter($this->build_workspace_inventory_rows(), fn( $wt ) => ! empty($wt['is_worktree']) )); + $total = count($inventory_rows); + $offset = max(0, $offset); + $page_rows = null === $limit ? array_slice($inventory_rows, $offset) : array_slice($inventory_rows, $offset, max(1, $limit)); + $processed = 0; + + foreach ( $page_rows as $wt ) { + ++$processed; if ( empty($wt['is_worktree']) ) { continue; } @@ -234,13 +244,17 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so } $candidates = $this->sort_worktree_cleanup_rows($candidates, $sort); + $pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total, false, null); $summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter); + if ( null !== $pagination ) { + $summary['pagination'] = $pagination; + } if ( ! empty($candidates) ) { $summary['bounded_cleanup_eligible_apply'] = $this->build_bounded_cleanup_eligible_apply_hint(count($candidates), $older_than, $sort, $include_repaired_metadata); $summary['apply_command'] = $summary['bounded_cleanup_eligible_apply']['apply_command']; } - return array( + $response = array( 'success' => true, 'dry_run' => true, 'inventory_only' => true, @@ -249,6 +263,10 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so 'skipped' => $skipped, 'summary' => $summary, ); + if ( null !== $pagination ) { + $response['pagination'] = $pagination; + } + return $response; } /** diff --git a/tests/smoke-workspace-cleanup-plan-high-volume.php b/tests/smoke-workspace-cleanup-plan-high-volume.php index 1abe0ef..e6630ec 100644 --- a/tests/smoke-workspace-cleanup-plan-high-volume.php +++ b/tests/smoke-workspace-cleanup-plan-high-volume.php @@ -35,6 +35,11 @@ function datamachine_code_cleanup_plan_assert( bool $condition, string $message class HighVolumeCleanupPlanWorkspace { use WorkspaceCleanupPlan; + 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'; + public string $workspace_path = '/workspace'; public array $artifact_input = array(); public array $worktree_input = array(); @@ -54,7 +59,23 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array { 'artifacts' => array( array( 'path' => 'target', 'size_bytes' => $i * 1024 ) ), ); } - usort($candidates, fn( $a, $b ) => (int) $b['artifact_size_bytes'] <=> (int) $a['artifact_size_bytes']); + if ( 'size' === (string) ( $opts['sort'] ?? '' ) ) { + usort($candidates, fn( $a, $b ) => (int) $b['artifact_size_bytes'] <=> (int) $a['artifact_size_bytes']); + } + $limit = isset($opts['limit']) ? max(1, (int) $opts['limit']) : 100; + $offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0; + $total = count($candidates); + $candidates = ! empty($opts['full_workspace']) ? $candidates : array_slice($candidates, $offset, $limit); + $next = ( $offset + count($candidates) ) < $total ? $offset + count($candidates) : null; + $pagination = array( + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => count($candidates), + 'partial' => null !== $next, + 'complete' => null === $next, + 'next_offset' => $next, + ); return array( 'success' => true, @@ -71,35 +92,55 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array { 'artifact_size_bytes' => 4096, ), ), - 'summary' => array(), + 'pagination' => $pagination, + 'summary' => array( 'pagination' => $pagination ), ); } public function worktree_cleanup_merged( array $opts = array() ): array { $this->worktree_input = $opts; + $candidates = array( + array( + 'handle' => 'repo@old-small', + 'repo' => 'repo', + 'branch' => 'old-small', + 'path' => '/workspace/repo@old-small', + 'reason_code' => 'cleanup_eligible', + 'reason' => 'worktree finalized or explicitly marked cleanup_eligible', + 'size_bytes' => 2048, + ), + array( + 'handle' => 'repo@old-large', + 'repo' => 'repo', + 'branch' => 'old-large', + 'path' => '/workspace/repo@old-large', + 'reason_code' => 'cleanup_eligible', + 'reason' => 'worktree finalized or explicitly marked cleanup_eligible', + 'size_bytes' => 4096, + ), + ); + if ( 'size' === (string) ( $opts['sort'] ?? '' ) ) { + usort($candidates, fn( $a, $b ) => (int) $b['size_bytes'] <=> (int) $a['size_bytes']); + } + $limit = isset($opts['limit']) ? max(1, (int) $opts['limit']) : 100; + $offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0; + $total = count($candidates); + $candidates = array_slice($candidates, $offset, $limit); + $next = ( $offset + count($candidates) ) < $total ? $offset + count($candidates) : null; + $pagination = array( + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => count($candidates), + 'partial' => null !== $next, + 'complete' => null === $next, + 'next_offset' => $next, + ); + return array( 'success' => true, 'dry_run' => true, - 'candidates' => array( - array( - 'handle' => 'repo@old-small', - 'repo' => 'repo', - 'branch' => 'old-small', - 'path' => '/workspace/repo@old-small', - 'reason_code' => 'upstream-gone', - 'reason' => 'remote branch deleted (likely merged + auto-deleted)', - 'size_bytes' => 2048, - ), - array( - 'handle' => 'repo@old-large', - 'repo' => 'repo', - 'branch' => 'old-large', - 'path' => '/workspace/repo@old-large', - 'reason_code' => 'upstream-gone', - 'reason' => 'remote branch deleted (likely merged + auto-deleted)', - 'size_bytes' => 4096, - ), - ), + 'candidates' => $candidates, 'skipped' => array( array( 'handle' => 'repo@dirty', @@ -111,7 +152,8 @@ public function worktree_cleanup_merged( array $opts = array() ): array { 'size_bytes' => 8192, ), ), - 'summary' => array(), + 'pagination' => $pagination, + 'summary' => array( 'pagination' => $pagination ), ); } } @@ -130,20 +172,27 @@ public function worktree_cleanup_merged( array $opts = array() ): array { ); datamachine_code_cleanup_plan_assert(true === ( $plan['success'] ?? false ), 'cleanup plan succeeds'); - datamachine_code_cleanup_plan_assert(true === ( $workspace->artifact_input['full_workspace'] ?? false ), 'artifact planning uses full-workspace inventory mode'); - datamachine_code_cleanup_plan_assert('size' === ( $workspace->artifact_input['sort'] ?? '' ), 'artifact planning asks for biggest artifacts first'); - datamachine_code_cleanup_plan_assert('size' === ( $workspace->worktree_input['sort'] ?? '' ), 'worktree cleanup planning asks for biggest worktrees first'); - datamachine_code_cleanup_plan_assert(150 === count($plan['rows']['artifact_cleanup'] ?? array()), 'all artifact rows are planned without manual paging'); - datamachine_code_cleanup_plan_assert('repo@artifact-150' === ( $plan['rows']['artifact_cleanup'][0]['handle'] ?? '' ), 'artifact rows are largest first'); + datamachine_code_cleanup_plan_assert(false === ( $workspace->artifact_input['full_workspace'] ?? false ), 'artifact planning defaults to bounded inventory mode'); + datamachine_code_cleanup_plan_assert(100 === (int) ( $workspace->artifact_input['limit'] ?? 0 ), 'artifact planning uses the default page limit'); + datamachine_code_cleanup_plan_assert(0 === (int) ( $workspace->artifact_input['offset'] ?? -1 ), 'artifact planning starts at offset 0'); + datamachine_code_cleanup_plan_assert(true === ( $workspace->worktree_input['inventory_only'] ?? false ), 'worktree planning defaults to inventory-only cleanup signals'); + datamachine_code_cleanup_plan_assert(100 === (int) ( $workspace->worktree_input['limit'] ?? 0 ), 'worktree planning uses the default page limit'); + datamachine_code_cleanup_plan_assert('30s' === ( $workspace->worktree_input['until_budget'] ?? '' ), 'worktree planning includes a default wall-clock budget'); + datamachine_code_cleanup_plan_assert(100 === count($plan['rows']['artifact_cleanup'] ?? array()), 'artifact rows are bounded to one default page'); + datamachine_code_cleanup_plan_assert('repo@artifact-100' === ( $plan['rows']['artifact_cleanup'][0]['handle'] ?? '' ), 'artifact rows are ranked within the bounded page for review'); datamachine_code_cleanup_plan_assert('repo@old-large' === ( $plan['rows']['worktree_removal'][0]['handle'] ?? '' ), 'worktree rows are largest first'); datamachine_code_cleanup_plan_assert(2 === (int) ( $plan['summary']['rows_by_type']['worktree_removal'] ?? 0 ), 'worktree cleanup rows are counted separately'); - datamachine_code_cleanup_plan_assert(150 === (int) ( $plan['summary']['rows_by_type']['artifact_cleanup'] ?? 0 ), 'artifact cleanup rows are counted separately'); + datamachine_code_cleanup_plan_assert(100 === (int) ( $plan['summary']['rows_by_type']['artifact_cleanup'] ?? 0 ), 'artifact cleanup rows are counted separately'); datamachine_code_cleanup_plan_assert(6144 === (int) ( $plan['summary']['byte_totals']['worktree_removal'] ?? 0 ), 'worktree byte total includes all removal rows'); - datamachine_code_cleanup_plan_assert(11596800 === (int) ( $plan['summary']['byte_totals']['artifact_cleanup'] ?? 0 ), 'artifact byte total includes all 150 rows'); + datamachine_code_cleanup_plan_assert(5171200 === (int) ( $plan['summary']['byte_totals']['artifact_cleanup'] ?? 0 ), 'artifact byte total includes the bounded page rows'); datamachine_code_cleanup_plan_assert(1 === (int) ( $plan['summary']['blocked_by_reason']['artifact_cleanup']['active_symlink_target'] ?? 0 ), 'artifact blockers include clear reason code'); datamachine_code_cleanup_plan_assert(1 === (int) ( $plan['summary']['blocked_by_reason']['worktree_removal']['dirty_worktree'] ?? 0 ), 'worktree blockers include clear reason code'); datamachine_code_cleanup_plan_assert('artifact_cleanup' === ( $plan['summary']['top_reclaimable'][0]['row_type'] ?? '' ), 'top reclaimable summary starts with the biggest lane row'); - datamachine_code_cleanup_plan_assert(153600 === (int) ( $plan['summary']['top_reclaimable'][0]['reclaimable_bytes'] ?? 0 ), 'top reclaimable summary reports the largest bytes first'); + datamachine_code_cleanup_plan_assert(102400 === (int) ( $plan['summary']['top_reclaimable'][0]['size_bytes'] ?? 0 ), 'top reclaimable summary reports the largest bytes in the bounded page'); + datamachine_code_cleanup_plan_assert(false === ( $plan['continuation']['complete'] ?? true ), 'cleanup plan reports incomplete bounded scan'); + datamachine_code_cleanup_plan_assert(100 === (int) ( $plan['continuation']['next_offset'] ?? 0 ), 'cleanup plan reports next offset for continuation'); + datamachine_code_cleanup_plan_assert(str_contains((string) ( $plan['continuation']['next_command'] ?? '' ), '--offset=100'), 'cleanup plan emits next page command'); + datamachine_code_cleanup_plan_assert(str_contains((string) ( $plan['continuation']['full_audit_command'] ?? '' ), '--exhaustive'), 'cleanup plan exposes explicit full audit command'); echo "=== done ===\n"; } diff --git a/tests/smoke-worktree-cleanup.php b/tests/smoke-worktree-cleanup.php index f1564bc..4bb1400 100644 --- a/tests/smoke-worktree-cleanup.php +++ b/tests/smoke-worktree-cleanup.php @@ -602,7 +602,10 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert(true, isset($cleanup_plan['action_rows']['remove_artifacts']), 'cleanup plan exposes remove_artifacts action rows separately'); $assert(true, isset($cleanup_plan['action_rows']['remove_worktree']), 'cleanup plan exposes remove_worktree action rows separately'); $assert(count($cleanup_plan['rows']['worktree_removal'] ?? array()), count($cleanup_plan['action_rows']['remove_worktree'] ?? array()), 'remove_worktree action rows mirror worktree removal rows'); - $assert_contains($cleanup_plan['rows']['worktree_removal'] ?? array(), 'demo@unmerged-feature', 'cleanup plan promotes locally clean remote-backed worktree instead of active/no-signal resolver'); + $assert_contains($cleanup_plan['rows']['worktree_removal'] ?? array(), 'demo@inventory-cleanup-eligible', 'cleanup plan defaults to cheap inventory cleanup-eligible rows'); + $full_cleanup_plan = $ws->workspace_cleanup_plan(array( 'include_resolvers' => true, 'full_workspace' => true )); + $assert(true, ! is_wp_error($full_cleanup_plan) && ( $full_cleanup_plan['success'] ?? false ), 'full cleanup plan audit succeeds when explicitly requested'); + $assert_contains($full_cleanup_plan['rows']['worktree_removal'] ?? array(), 'demo@unmerged-feature', 'full cleanup plan promotes locally clean remote-backed worktree after deeper probes'); $assert(true, isset($cleanup_plan['rows']['resolver']), 'cleanup plan includes optional resolver row bucket'); $chunk_report = $ws->workspace_cleanup_plan_chunks( array(