@@ -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 ) {
0 commit comments