Skip to content

Commit 488017a

Browse files
authored
fix: apply reviewed artifact cleanup plans (#937)
1 parent b00ae24 commit 488017a

8 files changed

Lines changed: 406 additions & 30 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2690,6 +2690,7 @@ private function registerAbilities(): void {
26902690
'full_workspace' => array( 'type' => 'boolean' ),
26912691
'worktree_older_than' => array( 'type' => 'string' ),
26922692
'worktree_sort' => array( 'type' => 'string' ),
2693+
'artifact_sort' => array( 'type' => 'string' ),
26932694
'worktree_stale_only' => array( 'type' => 'boolean' ),
26942695
'plan' => array( 'type' => 'object' ),
26952696
),
@@ -4669,6 +4670,9 @@ public static function workspaceCleanupPlan( array $input ): array|\WP_Error {
46694670
if ( isset($input['worktree_sort']) && '' !== trim( (string) $input['worktree_sort']) ) {
46704671
$opts['worktree_sort'] = trim( (string) $input['worktree_sort']);
46714672
}
4673+
if ( isset($input['artifact_sort']) && '' !== trim( (string) $input['artifact_sort']) ) {
4674+
$opts['artifact_sort'] = trim( (string) $input['artifact_sort']);
4675+
}
46724676
if ( isset($input['plan']) && is_array($input['plan']) ) {
46734677
$opts['plan'] = $input['plan'];
46744678
}

inc/Workspace/CleanupRunService.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error
125125
}
126126

127127
$items = $this->repository->get_items($run_id);
128-
$force_artifact_cleanup = ! empty( (array) ( $run['policy'] ?? array() )['force_artifact_cleanup'] );
128+
$policy = (array) ( $run['policy'] ?? array() );
129+
$force_artifact_cleanup = ! empty($policy['force_artifact_cleanup']);
129130
$artifact_rows = $this->pending_rows_of_type($items, 'artifact_cleanup');
130131
$worktree_rows = $this->pending_rows_of_type($items, 'worktree_removal');
131132
$stale_worktrees_only = 'stale-worktrees' === (string) ( $run['mode'] ?? '' );
@@ -835,6 +836,7 @@ private function terminal_empty_safe_cleanup_state( array $run, array $summary )
835836
*/
836837
private function remaining_work_summary( array $run, string $run_id, array $items, array $progress ): array {
837838
$summary = CleanupRemainingWorkSummary::from_items($items);
839+
$policy = (array) ( $run['policy'] ?? array() );
838840
$safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array();
839841
$safe_commands = is_array($safe_cleanup['commands'] ?? null) ? (array) $safe_cleanup['commands'] : array();
840842
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
854856
$resume_command = array(
855857
'bucket' => 'current_run_resume',
856858
'command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id),
857-
'apply' => $this->cleanup_run_resume_command($run_id, self::DEFAULT_APPLY_LIMIT, ! empty( (array) ( $run['policy'] ?? array() )['force_artifact_cleanup'] )),
859+
'apply' => $this->cleanup_run_resume_command($run_id, self::DEFAULT_APPLY_LIMIT, ! empty($policy['force_artifact_cleanup'])),
858860
'destructive' => false,
859861
'apply_destructive' => true,
860862
'why' => 'Resume the reviewed DB-backed cleanup run from persisted pending/failed/applying rows.',

inc/Workspace/WorkspaceArtifactCleanup.php

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -116,20 +116,25 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
116116
}
117117

118118
if ( $rank_by_size ) {
119-
usort($candidates, fn( $a, $b ) => (int) ( $b['artifact_size_bytes'] ?? 0 ) <=> (int) ( $a['artifact_size_bytes'] ?? 0 ));
119+
usort($candidates, function ( $a, $b ): int {
120+
$size = (int) ( $b['artifact_size_bytes'] ?? 0 ) <=> (int) ( $a['artifact_size_bytes'] ?? 0 );
121+
return 0 !== $size ? $size : strcmp( (string) ( $a['handle'] ?? '' ), (string) ( $b['handle'] ?? '' ));
122+
});
120123
$total_ranked = count($candidates);
124+
$rank_offset = min($offset, $total_ranked);
121125
if ( $limit > 0 ) {
122-
$candidates = array_slice($candidates, 0, $limit);
126+
$candidates = array_slice($candidates, $rank_offset, $limit);
123127
}
128+
$rank_end = $limit > 0 ? min($rank_offset + $limit, $total_ranked) : $total_ranked;
124129
$pagination = array(
125130
'mode' => 'ranked_inventory',
126131
'limit' => $limit,
127-
'offset' => 0,
132+
'offset' => $rank_offset,
128133
'scanned' => (int) ( $pagination['scanned'] ?? 0 ),
129134
'total' => (int) ( $pagination['total'] ?? 0 ),
130-
'complete' => true,
131-
'partial' => false,
132-
'next_offset' => null,
135+
'complete' => $rank_end >= $total_ranked,
136+
'partial' => $rank_end < $total_ranked,
137+
'next_offset' => $rank_end < $total_ranked ? $rank_end : null,
133138
'safety_probes' => $safety_probes,
134139
'sort' => 'size',
135140
'ranked_total' => $total_ranked,
@@ -432,15 +437,19 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts
432437
}
433438
}
434439
if ( $dirty_count > 0 && ! $force ) {
435-
$skipped[] = array_merge(
436-
$base_row, array(
437-
'reason_code' => 'dirty_worktree',
438-
'reason' => sprintf('working tree dirty (%d files) - pass force=true to override artifact cleanup only', $dirty_count),
439-
'dirty' => $dirty_count,
440-
'artifacts' => $artifacts,
441-
)
442-
);
443-
continue;
440+
$artifact_dirty = $this->classify_artifact_only_dirty_worktree($repo, $wt_path);
441+
if ( null === $artifact_dirty ) {
442+
$skipped[] = array_merge(
443+
$base_row, array(
444+
'reason_code' => 'dirty_worktree',
445+
'reason' => sprintf('working tree contains source changes (%d dirty paths) - leaving artifacts in place', $dirty_count),
446+
'dirty' => $dirty_count,
447+
'artifacts' => $artifacts,
448+
)
449+
);
450+
continue;
451+
}
452+
$base_row['artifact_dirty_paths'] = $artifact_dirty['paths'];
444453
}
445454

446455
if ( null === $stale_marker_recovery ) {
@@ -549,7 +558,7 @@ private function build_worktree_artifact_cleanup_summary( array $candidates, arr
549558

550559
foreach ( $removed as $row ) {
551560
foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) {
552-
$removed_bytes += max(0, (int) ( is_array($artifact) ? ( $artifact['size_bytes'] ?? 0 ) : 0 ));
561+
$removed_bytes += max(0, (int) ( is_array($artifact) ? ( $artifact['removal']['bytes_reclaimed'] ?? $artifact['size_bytes'] ?? 0 ) : 0 ));
553562
++$removed_count;
554563
}
555564
}
@@ -696,6 +705,11 @@ private function scope_worktree_artifact_cleanup_to_plan( array $planned_candida
696705
$mismatches[] = 'artifact:' . $relative;
697706
continue;
698707
}
708+
if ( is_array($planned_artifact) && array_key_exists('size_bytes', $planned_artifact)
709+
&& (int) $planned_artifact['size_bytes'] !== (int) ( $current_artifacts[ $relative ]['size_bytes'] ?? -1 ) ) {
710+
$mismatches[] = 'artifact_size:' . $relative;
711+
continue;
712+
}
699713
$artifacts[] = $current_artifacts[ $relative ];
700714
}
701715

@@ -758,6 +772,7 @@ private function remove_worktree_artifact_path( string $worktree_path, string $r
758772
if ( ! $artifact_validation['valid'] || '' === $artifact_real || $artifact_real === $worktree_real ) {
759773
return new \WP_Error('artifact_path_outside_worktree', sprintf('Refusing artifact cleanup for %s: %s', $relative, $artifact_validation['message'] ?? ''), array( 'status' => 403 ));
760774
}
775+
$bytes_reclaimed = max(0, $this->estimate_path_size_bytes($artifact_real));
761776

762777
$output = array();
763778
$exit = 0;
@@ -778,10 +793,11 @@ private function remove_worktree_artifact_path( string $worktree_path, string $r
778793
}
779794

780795
return array(
781-
'resolved_path' => $artifact_real,
782-
'exit_code' => $exit,
783-
'exists_after' => false,
784-
'verified_at' => gmdate('c'),
796+
'resolved_path' => $artifact_real,
797+
'bytes_reclaimed' => $bytes_reclaimed,
798+
'exit_code' => $exit,
799+
'exists_after' => false,
800+
'verified_at' => gmdate('c'),
785801
);
786802
}
787803

inc/Workspace/WorkspaceCleanupPlan.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ trait WorkspaceCleanupPlan {
3434
* @return array<string,mixed>|\WP_Error
3535
*/
3636
public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error {
37+
$mode = (string) ( $opts['mode'] ?? 'cleanup_plan' );
3738
$inputs = array(
39+
'mode' => $mode,
3840
'force_artifact_cleanup' => ! empty($opts['force_artifact_cleanup']),
3941
'include_artifacts' => array_key_exists('include_artifacts', $opts) ? (bool) $opts['include_artifacts'] : true,
4042
'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
4648
'full_workspace' => ! empty($opts['full_workspace']),
4749
'worktree_older_than' => isset($opts['worktree_older_than']) ? trim( (string) $opts['worktree_older_than']) : '',
4850
'worktree_sort' => isset($opts['worktree_sort']) && '' !== trim( (string) $opts['worktree_sort']) ? trim( (string) $opts['worktree_sort']) : '',
49-
'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : '',
51+
'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : ( 'artifacts' === $mode ? 'size' : '' ),
5052
'worktree_stale_only' => ! empty($opts['worktree_stale_only']),
5153
);
5254

@@ -244,7 +246,10 @@ public function workspace_cleanup_plan_chunks( array $opts = array() ): array|\W
244246
*/
245247
private function prepare_cleanup_plan_rows( string $type, array $rows, string $safety_class ): array {
246248
$result = array();
247-
usort($rows, fn( $a, $b ) => $this->cleanup_plan_reclaimable_bytes( (array) $b) <=> $this->cleanup_plan_reclaimable_bytes( (array) $a));
249+
usort($rows, function ( $a, $b ): int {
250+
$size = $this->cleanup_plan_reclaimable_bytes( (array) $b) <=> $this->cleanup_plan_reclaimable_bytes( (array) $a);
251+
return 0 !== $size ? $size : strcmp( (string) ( $a['handle'] ?? '' ), (string) ( $b['handle'] ?? '' ));
252+
});
248253
foreach ( $rows as $row ) {
249254
if ( ! is_array($row) ) {
250255
continue;
@@ -467,6 +472,7 @@ private function build_cleanup_plan_continuation( array $artifact_plan, array $w
467472
}
468473

469474
$complete = null === $next_offset;
475+
$mode = (string) ( $inputs['mode'] ?? 'retention' );
470476
return array(
471477
'bounded' => empty($inputs['full_workspace']),
472478
'complete' => $complete,
@@ -475,8 +481,8 @@ private function build_cleanup_plan_continuation( array $artifact_plan, array $w
475481
'offset' => $offset,
476482
'next_offset' => $next_offset,
477483
'lanes' => $lanes,
478-
'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),
479-
'full_audit_command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --exhaustive --format=json',
484+
'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),
485+
'full_audit_command' => sprintf('studio wp datamachine-code workspace cleanup plan --mode=%s --exhaustive --format=json', $mode),
480486
'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.',
481487
);
482488
}

inc/Workspace/WorkspaceSafeCleanupOrchestrator.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ public function run( array $input ): array|\WP_Error {
6565
if ( is_wp_error($active_no_signal) ) {
6666
return $active_no_signal;
6767
}
68+
$artifact_cleanup = $this->resolve_ability($dry_run ? 'datamachine-code/workspace-cleanup-plan' : 'datamachine-code/workspace-cleanup-until-empty');
69+
if ( is_wp_error($artifact_cleanup) ) {
70+
return $artifact_cleanup;
71+
}
6872

6973
$result = array(
7074
'success' => true,
@@ -78,6 +82,10 @@ public function run( array $input ): array|\WP_Error {
7882
'steps' => array(),
7983
'summary' => array(
8084
'cycles' => 0,
85+
'planned' => 0,
86+
'applied_rows' => 0,
87+
'skipped_rows' => 0,
88+
'would_reclaim_bytes' => 0,
8189
'removed' => 0,
8290
'would_remove' => 0,
8391
'marked_cleanup_eligible' => 0,
@@ -120,6 +128,28 @@ public function run( array $input ): array|\WP_Error {
120128
$result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_start']['removed_count'] ?? 0 );
121129
$this->checkpoint_progress($run_id, $result, 'applying');
122130

131+
$artifact_input = $dry_run
132+
? array(
133+
'mode' => 'artifacts',
134+
'include_artifacts' => true,
135+
'include_worktrees' => false,
136+
'include_resolvers' => false,
137+
'limit' => $limit,
138+
)
139+
: array(
140+
'mode' => 'artifacts',
141+
'force' => false,
142+
'limit' => $limit,
143+
'max_passes' => $passes,
144+
);
145+
$artifacts = $this->execute_ability($artifact_cleanup, $artifact_input);
146+
if ( is_wp_error($artifacts) ) {
147+
return $artifacts;
148+
}
149+
$result['steps']['artifact_cleanup'] = $this->summarize_artifact_step($artifacts, $dry_run);
150+
$this->accumulate_artifact_step($result, $result['steps']['artifact_cleanup']);
151+
$this->checkpoint_progress($run_id, $result, 'applying');
152+
123153
$common = array(
124154
'apply' => ! $dry_run,
125155
'force' => false,
@@ -311,6 +341,54 @@ private function summarize_cleanup_step( array $step ): array {
311341
);
312342
}
313343

344+
/** @return array<string,mixed> */
345+
private function summarize_artifact_step( array $step, bool $dry_run ): array {
346+
$rows = (array) ( $step['rows']['artifact_cleanup'] ?? array() );
347+
$blocked = (array) ( $step['blocked']['artifact_cleanup'] ?? array() );
348+
$passes = (array) ( $step['passes'] ?? array() );
349+
$planned = $dry_run ? count($rows) : array_sum(array_map(static fn( array $pass ): int => (int) ( $pass['planned_rows'] ?? 0 ), $passes));
350+
$blockers = array();
351+
if ( $dry_run ) {
352+
foreach ( $blocked as $row ) {
353+
$reason = (string) ( is_array($row) ? ( $row['reason_code'] ?? 'unknown' ) : 'unknown' );
354+
$blockers[ $reason ] = ( $blockers[ $reason ] ?? 0 ) + 1;
355+
}
356+
} else {
357+
foreach ( (array) ( $step['remaining_blocked_reasons'] ?? array() ) as $reason => $bucket ) {
358+
$blockers[ (string) $reason ] = max(0, (int) ( is_array($bucket) ? ( $bucket['count'] ?? 0 ) : $bucket ));
359+
}
360+
}
361+
362+
return array(
363+
'mode' => 'artifacts',
364+
'applied' => ! $dry_run,
365+
'planned' => $planned,
366+
'applied_rows' => $dry_run ? 0 : (int) ( $step['applied'] ?? 0 ),
367+
'skipped_rows' => $dry_run ? count($blocked) : (int) ( $step['skipped'] ?? 0 ),
368+
'bytes_reclaimed' => $dry_run ? 0 : (int) ( $step['bytes_reclaimed'] ?? 0 ),
369+
'would_reclaim' => $dry_run ? (int) ( $step['summary']['total_reclaimable_bytes'] ?? 0 ) : 0,
370+
'blockers' => array_filter($blockers),
371+
'state' => (string) ( $step['state'] ?? $step['status'] ?? 'planned' ),
372+
);
373+
}
374+
375+
/** @param array<string,mixed> $step */
376+
private function accumulate_artifact_step( array &$result, array $step ): void {
377+
$result['summary']['planned'] += (int) ( $step['planned'] ?? 0 );
378+
$result['summary']['applied_rows'] += (int) ( $step['applied_rows'] ?? 0 );
379+
$result['summary']['skipped_rows'] += (int) ( $step['skipped_rows'] ?? 0 );
380+
$result['summary']['would_reclaim_bytes'] += (int) ( $step['would_reclaim'] ?? 0 );
381+
$result['summary']['removed'] += (int) ( $step['applied_rows'] ?? 0 );
382+
$result['summary']['would_remove'] += (int) ( $step['planned'] ?? 0 );
383+
$result['summary']['bytes_reclaimed'] += (int) ( $step['bytes_reclaimed'] ?? 0 );
384+
foreach ( (array) ( $step['blockers'] ?? array() ) as $reason => $count ) {
385+
$result['blockers'][] = array(
386+
'reason_code' => (string) $reason,
387+
'count' => (int) $count,
388+
);
389+
}
390+
}
391+
314392
private function accumulate_cleanup_step( array &$result, array $step ): int {
315393
$summary = (array) ( $step['summary'] ?? array() );
316394
foreach ( array( 'removed', 'would_remove', 'marked_cleanup_eligible', 'bytes_reclaimed' ) as $field ) {

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2784,7 +2784,7 @@ private function detect_worktree_artifacts( string $repo, string $path ): array
27842784
* @param string $path Worktree path.
27852785
* @return array{paths: array<int,string>, artifacts: array<int,array<string,mixed>>, artifact_size_bytes: int}|null Artifact-only classification.
27862786
*/
2787-
private function classify_artifact_only_dirty_worktree( string $repo, string $path ): ?array {
2787+
protected function classify_artifact_only_dirty_worktree( string $repo, string $path ): ?array {
27882788
if ( '' === $repo || '' === $path || ! is_dir($path) ) {
27892789
return null;
27902790
}

0 commit comments

Comments
 (0)