-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathWorktreeDiskBudget.php
More file actions
420 lines (378 loc) · 16.3 KB
/
Copy pathWorktreeDiskBudget.php
File metadata and controls
420 lines (378 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
<?php
/**
* Worktree Disk Budget
*
* Cheap pre-create guardrails for workspace worktree growth.
*
* @package DataMachineCode\Workspace
*/
namespace DataMachineCode\Workspace;
defined('ABSPATH') || exit;
final class WorktreeDiskBudget {
private const BYTES_PER_GIB = 1073741824;
/**
* Default warning threshold: 20 GiB free.
*/
private const DEFAULT_WARN_FREE_GIB = 20;
/**
* Default refusal threshold: 10 GiB free.
*/
private const DEFAULT_REFUSE_FREE_GIB = 10;
/**
* Default warning threshold: 15% free.
*/
private const DEFAULT_WARN_FREE_PERCENT = 15.0;
/**
* Default refusal threshold: 10% free.
*/
private const DEFAULT_REFUSE_FREE_PERCENT = 10.0;
/**
* Default worktree-count warning threshold.
*/
private const DEFAULT_WARN_WORKTREE_COUNT = 100;
/**
* Inspect workspace disk budget without walking file contents.
*
* @param string $workspace_path Workspace root path.
* @param array $thresholds Optional threshold override for tests.
* @param bool $forced Whether the caller explicitly forced creation.
* @return array<string,mixed>
*/
public static function inspect( string $workspace_path, array $thresholds = array(), bool $forced = false ): array {
$thresholds = self::normalize_thresholds($thresholds);
$free_bytes = is_dir($workspace_path) ? disk_free_space($workspace_path) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_disk_free_space
$total_bytes = is_dir($workspace_path) ? disk_total_space($workspace_path) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_disk_total_space
$free_bytes = is_float($free_bytes) ? (int) $free_bytes : null;
$total_bytes = is_float($total_bytes) ? (int) $total_bytes : null;
$worktrees = self::count_worktree_like_dirs($workspace_path);
return self::evaluate(
array(
'workspace_path' => $workspace_path,
'free_bytes' => $free_bytes,
'total_bytes' => $total_bytes,
'worktree_count' => $worktrees,
),
$thresholds,
$forced
);
}
/**
* Evaluate disk-budget status from already-measured values.
*
* @param array $metrics Measured values.
* @param array $thresholds Threshold values.
* @param bool $forced Whether the caller explicitly forced creation.
* @return array<string,mixed>
*/
public static function evaluate( array $metrics, array $thresholds = array(), bool $forced = false ): array {
$thresholds = self::normalize_thresholds($thresholds);
$free_bytes = isset($metrics['free_bytes']) && is_numeric($metrics['free_bytes']) ? (int) $metrics['free_bytes'] : null;
$total_bytes = isset($metrics['total_bytes']) && is_numeric($metrics['total_bytes']) ? (int) $metrics['total_bytes'] : null;
$free_percent = null;
if ( null !== $free_bytes && null !== $total_bytes && $total_bytes > 0 ) {
$free_percent = ( $free_bytes / $total_bytes ) * 100;
}
$count = isset($metrics['worktree_count']) && is_numeric($metrics['worktree_count']) ? (int) $metrics['worktree_count'] : 0;
$warnings = array();
$refused = false;
$effective_refuse_bytes = (int) $thresholds['refuse_free_bytes'];
$effective_warn_bytes = (int) $thresholds['warn_free_bytes'];
if ( null !== $total_bytes && $total_bytes > 0 ) {
$effective_refuse_bytes = self::effective_refuse_free_bytes_threshold(
(int) $thresholds['refuse_free_bytes'],
$thresholds['refuse_free_percent'],
$total_bytes
);
$effective_warn_bytes = self::effective_free_bytes_threshold(
(int) $thresholds['warn_free_bytes'],
$thresholds['warn_free_percent'],
$total_bytes
);
}
if ( null !== $free_bytes ) {
if ( $free_bytes < $effective_refuse_bytes ) {
$refused = ! $forced;
$warnings[] = sprintf(
'Free disk space is %.1f GiB%s, below the refusal threshold of %.1f GiB.',
self::bytes_to_gib($free_bytes),
null === $free_percent ? '' : sprintf(' (%.1f%%)', $free_percent),
self::bytes_to_gib($effective_refuse_bytes)
);
} elseif ( $free_bytes < $effective_warn_bytes ) {
$warnings[] = sprintf(
'Free disk space is %.1f GiB%s, below the warning threshold of %.1f GiB or %.1f%% free, whichever is stricter.',
self::bytes_to_gib($free_bytes),
null === $free_percent ? '' : sprintf(' (%.1f%%)', $free_percent),
self::bytes_to_gib( (int) $thresholds['warn_free_bytes'] ),
$thresholds['warn_free_percent']
);
}
} else {
$warnings[] = 'Free disk space could not be measured.';
}
if ( $count > $thresholds['warn_worktree_count'] ) {
$warnings[] = sprintf(
'Workspace has %d worktree-like directories, above the %d warning threshold.',
$count,
$thresholds['warn_worktree_count']
);
}
$status = $refused ? 'refused' : ( empty($warnings) ? 'ok' : 'warning' );
$trigger_reasons = array();
if ( null !== $free_bytes && $free_bytes < $effective_refuse_bytes ) {
$trigger_reasons[] = 'free_space_refusal_threshold';
} elseif ( null !== $free_bytes && $free_bytes < $effective_warn_bytes ) {
$trigger_reasons[] = 'free_space_warning_threshold';
}
if ( $count > $thresholds['warn_worktree_count'] ) {
$trigger_reasons[] = 'worktree_count_warning_threshold';
}
return array(
'workspace_path' => (string) ( $metrics['workspace_path'] ?? '' ),
'free_bytes' => $free_bytes,
'free_gib' => null === $free_bytes ? null : round(self::bytes_to_gib($free_bytes), 2),
'total_bytes' => $total_bytes,
'total_gib' => null === $total_bytes ? null : round(self::bytes_to_gib($total_bytes), 2),
'free_percent' => null === $free_percent ? null : round($free_percent, 2),
'workspace_size_bytes' => null,
'workspace_size_exact' => false,
'worktree_count' => $count,
'warn_free_bytes' => $thresholds['warn_free_bytes'],
'warn_free_gib' => round(self::bytes_to_gib( (int) $thresholds['warn_free_bytes'] ), 2),
'warn_free_percent' => $thresholds['warn_free_percent'],
'refuse_free_bytes' => $thresholds['refuse_free_bytes'],
'refuse_free_gib' => round(self::bytes_to_gib( (int) $thresholds['refuse_free_bytes'] ), 2),
'refuse_free_percent' => $thresholds['refuse_free_percent'],
'effective_refuse_bytes' => $effective_refuse_bytes,
'effective_refuse_gib' => round(self::bytes_to_gib($effective_refuse_bytes), 2),
'effective_warn_bytes' => $effective_warn_bytes,
'effective_warn_gib' => round(self::bytes_to_gib($effective_warn_bytes), 2),
'warn_worktree_count' => $thresholds['warn_worktree_count'],
'forced' => $forced,
'status' => $status,
'warnings' => $warnings,
'emergency_triggered' => array() !== $trigger_reasons,
'trigger_reasons' => $trigger_reasons,
'cleanup_dry_run_command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run',
'artifact_cleanup_command' => 'studio wp datamachine-code workspace worktree cleanup-artifacts --dry-run',
'emergency_cleanup_command' => 'studio wp datamachine-code workspace worktree emergency-cleanup --format=json',
'cleanup_recommendations' => self::cleanup_recommendations($free_bytes, $effective_refuse_bytes),
'force_override_required' => $refused,
'force_override_applied' => $forced && ! empty($warnings),
);
}
/**
* Build concise operator remediation commands for disk-pressure failures.
*
* @param int|null $free_bytes Current free bytes.
* @param int $effective_refuse_bytes Effective refusal floor.
* @return array<int,array<string,mixed>>
*/
private static function cleanup_recommendations( ?int $free_bytes, int $effective_refuse_bytes ): array {
$target_reclaim = null === $free_bytes ? null : max(0, $effective_refuse_bytes - $free_bytes);
$target_human = null === $target_reclaim ? 'enough space to clear the refusal threshold' : self::format_bytes($target_reclaim);
return array(
array(
'priority' => 1,
'action' => 'review largest reconstructable artifacts',
'expected_reclaim_bytes' => $target_reclaim,
'expected_reclaim' => $target_human,
'command' => 'studio wp datamachine-code workspace worktree cleanup-artifacts --dry-run --sort=size',
'preview_command' => 'studio wp datamachine-code workspace worktree cleanup-artifacts --dry-run --sort=size',
),
array(
'priority' => 2,
'action' => 'review bounded cleanup-eligible worktrees; apply revalidates before removal',
'expected_reclaim_bytes' => $target_reclaim,
'expected_reclaim' => $target_human,
'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25',
'preview_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25',
'apply_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25',
'apply_note' => 'Apply runs fresh dirty, unpushed, containment, and primary safety probes and may skip rows that the cheap inventory review listed.',
),
array(
'priority' => 3,
'action' => 'generate combined emergency cleanup report',
'expected_reclaim_bytes' => $target_reclaim,
'expected_reclaim' => $target_human,
'command' => 'studio wp datamachine-code workspace worktree emergency-cleanup --format=json',
'preview_command' => 'studio wp datamachine-code workspace worktree emergency-cleanup --format=json',
),
);
}
/**
* Get filterable thresholds.
*
* @param string $repo Repository name.
* @param string $branch Branch name.
* @return array<string,int|float>
*/
public static function thresholds( string $repo, string $branch ): array {
$thresholds = array(
'warn_free_bytes' => self::DEFAULT_WARN_FREE_GIB * self::BYTES_PER_GIB,
'refuse_free_bytes' => self::DEFAULT_REFUSE_FREE_GIB * self::BYTES_PER_GIB,
'warn_free_percent' => self::DEFAULT_WARN_FREE_PERCENT,
'refuse_free_percent' => self::DEFAULT_REFUSE_FREE_PERCENT,
'warn_worktree_count' => self::DEFAULT_WARN_WORKTREE_COUNT,
);
if ( function_exists('apply_filters') ) {
/**
* Filters pre-create worktree disk-budget thresholds.
*
* @param array $thresholds Default thresholds.
* @param string $repo Repository name.
* @param string $branch Branch being materialized.
*/
// @phpstan-ignore-next-line WordPress accepts context args beyond the filtered value.
$thresholds = apply_filters('datamachine_worktree_disk_budget_thresholds', $thresholds, $repo, $branch);
}
return self::normalize_thresholds( (array) $thresholds);
}
/**
* Format a short human-readable summary.
*
* @param array $budget Budget report.
* @return string
*/
public static function format_summary( array $budget ): string {
$free = null === ( $budget['free_gib'] ?? null ) ? 'unknown' : sprintf('%.1f GiB', (float) $budget['free_gib']);
if ( null !== ( $budget['free_percent'] ?? null ) ) {
$free .= sprintf(' (%.1f%%)', (float) $budget['free_percent']);
}
$total = null === ( $budget['total_gib'] ?? null ) ? 'unknown total' : sprintf('%.1f GiB total', (float) $budget['total_gib']);
return sprintf(
'Disk budget: workspace=%s, %s free of %s, %d worktree-like dirs, status=%s.',
(string) ( $budget['workspace_path'] ?? '' ),
$free,
$total,
(int) ( $budget['worktree_count'] ?? 0 ),
(string) ( $budget['status'] ?? 'unknown' )
);
}
/**
* Normalize threshold inputs.
*
* @param array $thresholds Raw thresholds.
* @return array<string,int|float>
*/
private static function normalize_thresholds( array $thresholds ): array {
$warn_free = isset($thresholds['warn_free_bytes']) && is_numeric($thresholds['warn_free_bytes'])
? max(0, (int) $thresholds['warn_free_bytes'])
: self::DEFAULT_WARN_FREE_GIB * self::BYTES_PER_GIB;
$refuse_free = isset($thresholds['refuse_free_bytes']) && is_numeric($thresholds['refuse_free_bytes'])
? max(0, (int) $thresholds['refuse_free_bytes'])
: self::DEFAULT_REFUSE_FREE_GIB * self::BYTES_PER_GIB;
$warn_percent = isset($thresholds['warn_free_percent']) && is_numeric($thresholds['warn_free_percent'])
? max(0.0, min(100.0, (float) $thresholds['warn_free_percent']))
: self::DEFAULT_WARN_FREE_PERCENT;
$refuse_percent = isset($thresholds['refuse_free_percent']) && is_numeric($thresholds['refuse_free_percent'])
? max(0.0, min(100.0, (float) $thresholds['refuse_free_percent']))
: self::DEFAULT_REFUSE_FREE_PERCENT;
$count = isset($thresholds['warn_worktree_count']) && is_numeric($thresholds['warn_worktree_count'])
? max(0, (int) $thresholds['warn_worktree_count'])
: self::DEFAULT_WARN_WORKTREE_COUNT;
if ( $refuse_free > $warn_free ) {
$warn_free = $refuse_free;
}
if ( $refuse_percent > $warn_percent ) {
$warn_percent = $refuse_percent;
}
return array(
'warn_free_bytes' => $warn_free,
'refuse_free_bytes' => $refuse_free,
'warn_free_percent' => $warn_percent,
'refuse_free_percent' => $refuse_percent,
'warn_worktree_count' => $count,
);
}
/**
* Format bytes for command guidance without WordPress runtime helpers.
*
* @param int|float $bytes Bytes.
* @return string
*/
private static function format_bytes( int|float $bytes ): string {
$bytes = max(0, (float) $bytes);
$units = array( 'B', 'KiB', 'MiB', 'GiB', 'TiB' );
$unit = 0;
$unit_count = count($units);
while ( $bytes >= 1024 && $unit < $unit_count - 1 ) {
$bytes /= 1024;
++$unit;
}
return number_format($bytes, 0 === $unit ? 0 : 1) . ' ' . $units[ $unit ];
}
/**
* Count worktree-like directories cheaply without consulting every primary.
*
* @param string $workspace_path Workspace root path.
* @return int
*/
private static function count_worktree_like_dirs( string $workspace_path ): int {
if ( ! is_dir($workspace_path) ) {
return 0;
}
$entries = scandir($workspace_path); // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.Found,WordPress.WP.AlternativeFunctions.file_system_operations_scandir
if ( false === $entries ) {
return 0;
}
$count = 0;
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry || ! str_contains($entry, '@') ) {
continue;
}
if ( is_dir($workspace_path . '/' . $entry) ) {
++$count;
}
}
return $count;
}
/**
* Calculate the free-space threshold for the measured filesystem.
*
* The absolute GiB floor protects normal workspaces, but bounded ephemeral
* filesystems can be smaller than that floor. In that case, the percentage
* threshold is the only attainable safety signal.
*
* @param int $absolute_bytes Absolute free-space threshold.
* @param float $percent Percentage free-space threshold.
* @param int $total_bytes Measured filesystem size.
* @return int
*/
private static function effective_free_bytes_threshold( int $absolute_bytes, float $percent, int $total_bytes ): int {
$percent_bytes = (int) ceil($total_bytes * ( $percent / 100 ));
if ( $total_bytes < $absolute_bytes ) {
return $percent_bytes;
}
return max($absolute_bytes, $percent_bytes);
}
/**
* Calculate the hard refusal threshold for a measured filesystem.
*
* Large filesystems can safely fall below a percentage threshold while still
* having enough absolute free space for a bare worktree checkout. Keep the
* percentage refusal only for filesystems smaller than the absolute floor,
* where the absolute GiB floor is impossible to satisfy.
*
* @param int $absolute_bytes Absolute free-space threshold.
* @param float $percent Percentage free-space threshold.
* @param int $total_bytes Measured filesystem size.
* @return int
*/
private static function effective_refuse_free_bytes_threshold( int $absolute_bytes, float $percent, int $total_bytes ): int {
$percent_bytes = (int) ceil($total_bytes * ( $percent / 100 ));
if ( $total_bytes < $absolute_bytes ) {
return $percent_bytes;
}
return $absolute_bytes;
}
/**
* Convert bytes to GiB.
*
* @param int $bytes Bytes.
* @return float
*/
private static function bytes_to_gib( int $bytes ): float {
return $bytes / self::BYTES_PER_GIB;
}
}