Skip to content

Commit 01cb4a5

Browse files
Expose safe cleanup progress evidence (#842)
* Expose safe cleanup progress run evidence * Fix safe cleanup lint issues * Fix safe cleanup repository contract lint * Fix final safe cleanup lint nits --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 01da005 commit 01cb4a5

7 files changed

Lines changed: 262 additions & 35 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,15 +1695,18 @@ private function registerAbilities(): void {
16951695
'output_schema' => array(
16961696
'type' => 'object',
16971697
'properties' => array(
1698-
'success' => array( 'type' => 'boolean' ),
1699-
'mode' => array( 'type' => 'string' ),
1700-
'applied' => array( 'type' => 'boolean' ),
1701-
'destructive' => array( 'type' => 'boolean' ),
1702-
'summary' => array( 'type' => 'object' ),
1703-
'blockers' => array( 'type' => 'array' ),
1704-
'evidence' => array( 'type' => 'object' ),
1705-
'steps' => array( 'type' => 'object' ),
1706-
'state' => array( 'type' => 'string' ),
1698+
'success' => array( 'type' => 'boolean' ),
1699+
'mode' => array( 'type' => 'string' ),
1700+
'run_id' => array( 'type' => 'string' ),
1701+
'applied' => array( 'type' => 'boolean' ),
1702+
'destructive' => array( 'type' => 'boolean' ),
1703+
'summary' => array( 'type' => 'object' ),
1704+
'blockers' => array( 'type' => 'array' ),
1705+
'evidence' => array( 'type' => 'object' ),
1706+
'steps' => array( 'type' => 'object' ),
1707+
'commands' => array( 'type' => 'object' ),
1708+
'continuation' => array( 'type' => 'object' ),
1709+
'state' => array( 'type' => 'string' ),
17071710
),
17081711
),
17091712
'execute_callback' => array( self::class, 'workspaceCleanupSafe' ),

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,12 @@ private function run_cleanup_safe( array $assoc_args ): void {
827827
if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) {
828828
$input['until_budget'] = trim( (string) $assoc_args['until-budget']);
829829
}
830+
if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
831+
$input['progress_callback'] = function ( array $progress ) use ( $assoc_args ): void {
832+
$this->render_cleanup_safe_result($progress, $assoc_args);
833+
$this->flush_cli_output();
834+
};
835+
}
830836

831837
$ability = wp_get_ability('datamachine-code/workspace-cleanup-safe');
832838
if ( ! $ability ) {

inc/Storage/CleanupRunRepository.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
require_once dirname(__DIR__) . '/Support/JsonCodec.php';
1616
}
1717

18-
class CleanupRunRepository {
19-
18+
if ( ! interface_exists(CleanupRunRepositoryInterface::class) ) {
19+
require_once __DIR__ . '/CleanupRunRepositoryInterface.php';
20+
}
2021

22+
class CleanupRunRepository implements CleanupRunRepositoryInterface {
2123

2224
/**
2325
* Create a cleanup run.
@@ -60,7 +62,7 @@ public function create_run( array $run ): string|\WP_Error {
6062
* Insert planned cleanup items.
6163
*
6264
* @param string $run_id Run ID.
63-
* @param array<int,array> $items Items.
65+
* @param array<int,mixed> $items Items.
6466
* @return int|\WP_Error
6567
*/
6668
public function add_items( string $run_id, array $items ): int|\WP_Error {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
/**
3+
* Cleanup run repository contract.
4+
*
5+
* @package DataMachineCode\Storage
6+
*/
7+
8+
namespace DataMachineCode\Storage;
9+
10+
defined('ABSPATH') || exit;
11+
12+
interface CleanupRunRepositoryInterface {
13+
14+
/**
15+
* Create a cleanup run.
16+
*
17+
* @param array<string,mixed> $run Run fields.
18+
* @return string|\WP_Error
19+
*/
20+
public function create_run( array $run ): string|\WP_Error;
21+
22+
/**
23+
* Update a cleanup run.
24+
*
25+
* @param string $run_id Run ID.
26+
* @param array<string,mixed> $fields Run fields.
27+
*/
28+
public function update_run( string $run_id, array $fields ): bool;
29+
}

inc/Workspace/CleanupRunService.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -626,9 +626,10 @@ private function run_progress( array $run, array $items, array $summary ): array
626626
'applying_rows' => count($applying),
627627
'applying_examples' => $examples,
628628
'pending_or_failed' => (int) ( $summary['pending_or_failed'] ?? 0 ),
629+
'safe_cleanup' => $run['summary']['safe_cleanup_progress'] ?? null,
629630
'started_at' => $started_at,
630631
'age_seconds' => $age,
631-
'resumable' => $resumable,
632+
'resumable' => $resumable || ( 'safe_workspace_cleanup' === (string) ( $run['mode'] ?? '' ) && 'applying' === $run_status ),
632633
'note' => count($applying) > 0 ? 'Rows marked applying are safe to retry with workspace cleanup resume if the previous apply process was interrupted.' : '',
633634
);
634635
}
@@ -642,7 +643,22 @@ private function run_progress( array $run, array $items, array $summary ): array
642643
* @return array<string,mixed>
643644
*/
644645
private function remaining_work_summary( string $run_id, array $items, array $progress ): array {
645-
$summary = CleanupRemainingWorkSummary::from_items($items);
646+
$summary = CleanupRemainingWorkSummary::from_items($items);
647+
$safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array();
648+
$safe_commands = is_array($safe_cleanup['commands'] ?? null) ? (array) $safe_cleanup['commands'] : array();
649+
if ( isset($safe_commands['status'], $safe_commands['resume']) ) {
650+
$resume_command = array(
651+
'bucket' => 'safe_cleanup_continuation',
652+
'command' => (string) $safe_commands['status'],
653+
'apply' => (string) $safe_commands['resume'],
654+
'destructive' => false,
655+
'apply_destructive' => true,
656+
'why' => 'Inspect or continue the DMC safe cleanup run from durable progress evidence.',
657+
);
658+
array_unshift($summary['recommended_commands'], $resume_command);
659+
array_unshift($summary['next_commands'], (string) $resume_command['command'], (string) $resume_command['apply']);
660+
$summary['next_commands'] = array_values(array_unique($summary['next_commands']));
661+
}
646662
if ( ! empty($progress['resumable']) ) {
647663
$resume_command = array(
648664
'bucket' => 'current_run_resume',

inc/Workspace/WorkspaceSafeCleanupOrchestrator.php

Lines changed: 145 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)