Skip to content

Commit f91c4b9

Browse files
authored
fix: summarize cleanup remaining work (#502)
1 parent d4b82bd commit f91c4b9

6 files changed

Lines changed: 492 additions & 45 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php
2+
/**
3+
* Compact cleanup remaining-work summaries.
4+
*
5+
* @package DataMachineCode\Cleanup
6+
*/
7+
8+
namespace DataMachineCode\Cleanup;
9+
10+
defined('ABSPATH') || exit;
11+
12+
class CleanupRemainingWorkSummary {
13+
14+
15+
16+
private const EXAMPLE_LIMIT = 3;
17+
18+
/**
19+
* Build a concise summary from DB-backed cleanup items.
20+
*
21+
* @param array<int,array<string,mixed>> $items Cleanup items.
22+
* @return array<string,mixed>
23+
*/
24+
public static function from_items( array $items ): array {
25+
$summary = self::empty_summary();
26+
27+
foreach ( $items as $item ) {
28+
if ( ! is_array($item) ) {
29+
continue;
30+
}
31+
32+
$type = (string) ( $item['item_type'] ?? 'unknown' );
33+
$status = (string) ( $item['status'] ?? 'unknown' );
34+
$evidence = (array) ( $item['evidence'] ?? array() );
35+
$row = array_merge($evidence, $item);
36+
37+
if ( 'applied' === $status ) {
38+
self::increment_type($summary['applied_by_type'], $type, (int) ( $item['bytes_reclaimed'] ?? 0 ));
39+
continue;
40+
}
41+
42+
if ( 'skipped' === $status || 'failed' === $status ) {
43+
self::increment_reason($summary['skipped_by_reason'], self::reason_code($item), $row);
44+
}
45+
46+
if ( 'blocked' === $status || 'resolver' === $type ) {
47+
self::increment_reason($summary['blocked_resolvers_by_reason'], self::reason_code($item), $row);
48+
}
49+
50+
if ( 'artifact_cleanup' === $type && 'applied' !== $status ) {
51+
$summary['remaining_reclaimable_artifact_bytes'] += self::row_bytes($row, array( 'artifact_size_bytes', 'size_bytes' ));
52+
}
53+
if ( 'worktree_removal' === $type && in_array($status, array( 'pending', 'failed' ), true) ) {
54+
++$summary['remaining_safely_removable_worktrees'];
55+
}
56+
}
57+
58+
return self::finalize($summary);
59+
}
60+
61+
/**
62+
* Build a concise summary from job-backed cleanup run aggregates.
63+
*
64+
* @param array<string,mixed> $aggregate Cleanup child aggregate.
65+
* @return array<string,mixed>
66+
*/
67+
public static function from_job_aggregate( array $aggregate ): array {
68+
$summary = self::empty_summary();
69+
$items = (array) ( $aggregate['cleanup_items'] ?? array() );
70+
71+
foreach ( (array) ( $items['by_type'] ?? array() ) as $type => $row ) {
72+
if ( ! is_array($row) ) {
73+
continue;
74+
}
75+
self::increment_type($summary['applied_by_type'], (string) $type, (int) ( $row['bytes_reclaimed'] ?? 0 ), (int) ( $row['applied_rows'] ?? 0 ));
76+
}
77+
78+
foreach ( (array) ( $items['skipped_examples_by_reason'] ?? array() ) as $reason => $bucket ) {
79+
self::copy_reason_bucket($summary['skipped_by_reason'], (string) $reason, (array) $bucket);
80+
}
81+
foreach ( (array) ( $items['failed_examples_by_reason'] ?? array() ) as $reason => $bucket ) {
82+
self::copy_reason_bucket($summary['skipped_by_reason'], (string) $reason, (array) $bucket);
83+
}
84+
85+
$summary['remaining_reclaimable_artifact_bytes'] = max(0, (int) ( $items['remaining_reclaimable_artifact_bytes'] ?? 0 ));
86+
$summary['remaining_safely_removable_worktrees'] = max(0, (int) ( $items['remaining_safely_removable_worktrees'] ?? 0 ));
87+
88+
return self::finalize($summary);
89+
}
90+
91+
private static function empty_summary(): array {
92+
return array(
93+
'applied_by_type' => array(),
94+
'skipped_by_reason' => array(),
95+
'blocked_resolvers_by_reason' => array(),
96+
'remaining_reclaimable_artifact_bytes' => 0,
97+
'remaining_safely_removable_worktrees' => 0,
98+
'recommended_commands' => array(),
99+
);
100+
}
101+
102+
private static function increment_type( array &$types, string $type, int $bytes, int $count = 1 ): void {
103+
if ( '' === $type ) {
104+
$type = 'unknown';
105+
}
106+
$types[ $type ] ??= array(
107+
'count' => 0,
108+
'bytes_reclaimed' => 0,
109+
);
110+
$types[ $type ]['count'] += max(0, $count);
111+
$types[ $type ]['bytes_reclaimed'] += max(0, $bytes);
112+
}
113+
114+
private static function increment_reason( array &$reasons, string $reason, array $row ): void {
115+
$reasons[ $reason ] ??= array(
116+
'count' => 0,
117+
'examples' => array(),
118+
);
119+
++$reasons[ $reason ]['count'];
120+
if ( count($reasons[ $reason ]['examples']) < self::EXAMPLE_LIMIT ) {
121+
$reasons[ $reason ]['examples'][] = self::example_row($row);
122+
}
123+
}
124+
125+
private static function copy_reason_bucket( array &$reasons, string $reason, array $bucket ): void {
126+
if ( '' === $reason ) {
127+
$reason = 'unknown';
128+
}
129+
$reasons[ $reason ] ??= array(
130+
'count' => 0,
131+
'examples' => array(),
132+
);
133+
$reasons[ $reason ]['count'] += max(0, (int) ( $bucket['count'] ?? 0 ));
134+
foreach ( (array) ( $bucket['examples'] ?? array() ) as $example ) {
135+
if ( count($reasons[ $reason ]['examples']) >= self::EXAMPLE_LIMIT ) {
136+
break;
137+
}
138+
$reasons[ $reason ]['examples'][] = is_array($example) ? self::example_row($example) : array( 'handle' => (string) $example );
139+
}
140+
}
141+
142+
private static function reason_code( array $row ): string {
143+
$reason = (string) ( $row['reason_code'] ?? $row['reason'] ?? 'unknown' );
144+
return '' === $reason ? 'unknown' : $reason;
145+
}
146+
147+
private static function example_row( array $row ): array {
148+
$example = array(
149+
'handle' => (string) ( $row['handle'] ?? '' ),
150+
);
151+
foreach ( array( 'repo', 'branch', 'reason', 'path' ) as $field ) {
152+
if ( isset($row[ $field ]) && '' !== (string) $row[ $field ] ) {
153+
$example[ $field ] = (string) $row[ $field ];
154+
}
155+
}
156+
return $example;
157+
}
158+
159+
private static function row_bytes( array $row, array $fields ): int {
160+
foreach ( $fields as $field ) {
161+
if ( isset($row[ $field ]) ) {
162+
return max(0, (int) $row[ $field ]);
163+
}
164+
}
165+
$total = 0;
166+
foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) {
167+
$total += max(0, (int) ( is_array($artifact) ? ( $artifact['size_bytes'] ?? 0 ) : 0 ));
168+
}
169+
return $total;
170+
}
171+
172+
private static function finalize( array $summary ): array {
173+
ksort($summary['applied_by_type']);
174+
ksort($summary['skipped_by_reason']);
175+
ksort($summary['blocked_resolvers_by_reason']);
176+
$summary['recommended_commands'] = self::recommended_commands($summary);
177+
return $summary;
178+
}
179+
180+
private static function recommended_commands( array $summary ): array {
181+
$commands = array();
182+
if ( (int) $summary['remaining_reclaimable_artifact_bytes'] > 0 ) {
183+
$commands[] = array(
184+
'bucket' => 'remaining_artifacts',
185+
'command' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json',
186+
'apply' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts',
187+
'destructive' => false,
188+
);
189+
}
190+
if ( (int) $summary['remaining_safely_removable_worktrees'] > 0 ) {
191+
$commands[] = array(
192+
'bucket' => 'remaining_worktrees',
193+
'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25',
194+
'apply' => 'studio wp datamachine-code workspace cleanup run --mode=retention',
195+
'destructive' => false,
196+
);
197+
}
198+
foreach ( array_keys( (array) $summary['skipped_by_reason'] ) as $reason ) {
199+
$commands[] = self::command_for_reason( (string) $reason, 'skipped');
200+
}
201+
foreach ( array_keys( (array) $summary['blocked_resolvers_by_reason'] ) as $reason ) {
202+
$commands[] = self::command_for_reason( (string) $reason, 'blocked_resolver');
203+
}
204+
205+
$seen = array();
206+
return array_values(array_filter(
207+
$commands,
208+
function ( array $command ) use ( &$seen ): bool {
209+
$key = (string) ( $command['bucket'] ?? '' ) . '|' . (string) ( $command['command'] ?? '' );
210+
if ( isset($seen[ $key ]) ) {
211+
return false;
212+
}
213+
$seen[ $key ] = true;
214+
return true;
215+
}
216+
));
217+
}
218+
219+
private static function command_for_reason( string $reason, string $bucket ): array {
220+
$command = match ( $reason ) {
221+
'needs_metadata_reconcile', 'lifecycle_reconciliation_candidate', 'repaired_metadata' => 'studio wp datamachine-code workspace worktree reconcile-metadata --dry-run --format=json',
222+
'dirty_worktree', 'unpushed_commits', 'probe_timeout', 'plan_mismatch' => 'studio wp datamachine-code workspace cleanup run --mode=retention --dry-run --format=json',
223+
'artifact_already_removed', 'artifact_plan_mismatch' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json',
224+
default => 'studio wp datamachine-code workspace cleanup run --mode=retention --dry-run --format=json',
225+
};
226+
227+
return array(
228+
'bucket' => $bucket . ':' . $reason,
229+
'command' => $command,
230+
'destructive' => false,
231+
);
232+
}
233+
}

0 commit comments

Comments
 (0)