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
4 changes: 4 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
),
Expand Down Expand Up @@ -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'];
}
Expand Down
6 changes: 4 additions & 2 deletions inc/Workspace/CleanupRunService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? '' );
Expand Down Expand Up @@ -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']) ) {
Expand All @@ -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.',
Expand Down
56 changes: 36 additions & 20 deletions inc/Workspace/WorkspaceArtifactCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 ];
}

Expand Down Expand Up @@ -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;
Expand All @@ -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'),
);
}

Expand Down
14 changes: 10 additions & 4 deletions inc/Workspace/WorkspaceCleanupPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ trait WorkspaceCleanupPlan {
* @return array<string,mixed>|\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,
Expand All @@ -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']),
);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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.',
);
}
Expand Down
78 changes: 78 additions & 0 deletions inc/Workspace/WorkspaceSafeCleanupOrchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -311,6 +341,54 @@ private function summarize_cleanup_step( array $step ): array {
);
}

/** @return array<string,mixed> */
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<string,mixed> $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 ) {
Expand Down
2 changes: 1 addition & 1 deletion inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -2784,7 +2784,7 @@ private function detect_worktree_artifacts( string $repo, string $path ): array
* @param string $path Worktree path.
* @return array{paths: array<int,string>, artifacts: array<int,array<string,mixed>>, 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;
}
Expand Down
Loading