Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down Expand Up @@ -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']);
}
Expand Down
50 changes: 29 additions & 21 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<duration>]
* : Pass an age gate such as 7d or 24h into cleanup task params.
Expand All @@ -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=<count>]
* : 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=<count>]
* : 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=<count>]
* : 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=<count>]
* : 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
Expand Down Expand Up @@ -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']) ) {
Expand Down
10 changes: 10 additions & 0 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
111 changes: 93 additions & 18 deletions inc/Workspace/WorkspaceCleanupPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
);

Expand All @@ -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'],
)
);
Expand All @@ -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;
}
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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<string,mixed> $artifact_plan Artifact cleanup child plan.
* @param array<string,mixed> $worktree_plan Worktree cleanup child plan.
* @param array<string,mixed> $inputs Normalized plan inputs.
* @return array<string,mixed>
*/
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.
*
Expand Down Expand Up @@ -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',
Expand All @@ -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',
),
);

Expand Down
2 changes: 1 addition & 1 deletion inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 23 additions & 5 deletions inc/Workspace/WorkspaceWorktreeInventoryCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>|\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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand Down
Loading
Loading