@@ -22,13 +22,18 @@ class WorkspaceSafeCleanupOrchestrator {
2222 /** @var callable */
2323 private $ lock_pruner ;
2424
25+ /** @var \DataMachineCode\Storage\CleanupRunRepositoryInterface|null */
26+ private $ run_repository ;
27+
2528 /**
2629 * @param callable|null $ability_resolver Resolver receiving an ability name.
2730 * @param callable|null $lock_pruner Callback receiving a dry-run bool.
31+ * @param \DataMachineCode\Storage\CleanupRunRepositoryInterface|null $run_repository Cleanup run repository override for tests.
2832 */
29- public function __construct ( ?callable $ ability_resolver = null , ?callable $ lock_pruner = null ) {
33+ public function __construct ( ?callable $ ability_resolver = null , ?callable $ lock_pruner = null , ? \ DataMachineCode \ Storage \ CleanupRunRepositoryInterface $ run_repository = null ) {
3034 $ this ->ability_resolver = $ ability_resolver ? $ ability_resolver : static fn ( string $ name ) => function_exists ('wp_get_ability ' ) ? wp_get_ability ($ name ) : null ;
3135 $ this ->lock_pruner = $ lock_pruner ? $ lock_pruner : array ( $ this , 'prune_locks ' );
36+ $ this ->run_repository = $ run_repository ;
3237 }
3338
3439 /**
@@ -45,11 +50,12 @@ public function run( array $input ): array|\WP_Error {
4550 return new \WP_Error ('safe_cleanup_refuses_unpushed_discard ' , 'Safe workspace cleanup refuses unpushed commit discard. Unpushed worktrees remain blockers. ' , array ( 'status ' => 400 ));
4651 }
4752
48- $ dry_run = ! empty ($ input ['dry_run ' ]);
49- $ limit = isset ($ input ['limit ' ]) ? max (1 , min (200 , (int ) $ input ['limit ' ])) : 25 ;
50- $ passes = isset ($ input ['passes ' ]) ? max (1 , min (100 , (int ) $ input ['passes ' ])) : 10 ;
51- $ cycles = isset ($ input ['cycles ' ]) ? max (1 , min (25 , (int ) $ input ['cycles ' ])) : 5 ;
52- $ source = isset ($ input ['source ' ]) && '' !== trim ( (string ) $ input ['source ' ]) ? trim ( (string ) $ input ['source ' ]) : self ::DEFAULT_SOURCE ;
53+ $ dry_run = ! empty ($ input ['dry_run ' ]);
54+ $ limit = isset ($ input ['limit ' ]) ? max (1 , min (200 , (int ) $ input ['limit ' ])) : 25 ;
55+ $ passes = isset ($ input ['passes ' ]) ? max (1 , min (100 , (int ) $ input ['passes ' ])) : 10 ;
56+ $ cycles = isset ($ input ['cycles ' ]) ? max (1 , min (25 , (int ) $ input ['cycles ' ])) : 5 ;
57+ $ source = isset ($ input ['source ' ]) && '' !== trim ( (string ) $ input ['source ' ]) ? trim ( (string ) $ input ['source ' ]) : self ::DEFAULT_SOURCE ;
58+ $ progress_callback = isset ($ input ['progress_callback ' ]) && is_callable ($ input ['progress_callback ' ]) ? $ input ['progress_callback ' ] : null ;
5359
5460 $ cleanup_eligible = $ this ->resolve_ability ('datamachine-code/workspace-worktree-cleanup-eligible-drain ' );
5561 if ( is_wp_error ($ cleanup_eligible ) ) {
@@ -88,12 +94,31 @@ public function run( array $input ): array|\WP_Error {
8894 ),
8995 );
9096
97+ $ run_id = $ this ->create_progress_run ($ result , $ dry_run , $ limit , $ passes , $ cycles , $ source );
98+ if ( $ run_id instanceof \WP_Error ) {
99+ return $ run_id ;
100+ }
101+ $ result ['run_id ' ] = $ run_id ;
102+ $ result ['commands ' ] = $ this ->progress_commands ($ run_id , $ dry_run , $ limit , $ passes , $ cycles , $ input );
103+ $ result ['continuation ' ] = array (
104+ 'run_id ' => $ run_id ,
105+ 'status_command ' => $ result ['commands ' ]['status ' ],
106+ 'evidence_command ' => $ result ['commands ' ]['evidence ' ],
107+ 'resume_command ' => $ result ['commands ' ]['resume ' ],
108+ 'note ' => 'If the client disconnects, inspect this run_id and rerun the resume command. Safe cleanup remains bounded and preserves dirty/unpushed blockers. ' ,
109+ );
110+ $ this ->checkpoint_progress ($ run_id , $ result , 'applying ' );
111+ if ( null !== $ progress_callback ) {
112+ $ progress_callback ($ this ->early_progress_result ($ result ));
113+ }
114+
91115 $ lock_start = ( $ this ->lock_pruner )($ dry_run );
92116 if ( is_wp_error ($ lock_start ) ) {
93117 return $ lock_start ;
94118 }
95- $ result ['steps ' ]['lock_prune_start ' ] = $ this ->summarize_lock_step ($ lock_start );
119+ $ result ['steps ' ]['lock_prune_start ' ] = $ this ->summarize_lock_step ($ lock_start );
96120 $ result ['summary ' ]['lock_files_removed ' ] += (int ) ( $ result ['steps ' ]['lock_prune_start ' ]['removed_count ' ] ?? 0 );
121+ $ this ->checkpoint_progress ($ run_id , $ result , 'applying ' );
97122
98123 $ common = array (
99124 'apply ' => ! $ dry_run ,
@@ -109,21 +134,23 @@ public function run( array $input ): array|\WP_Error {
109134
110135 for ( $ cycle = 1 ; $ cycle <= $ cycles ; ++$ cycle ) {
111136 $ result ['summary ' ]['cycles ' ] = $ cycle ;
112- $ cycle_progress = 0 ;
137+ $ cycle_progress = 0 ;
113138
114139 $ eligible = $ this ->execute_ability ($ cleanup_eligible , $ common );
115140 if ( is_wp_error ($ eligible ) ) {
116141 return $ eligible ;
117142 }
118143 $ result ['steps ' ][ 'cleanup_eligible_ ' . $ cycle ] = $ this ->summarize_cleanup_step ($ eligible );
119- $ cycle_progress += $ this ->accumulate_cleanup_step ($ result , $ eligible );
144+ $ cycle_progress += $ this ->accumulate_cleanup_step ($ result , $ eligible );
145+ $ this ->checkpoint_progress ($ run_id , $ result , 'applying ' );
120146
121147 $ active = $ this ->execute_ability ($ active_no_signal , $ common );
122148 if ( is_wp_error ($ active ) ) {
123149 return $ active ;
124150 }
125151 $ result ['steps ' ][ 'active_no_signal_ ' . $ cycle ] = $ this ->summarize_cleanup_step ($ active );
126- $ cycle_progress += $ this ->accumulate_cleanup_step ($ result , $ active );
152+ $ cycle_progress += $ this ->accumulate_cleanup_step ($ result , $ active );
153+ $ this ->checkpoint_progress ($ run_id , $ result , 'applying ' );
127154
128155 if ( $ dry_run || 0 === $ cycle_progress ) {
129156 break ;
@@ -134,21 +161,117 @@ public function run( array $input ): array|\WP_Error {
134161 if ( is_wp_error ($ lock_end ) ) {
135162 return $ lock_end ;
136163 }
137- $ result ['steps ' ]['lock_prune_end ' ] = $ this ->summarize_lock_step ($ lock_end );
164+ $ result ['steps ' ]['lock_prune_end ' ] = $ this ->summarize_lock_step ($ lock_end );
138165 $ result ['summary ' ]['lock_files_removed ' ] += (int ) ( $ result ['steps ' ]['lock_prune_end ' ]['removed_count ' ] ?? 0 );
166+ $ this ->checkpoint_progress ($ run_id , $ result , 'applying ' );
139167
140- $ result ['blockers ' ] = $ this ->compact_blockers ($ result ['blockers ' ]);
141- $ result ['summary ' ]['blocker_count ' ] = array_sum (array_map (static fn ( array $ row ): int => (int ) ( $ row ['count ' ] ?? 0 ), $ result ['blockers ' ]));
168+ $ result ['blockers ' ] = $ this ->compact_blockers ($ result ['blockers ' ]);
169+ $ result ['summary ' ]['blocker_count ' ] = array_sum (array_map (static fn ( array $ row ): int => (int ) ( $ row ['count ' ] ?? 0 ), $ result ['blockers ' ]));
142170 $ result ['summary ' ]['blockers_by_reason ' ] = array_column ($ result ['blockers ' ], 'count ' , 'reason_code ' );
143171 if ( ! $ dry_run && $ result ['summary ' ]['blocker_count ' ] > 0 ) {
144172 $ result ['state ' ] = 'complete_with_blockers ' ;
145173 } else {
146174 $ result ['state ' ] = 'complete ' ;
147175 }
176+ $ this ->checkpoint_progress ($ run_id , $ result , $ result ['state ' ]);
148177
149178 return $ result ;
150179 }
151180
181+ /** @return string|\WP_Error */
182+ private function create_progress_run ( array $ result , bool $ dry_run , int $ limit , int $ passes , int $ cycles , string $ source ): string |\WP_Error {
183+ $ repository = $ this ->progress_repository ();
184+ $ run_id = $ repository ->create_run (
185+ array (
186+ 'mode ' => 'safe_workspace_cleanup ' ,
187+ 'status ' => 'applying ' ,
188+ 'started_at ' => gmdate ('Y-m-d H:i:s ' ),
189+ 'policy ' => array (
190+ 'dry_run ' => $ dry_run ,
191+ 'force ' => false ,
192+ 'discard_unpushed ' => false ,
193+ 'limit ' => $ limit ,
194+ 'passes ' => $ passes ,
195+ 'cycles ' => $ cycles ,
196+ 'source ' => $ source ,
197+ ),
198+ 'summary ' => $ this ->progress_summary ($ result ),
199+ )
200+ );
201+
202+ return $ run_id ;
203+ }
204+
205+ private function checkpoint_progress ( string $ run_id , array $ result , string $ status ): void {
206+ $ repository = $ this ->progress_repository ();
207+ $ fields = array (
208+ 'status ' => $ status ,
209+ 'summary ' => $ this ->progress_summary ($ result ),
210+ );
211+ if ( in_array ($ status , array ( 'complete ' , 'complete_with_blockers ' ), true ) ) {
212+ $ fields ['completed_at ' ] = gmdate ('Y-m-d H:i:s ' );
213+ }
214+ $ repository ->update_run ($ run_id , $ fields );
215+ }
216+
217+ private function progress_repository (): \DataMachineCode \Storage \CleanupRunRepositoryInterface {
218+ if ( null === $ this ->run_repository ) {
219+ $ this ->run_repository = new \DataMachineCode \Storage \CleanupRunRepository ();
220+ }
221+
222+ return $ this ->run_repository ;
223+ }
224+
225+ /** @return array<string,mixed> */
226+ private function progress_summary ( array $ result ): array {
227+ return array (
228+ 'safe_cleanup_progress ' => array (
229+ 'generated_at ' => gmdate ('c ' ),
230+ 'state ' => (string ) ( $ result ['state ' ] ?? 'applying ' ),
231+ 'applied ' => (bool ) ( $ result ['applied ' ] ?? false ),
232+ 'destructive ' => (bool ) ( $ result ['destructive ' ] ?? false ),
233+ 'summary ' => (array ) ( $ result ['summary ' ] ?? array () ),
234+ 'blockers ' => (array ) ( $ result ['blockers ' ] ?? array () ),
235+ 'steps ' => (array ) ( $ result ['steps ' ] ?? array () ),
236+ 'commands ' => (array ) ( $ result ['commands ' ] ?? array () ),
237+ 'continuation ' => (array ) ( $ result ['continuation ' ] ?? array () ),
238+ ),
239+ );
240+ }
241+
242+ /** @return array<string,string> */
243+ private function progress_commands ( string $ run_id , bool $ dry_run , int $ limit , int $ passes , int $ cycles , array $ input ): array {
244+ $ resume = sprintf ('studio wp datamachine-code workspace cleanup safe --limit=%d --passes=%d --cycles=%d ' , $ limit , $ passes , $ cycles );
245+ if ( $ dry_run ) {
246+ $ resume .= ' --dry-run ' ;
247+ }
248+ if ( isset ($ input ['until_budget ' ]) && '' !== trim ( (string ) $ input ['until_budget ' ]) ) {
249+ $ resume .= ' --until-budget= ' . trim ( (string ) $ input ['until_budget ' ]);
250+ }
251+
252+ return array (
253+ 'status ' => sprintf ('studio wp datamachine-code workspace cleanup status %s --format=json ' , $ run_id ),
254+ 'evidence ' => sprintf ('studio wp datamachine-code workspace cleanup evidence %s --format=json ' , $ run_id ),
255+ 'resume ' => $ resume . ' --format=json ' ,
256+ );
257+ }
258+
259+ /** @return array<string,mixed> */
260+ private function early_progress_result ( array $ result ): array {
261+ return array (
262+ 'success ' => true ,
263+ 'mode ' => (string ) ( $ result ['mode ' ] ?? 'safe_workspace_cleanup ' ),
264+ 'state ' => 'applying ' ,
265+ 'run_id ' => (string ) ( $ result ['run_id ' ] ?? '' ),
266+ 'applied ' => (bool ) ( $ result ['applied ' ] ?? false ),
267+ 'destructive ' => (bool ) ( $ result ['destructive ' ] ?? false ),
268+ 'generated_at ' => gmdate ('c ' ),
269+ 'summary ' => (array ) ( $ result ['summary ' ] ?? array () ),
270+ 'commands ' => (array ) ( $ result ['commands ' ] ?? array () ),
271+ 'continuation ' => (array ) ( $ result ['continuation ' ] ?? array () ),
272+ );
273+ }
274+
152275 private function resolve_ability ( string $ name ): mixed {
153276 $ ability = ( $ this ->ability_resolver )($ name );
154277 if ( ! is_object ($ ability ) || ! is_callable (array ( $ ability , 'execute ' )) ) {
@@ -159,11 +282,15 @@ private function resolve_ability( string $name ): mixed {
159282 }
160283
161284 private function execute_ability ( object $ ability , array $ input ): array |\WP_Error {
162- $ result = $ ability ->execute ($ input );
285+ $ executor = array ( $ ability , 'execute ' );
286+ if ( ! is_callable ($ executor ) ) {
287+ return new \WP_Error ('safe_cleanup_ability_missing ' , 'Safe cleanup ability is not executable. ' , array ( 'status ' => 500 ));
288+ }
289+ $ result = $ executor ($ input );
163290 return is_array ($ result ) || is_wp_error ($ result ) ? $ result : new \WP_Error ('safe_cleanup_invalid_result ' , 'Safe cleanup child ability returned an invalid result. ' , array ( 'status ' => 500 ));
164291 }
165292
166- private function prune_locks ( bool $ dry_run ): array | \ WP_Error {
293+ private function prune_locks ( bool $ dry_run ): array {
167294 $ workspace = new Workspace ();
168295 return WorkspaceMutationLock::prune_stale ($ workspace ->get_path (), $ dry_run );
169296 }
@@ -202,7 +329,7 @@ private function accumulate_cleanup_step( array &$result, array $step ): int {
202329
203330 /** @return array<string,int> */
204331 private function extract_blocker_counts ( array $ step ): array {
205- $ counts = array ();
332+ $ counts = array ();
206333 $ summary = (array ) ( $ step ['summary ' ] ?? array () );
207334 foreach ( (array ) ( $ summary ['blocked_by_reason ' ] ?? $ summary ['skipped_by_reason ' ] ?? array () ) as $ reason => $ count ) {
208335 $ counts [ (string ) $ reason ] = (int ) $ count ;
@@ -239,8 +366,8 @@ private function summarize_lock_step( array $step ): array {
239366 private function compact_blockers ( array $ rows ): array {
240367 $ blockers = array ();
241368 foreach ( $ rows as $ row ) {
242- $ reason = (string ) ( $ row ['reason_code ' ] ?? 'unknown ' );
243- $ blockers [ $ reason ] ??= array (
369+ $ reason = (string ) ( $ row ['reason_code ' ] ?? 'unknown ' );
370+ $ blockers [ $ reason ] ??= array (
244371 'reason_code ' => $ reason ,
245372 'count ' => 0 ,
246373 );
0 commit comments