Skip to content

Commit f18dd11

Browse files
authored
Merge pull request #714 from Extra-Chill/fix/issue-713-artifact-cleanup-until-empty
Add artifact cleanup until-empty runner
2 parents 802e627 + 16d7a88 commit f18dd11

7 files changed

Lines changed: 329 additions & 6 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2387,6 +2387,29 @@ private function registerAbilities(): void {
23872387
)
23882388
);
23892389

2390+
AbilityRegistry::register(
2391+
'datamachine-code/workspace-cleanup-until-empty',
2392+
array(
2393+
'label' => 'Run Artifact Cleanup Until Empty',
2394+
'description' => 'Repeatedly plan and apply DB-backed artifact cleanup until no safe rows remain, a budget is hit, or candidates repeat.',
2395+
'category' => 'datamachine-code-workspace',
2396+
'input_schema' => array(
2397+
'type' => 'object',
2398+
'properties' => array(
2399+
'mode' => array( 'type' => 'string' ),
2400+
'force' => array( 'type' => 'boolean' ),
2401+
'limit' => array( 'type' => 'integer' ),
2402+
'max_passes' => array( 'type' => 'integer' ),
2403+
'budget_seconds' => array( 'type' => 'integer' ),
2404+
),
2405+
),
2406+
'output_schema' => array( 'type' => 'object' ),
2407+
'execute_callback' => array( self::class, 'workspaceCleanupUntilEmpty' ),
2408+
'permission_callback' => fn() => PermissionHelper::can_manage(),
2409+
'meta' => array( 'show_in_rest' => false ),
2410+
)
2411+
);
2412+
23902413
foreach ( array( 'status', 'evidence', 'resume', 'cancel' ) as $cleanup_operation ) {
23912414
AbilityRegistry::register(
23922415
'datamachine-code/workspace-cleanup-' . $cleanup_operation,
@@ -4141,6 +4164,22 @@ public static function workspaceCleanupApply( array $input ): array|\WP_Error {
41414164
return ( new CleanupRunService() )->apply( (string) ( $input['run_id'] ?? '' ), self::cleanupRunApplyOptions($input));
41424165
}
41434166

4167+
/**
4168+
* Run artifact cleanup until no rows remain or progress stalls.
4169+
*
4170+
* @param array $input Input parameters.
4171+
* @return array<string,mixed>|\WP_Error
4172+
*/
4173+
public static function workspaceCleanupUntilEmpty( array $input ): array|\WP_Error {
4174+
$options = self::cleanupRunApplyOptions($input);
4175+
foreach ( array( 'mode', 'max_passes', 'budget_seconds' ) as $key ) {
4176+
if ( isset($input[ $key ]) ) {
4177+
$options[ $key ] = 'mode' === $key ? (string) $input[ $key ] : (int) $input[ $key ];
4178+
}
4179+
}
4180+
return ( new CleanupRunService() )->until_empty($options);
4181+
}
4182+
41444183
/**
41454184
* Return DB-backed cleanup run status.
41464185
*

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ public function adopt_repo( array $args, array $assoc_args ): void {
567567
* ## OPTIONS
568568
*
569569
* <operation>
570-
* : Cleanup operation. One of: <plan|apply|run|status|resume|cancel|evidence>.
570+
* : Cleanup operation. One of: <plan|apply|until-empty|run|status|resume|cancel|evidence>.
571571
* Existing task-backed controls remain: <run|status|resume|cancel|evidence>.
572572
*
573573
* [<run-id>]
@@ -637,6 +637,14 @@ public function adopt_repo( array $args, array $assoc_args ): void {
637637
* : For `cleanup run`, drain the queued parent job, drain active child cleanup
638638
* jobs discovered from cleanup status, then print verified bytes reclaimed.
639639
*
640+
* [--max-passes=<count>]
641+
* : For `cleanup until-empty --mode=artifacts`, maximum plan/apply passes before
642+
* stopping. Defaults to 10, max 25.
643+
*
644+
* [--budget-seconds=<seconds>]
645+
* : For `cleanup until-empty --mode=artifacts`, stop before starting another
646+
* pass after this many seconds.
647+
*
640648
* [--format=<format>]
641649
* : Output format.
642650
* ---
@@ -669,6 +677,9 @@ public function adopt_repo( array $args, array $assoc_args ): void {
669677
* wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json
670678
* wp datamachine-code workspace cleanup apply cleanup-run-20260504120000-abc123
671679
*
680+
* # Repeatedly apply reviewed safe artifact rows until none remain
681+
* wp datamachine-code workspace cleanup until-empty --mode=artifacts --format=json
682+
*
672683
* # Full audit (slow on huge workspaces)
673684
* wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --exhaustive --format=json
674685
*
@@ -702,6 +713,10 @@ public function cleanup( array $args, array $assoc_args ): void {
702713
$this->run_cleanup_control_ability('apply', (string) ( $args[1] ?? '' ), $assoc_args);
703714
return;
704715

716+
case 'until-empty':
717+
$this->run_cleanup_until_empty($assoc_args);
718+
return;
719+
705720
case 'run':
706721
$this->run_cleanup_task($assoc_args);
707722
return;
@@ -777,6 +792,45 @@ private function run_cleanup_task( array $assoc_args ): void {
777792
$this->render_cleanup_control_result($result, $assoc_args);
778793
}
779794

795+
private function run_cleanup_until_empty( array $assoc_args ): void {
796+
$mode = strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) ( $assoc_args['mode'] ?? 'artifacts' )));
797+
if ( 'artifacts' !== $mode ) {
798+
WP_CLI::error('cleanup until-empty currently supports --mode=artifacts only.');
799+
return;
800+
}
801+
802+
$ability = wp_get_ability('datamachine-code/workspace-cleanup-until-empty');
803+
if ( ! $ability ) {
804+
WP_CLI::error('Workspace cleanup until-empty ability not registered.');
805+
return;
806+
}
807+
808+
$input = array( 'mode' => $mode );
809+
foreach ( array( 'force', 'limit' ) as $key ) {
810+
if ( isset($assoc_args[ $key ]) ) {
811+
$input[ $key ] = 'force' === $key ? (bool) $assoc_args[ $key ] : (int) $assoc_args[ $key ];
812+
}
813+
}
814+
foreach (
815+
array(
816+
'max-passes' => 'max_passes',
817+
'budget-seconds' => 'budget_seconds',
818+
) as $cli_key => $input_key
819+
) {
820+
if ( isset($assoc_args[ $cli_key ]) ) {
821+
$input[ $input_key ] = (int) $assoc_args[ $cli_key ];
822+
}
823+
}
824+
825+
$result = $ability->execute($input);
826+
if ( is_wp_error($result) ) {
827+
WP_CLI::error($result->get_error_message());
828+
return;
829+
}
830+
831+
$this->render_cleanup_control_result($result, $assoc_args);
832+
}
833+
780834
/**
781835
* Flush stdout so JSON drain callers see the scheduled run before draining blocks.
782836
*

inc/Workspace/CleanupRunService.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,146 @@ public function resume( string $run_id, array $opts = array() ): array|\WP_Error
311311
return $this->apply($run_id, $opts);
312312
}
313313

314+
/**
315+
* Repeatedly plan/apply artifact cleanup until no safe rows remain or progress stalls.
316+
*
317+
* @param array<string,mixed> $opts Loop options.
318+
* @return array<string,mixed>|\WP_Error
319+
*/
320+
public function until_empty( array $opts = array() ): array|\WP_Error {
321+
$mode = (string) ( $opts['mode'] ?? 'artifacts' );
322+
if ( 'artifacts' !== $mode ) {
323+
return new \WP_Error('cleanup_until_empty_unsupported_mode', 'Cleanup until-empty currently supports mode=artifacts only.', array( 'status' => 400 ));
324+
}
325+
326+
$max_passes = max(1, min(25, (int) ( $opts['max_passes'] ?? 10 )));
327+
$limit = $this->apply_limit($opts);
328+
$started = microtime(true);
329+
$budget_seconds = isset($opts['budget_seconds']) ? max(1, (int) $opts['budget_seconds']) : 0;
330+
$seen = array();
331+
$passes = array();
332+
$total_bytes = 0;
333+
$total_applied = 0;
334+
$total_skipped = 0;
335+
336+
for ( $pass = 1; $pass <= $max_passes; ++$pass ) {
337+
if ( $budget_seconds > 0 && microtime(true) - $started >= $budget_seconds ) {
338+
return $this->until_empty_result('budget_exhausted', $passes, $total_bytes, $total_applied, $total_skipped, array( 'budget_seconds' => $budget_seconds ));
339+
}
340+
341+
$plan = $this->plan(
342+
array(
343+
'mode' => 'artifacts',
344+
'include_artifacts' => true,
345+
'include_worktrees' => false,
346+
'include_resolvers' => false,
347+
'force_artifact_cleanup' => ! empty($opts['force']),
348+
)
349+
);
350+
if ( $plan instanceof \WP_Error ) {
351+
return $plan;
352+
}
353+
354+
$rows = (array) ( $plan['rows']['artifact_cleanup'] ?? array() );
355+
$fingerprint = $this->cleanup_rows_fingerprint($rows);
356+
if ( array() === $rows ) {
357+
return $this->until_empty_result('completed', $passes, $total_bytes, $total_applied, $total_skipped, array( 'final_run_id' => $plan['run_id'] ?? null ));
358+
}
359+
if ( isset($seen[ $fingerprint ]) ) {
360+
return $this->until_empty_result(
361+
'repeated_candidates',
362+
$passes,
363+
$total_bytes,
364+
$total_applied,
365+
$total_skipped,
366+
array(
367+
'run_id' => $plan['run_id'] ?? null,
368+
'fingerprint' => $fingerprint,
369+
'reason' => 'The same artifact candidate set appeared after a previous apply; stopping to avoid a cleanup loop.',
370+
)
371+
);
372+
}
373+
$seen[ $fingerprint ] = true;
374+
375+
$run_id = (string) ( $plan['run_id'] ?? '' );
376+
$run_applied = 0;
377+
$run_skipped = 0;
378+
$run_reclaimed = 0;
379+
$status = null;
380+
do {
381+
$apply = $this->apply(
382+
$run_id,
383+
array(
384+
'force' => ! empty($opts['force']),
385+
'limit' => $limit,
386+
)
387+
);
388+
if ( $apply instanceof \WP_Error ) {
389+
return $apply;
390+
}
391+
$run_applied += (int) ( $apply['applied'] ?? 0 );
392+
$run_skipped += (int) ( $apply['skipped'] ?? 0 );
393+
$run_reclaimed = (int) ( $apply['summary']['bytes_reclaimed'] ?? $run_reclaimed );
394+
$status = (string) ( $apply['status'] ?? '' );
395+
} while ( 'needs_resume' === $status );
396+
397+
$total_applied += $run_applied;
398+
$total_skipped += $run_skipped;
399+
$total_bytes += $run_reclaimed;
400+
$passes[] = array(
401+
'pass' => $pass,
402+
'run_id' => $run_id,
403+
'planned_rows' => count($rows),
404+
'applied' => $run_applied,
405+
'skipped' => $run_skipped,
406+
'bytes_reclaimed' => $run_reclaimed,
407+
'fingerprint' => $fingerprint,
408+
);
409+
410+
if ( 0 === $run_applied ) {
411+
return $this->until_empty_result('no_progress', $passes, $total_bytes, $total_applied, $total_skipped, array( 'run_id' => $run_id ));
412+
}
413+
}
414+
415+
return $this->until_empty_result('max_passes_reached', $passes, $total_bytes, $total_applied, $total_skipped, array( 'max_passes' => $max_passes ));
416+
}
417+
418+
/**
419+
* @param array<int,array<string,mixed>> $rows Cleanup rows.
420+
*/
421+
private function cleanup_rows_fingerprint( array $rows ): string {
422+
$parts = array();
423+
foreach ( $rows as $row ) {
424+
$artifacts = array();
425+
foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) {
426+
if ( is_array($artifact) ) {
427+
$artifacts[] = (string) ( $artifact['path'] ?? '' );
428+
}
429+
}
430+
sort($artifacts);
431+
$parts[] = implode('|', array( (string) ( $row['handle'] ?? '' ), (string) ( $row['path'] ?? '' ), implode(',', $artifacts) ));
432+
}
433+
sort($parts);
434+
return sha1(implode("\n", $parts));
435+
}
436+
437+
/**
438+
* @param array<int,array<string,mixed>> $passes Loop pass summaries.
439+
* @param array<string,mixed> $extra Extra result fields.
440+
*/
441+
private function until_empty_result( string $state, array $passes, int $bytes, int $applied, int $skipped, array $extra = array() ): array {
442+
return array(
443+
'success' => in_array($state, array( 'completed', 'budget_exhausted', 'max_passes_reached' ), true),
444+
'state' => $state,
445+
'status' => $state,
446+
'passes' => $passes,
447+
'pass_count' => count($passes),
448+
'applied' => $applied,
449+
'skipped' => $skipped,
450+
'bytes_reclaimed' => $bytes,
451+
) + $extra;
452+
}
453+
314454
private function plan_items( array $plan ): array {
315455
$items = array();
316456
foreach ( (array) ( $plan['rows'] ?? array() ) as $type => $rows ) {

inc/Workspace/WorkspaceArtifactCleanup.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
176176
break;
177177
}
178178

179-
$removed_artifacts[] = $artifact;
179+
$removed_artifacts[] = is_array($remove) ? array_merge($artifact, array( 'removal' => $remove )) : $artifact;
180180
}
181181

182182
if ( $failed ) {
@@ -678,9 +678,9 @@ private function scope_worktree_artifact_cleanup_to_plan( array $planned_candida
678678
*
679679
* @param string $worktree_path Worktree root path.
680680
* @param string $relative Profile-relative artifact path.
681-
* @return true|\WP_Error
681+
* @return array<string,mixed>|\WP_Error
682682
*/
683-
private function remove_worktree_artifact_path( string $worktree_path, string $relative ): true|\WP_Error {
683+
private function remove_worktree_artifact_path( string $worktree_path, string $relative ): array|\WP_Error {
684684
$relative = trim($relative, '/');
685685
if ( '' === $relative || str_contains($relative, '..') ) {
686686
return new \WP_Error('invalid_artifact_path', sprintf('Invalid artifact path: %s', $relative), array( 'status' => 400 ));
@@ -728,7 +728,12 @@ private function remove_worktree_artifact_path( string $worktree_path, string $r
728728
return new \WP_Error('artifact_remove_failed', sprintf('Artifact path still exists after removal: %s', $relative), array( 'status' => 500 ));
729729
}
730730

731-
return true;
731+
return array(
732+
'resolved_path' => $artifact_real,
733+
'exit_code' => $exit,
734+
'exists_after' => false,
735+
'verified_at' => gmdate('c'),
736+
);
732737
}
733738

734739
/**

0 commit comments

Comments
 (0)