Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,9 @@ private function registerAbilities(): void {
'type' => 'boolean',
'description' => 'Allow pruning rows with unpushed_count > 0 or a non-empty pr_url. Default false.',
),
'limit' => array( 'type' => 'integer', 'description' => 'Maximum missing inventory rows to inspect. Default 25, maximum 200.' ),
'after_handle' => array( 'type' => 'string', 'description' => 'Last processed handle for a stable missing-inventory keyset continuation.' ),
'until_budget' => array( 'type' => 'string', 'description' => 'Optional compact wall-clock budget such as 30s.' ),
),
),
'output_schema' => array(
Expand All @@ -1728,6 +1731,7 @@ private function registerAbilities(): void {
'deleted' => array( 'type' => 'array' ),
'skipped' => array( 'type' => 'array' ),
'summary' => array( 'type' => 'object' ),
'continuation' => array( 'type' => 'object' ),
),
),
'execute_callback' => array( self::class, 'worktreeInventoryPruneMissing' ),
Expand Down Expand Up @@ -1795,14 +1799,14 @@ private function registerAbilities(): void {
'datamachine-code/workspace-cleanup-safe',
array(
'label' => 'Run Safe Workspace Cleanup',
'description' => 'Run the canonical DMC safe workspace cleanup flow. Uses DMC safe classifiers/removals, refuses force and unpushed discard, and reports remaining blockers.',
'description' => 'Run the canonical DMC safe workspace cleanup flow. Uses DMC safe classifiers/removals and missing-inventory pruning with fresh path revalidation, refuses force and unpushed discard, and reports remaining blockers.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'dry_run' => array(
'type' => 'boolean',
'description' => 'Preview safe cleanup without removing worktrees or stale DMC lock files.',
'description' => 'Preview safe cleanup without removing worktrees, inventory rows, or stale DMC lock files.',
),
'limit' => array(
'type' => 'integer',
Expand All @@ -1816,6 +1820,7 @@ private function registerAbilities(): void {
'type' => 'integer',
'description' => 'Maximum safe cleanup cycles before stopping. Clamped by the orchestrator.',
),
'inventory_after' => array( 'type' => 'string', 'description' => 'Last processed missing-inventory handle for a safe-cleanup keyset continuation.' ),
'until_budget' => array(
'type' => 'string',
'description' => 'Optional child-drain time budget such as 30s.',
Expand Down Expand Up @@ -4248,12 +4253,21 @@ public static function worktreeInventoryRefresh( array $input ): array|\WP_Error
*/
public static function worktreeInventoryPruneMissing( array $input ): array|\WP_Error {
$workspace = new Workspace();
return $workspace->worktree_inventory_prune_missing(
array(
'dry_run' => ! empty($input['dry_run']),
'force' => ! empty($input['force']),
)
$opts = array(
'dry_run' => ! empty($input['dry_run']),
'force' => ! empty($input['force']),
);
if ( isset($input['limit']) ) {
$opts['limit'] = (int) $input['limit'];
}
if ( isset($input['after_handle']) ) {
$opts['after_handle'] = (string) $input['after_handle'];
}
if ( isset($input['until_budget']) ) {
$opts['until_budget'] = (string) $input['until_budget'];
}

return $workspace->worktree_inventory_prune_missing($opts);
}

/**
Expand Down
34 changes: 27 additions & 7 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,9 @@ private function run_cleanup_safe( array $assoc_args ): void {
$input[ $key ] = (int) $assoc_args[ $key ];
}
}
if ( isset($assoc_args['inventory-after']) ) {
$input['inventory_after'] = (string) $assoc_args['inventory-after'];
}
if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) {
$input['until_budget'] = trim( (string) $assoc_args['until-budget']);
}
Expand Down Expand Up @@ -960,6 +963,9 @@ private function render_cleanup_safe_result( array $result, array $assoc_args ):
'metric' => 'marked_cleanup_eligible',
'value' => (string) ( $summary['marked_cleanup_eligible'] ?? 0 ),
),
array( 'metric' => 'inventory_rows_pruned', 'value' => (string) ( $summary['inventory_rows_pruned'] ?? 0 ) ),
array( 'metric' => 'inventory_rows_planned', 'value' => (string) ( $summary['inventory_rows_planned'] ?? 0 ) ),
array( 'metric' => 'inventory_rows_skipped', 'value' => (string) ( $summary['inventory_rows_skipped'] ?? 0 ) ),
array(
'metric' => 'bytes_reclaimed',
'value' => $this->format_bytes( (int) ( $summary['bytes_reclaimed'] ?? 0 ) ),
Expand Down Expand Up @@ -2472,6 +2478,15 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign
* [--force]
* : (prune-missing) Allow pruning rows with unpushed commits or an open PR.
*
* [--limit=<count>]
* : (prune-missing) Maximum rows to inspect. Default 25, maximum 200.
*
* [--after-handle=<handle>]
* : (prune-missing) Last processed handle for bounded keyset continuation.
*
* [--until-budget=<duration>]
* : (prune-missing) Optional compact wall-clock budget such as 30s.
*
* [--format=<format>]
* : Output format.
* ---
Expand Down Expand Up @@ -2543,6 +2558,16 @@ private function inventory_refresh( array $assoc_args ): void {
private function inventory_prune_missing( array $assoc_args ): void {
$dry_run = ! empty($assoc_args['dry-run']);
$force = ! empty($assoc_args['force']);
$opts = array( 'dry_run' => $dry_run, 'force' => $force );
if ( isset($assoc_args['limit']) ) {
$opts['limit'] = (int) $assoc_args['limit'];
}
if ( isset($assoc_args['after-handle']) ) {
$opts['after_handle'] = (string) $assoc_args['after-handle'];
}
if ( isset($assoc_args['until-budget']) ) {
$opts['until_budget'] = (string) $assoc_args['until-budget'];
}

$ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-prune-missing');
if ( ! $ability ) {
Expand All @@ -2552,7 +2577,7 @@ private function inventory_prune_missing( array $assoc_args ): void {

// A dry-run preview never mutates, so it does not need confirmation.
if ( ! $dry_run && empty($assoc_args['yes']) ) {
$preview = $ability->execute(array( 'dry_run' => true ));
$preview = $ability->execute(array_merge($opts, array( 'dry_run' => true )));
if ( is_wp_error($preview) ) {
WP_CLI::error($preview->get_error_message());
return;
Expand All @@ -2569,12 +2594,7 @@ private function inventory_prune_missing( array $assoc_args ): void {
WP_CLI::confirm(sprintf('Prune %d missing_path inventory row(s) (%d skipped)? Pass --yes to skip this prompt.', $would_delete, $would_skip));
}

$result = $ability->execute(
array(
'dry_run' => $dry_run,
'force' => $force,
)
);
$result = $ability->execute($opts);
if ( is_wp_error($result) ) {
WP_CLI::error($result->get_error_message());
return;
Expand Down
174 changes: 161 additions & 13 deletions inc/Storage/WorktreeInventoryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,24 +258,55 @@ public function mark_missing( string $handle ): bool {
* STILL absent (a stale missing_path flag alone is not trusted).
* - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url
* unless 'force' is true; such rows are reported as skipped.
* - Preserves rows with creator/owner provenance and malformed paths because
* their absent local path is not sufficient ownership evidence.
*
* @param array{dry_run?: bool, force?: bool} $opts Options.
* @param array{dry_run?: bool, force?: bool, limit?: int, after_handle?: string, until_budget?: string, lock_callback?: callable, workspace_root?: string} $opts Options.
* @return array<string,mixed> Result with deleted/skipped lists and summary.
*/
public function pruneMissing( array $opts = array() ): array {
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);

$rows = $this->missing_path_rows();
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);
$limit = isset($opts['limit']) ? max(1, min(200, (int) $opts['limit'])) : 25;
$after_handle = isset($opts['after_handle']) ? trim( (string) $opts['after_handle'] ) : '';
$deadline = $this->prune_deadline($opts['until_budget'] ?? null);

$rows = $this->missing_path_rows($limit + 1, $after_handle);
$has_more = count($rows) > $limit;
$rows = array_slice($rows, 0, $limit);
$deleted = array();
$skipped = array();
$last_handle = $after_handle;

foreach ( $rows as $row ) {
if ( null !== $deadline && microtime(true) >= $deadline ) {
$has_more = true;
break;
}
$handle = (string) ( $row['handle'] ?? '' );
$path = (string) ( $row['path'] ?? '' );
$last_handle = $handle;
$path = trim( (string) ( $row['path'] ?? '' ) );

if ( ! $this->is_prunable_path($path, $opts['workspace_root'] ?? null) ) {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'invalid_path',
);
continue;
}

if ( $this->has_owner_managed_provenance($row) ) {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'owner_managed',
);
continue;
}

// Re-probe the disk: only reap when the path is STILL absent.
if ( '' !== $path && is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
if ( is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
$skipped[] = array(
'handle' => $handle,
'path' => $path,
Expand Down Expand Up @@ -319,7 +350,42 @@ public function pruneMissing( array $opts = array() ): array {
continue;
}

if ( $this->delete($handle) ) {
$mutation = function () use ( $handle, $path, $opts, $force ): array {
$current = $this->get($handle);
if ( ! is_array($current) || ! $this->is_prunable_path($path, $opts['workspace_root'] ?? null) ) {
return array( 'deleted' => false, 'reason' => 'conditional_delete_mismatch' );
}
if ( $this->has_owner_managed_provenance($current) ) {
return array( 'deleted' => false, 'reason' => 'owner_managed' );
}
if ( ! $force && (int) ( $current['unpushed_count'] ?? 0 ) > 0 ) {
return array( 'deleted' => false, 'reason' => 'unpushed_count' );
}
if ( ! $force && '' !== trim( (string) ( $current['pr_url'] ?? '' ) ) ) {
return array( 'deleted' => false, 'reason' => 'pr_url' );
}
// Recheck while holding the lifecycle mutation lock before conditional deletion.
if ( is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
return array( 'deleted' => false, 'reason' => 'path_present_on_disk' );
}

return $this->delete_missing_if_current($handle, $path, $force)
? array( 'deleted' => true )
: array( 'deleted' => false, 'reason' => 'conditional_delete_mismatch' );
};
$mutation_result = isset($opts['lock_callback'])
? $opts['lock_callback']($row, $mutation)
: $mutation();
if ( $mutation_result instanceof \WP_Error ) {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'workspace_lock_unavailable',
);
continue;
}

if ( ! empty($mutation_result['deleted']) ) {
$deleted[] = array(
'handle' => $handle,
'path' => $path,
Expand All @@ -329,12 +395,13 @@ public function pruneMissing( array $opts = array() ): array {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'delete_failed',
'reason' => is_array($mutation_result) ? (string) ( $mutation_result['reason'] ?? 'delete_failed' ) : 'delete_failed',
);
}
}

return array(
$processed = count($deleted) + count($skipped);
$result = array(
'success' => true,
'pruned_at' => gmdate('c'),
'dry_run' => $dry_run,
Expand All @@ -343,17 +410,27 @@ public function pruneMissing( array $opts = array() ): array {
'summary' => array(
'deleted' => count($deleted),
'skipped' => count($skipped),
'total' => count($rows),
'total' => $processed,
'limit' => $limit,
'after_handle' => $after_handle,
),
);
if ( $has_more ) {
$result['continuation'] = array(
'reason' => null !== $deadline && microtime(true) >= $deadline ? 'budget_exhausted' : 'limit_reached',
'next_after_handle' => $last_handle,
);
}

return $result;
}

/**
* Fetch all rows currently flagged missing_path.
*
* @return array<int,array<string,mixed>>
*/
private function missing_path_rows(): array {
private function missing_path_rows( int $limit, string $after_handle ): array {
global $wpdb;

if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) {
Expand All @@ -362,14 +439,85 @@ private function missing_path_rows(): array {

$table = self::table_name();
// phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
$sql = "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC";
$sql = '' === $after_handle
? "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC LIMIT " . (int) $limit
: $wpdb->prepare("SELECT * FROM {$table} WHERE missing_path = 1 AND handle > %s ORDER BY handle ASC LIMIT " . (int) $limit, $after_handle);
// phpcs:enable WordPress.DB.PreparedSQL

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input.
$rows = $wpdb->get_results($sql, ARRAY_A);
return is_array($rows) ? array_map(array( $this, 'decode_row' ), $rows) : array();
}

private function is_prunable_path( string $path, mixed $workspace_root ): bool {
if ( '' === $path || ! str_starts_with($path, '/') || str_contains($path, "\0") || ! is_string($workspace_root) || '' === trim($workspace_root) ) {
return false;
}
$root = realpath($workspace_root);
$parent = realpath(dirname($path));
return false !== $root && false !== $parent && str_starts_with($parent . '/', rtrim($root, '/') . '/');
}

/** @param array<string,mixed> $row */
private function has_owner_managed_provenance( array $row ): bool {
$metadata = is_array($row['metadata'] ?? null) ? $row['metadata'] : array();
foreach ( array( 'origin_site', 'origin_agent', 'origin_session', 'owner_run_ref', 'cleanup_policy', 'task_url', 'task_ref' ) as $field ) {
if ( '' !== trim( (string) ( $row[ $field ] ?? $metadata[ $field ] ?? '' ) ) ) {
return true;
}
}

return false;
}

private function delete_missing_if_current( string $handle, string $path, bool $force ): bool {
global $wpdb;
$this->last_error = null;
if ( ! isset($wpdb) || ! method_exists($wpdb, 'query') || ! method_exists($wpdb, 'prepare') ) {
return false;
}

$table = self::table_name();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derives from $wpdb->prefix; values are prepared.
$sql = "DELETE FROM {$table} WHERE handle = %s AND path = %s AND missing_path = 1 AND last_probe_status = %s"
. " AND TRIM(COALESCE(origin_site, '')) = ''"
. " AND TRIM(COALESCE(origin_agent, '')) = ''"
. " AND TRIM(COALESCE(origin_session, '')) = ''"
. " AND TRIM(COALESCE(owner_run_ref, '')) = ''"
. " AND TRIM(COALESCE(cleanup_policy, '')) = ''"
. " AND TRIM(COALESCE(task_url, '')) = ''"
. " AND TRIM(COALESCE(task_ref, '')) = ''";
if ( ! $force ) {
$sql .= " AND COALESCE(unpushed_count, 0) <= 0 AND TRIM(COALESCE(pr_url, '')) = ''";
}
$sql = $wpdb->prepare($sql, $handle, $path, 'missing_path');
$result = SqliteBusyRetry::run('worktree_inventory_delete_missing_if_current', fn() => $wpdb->query($sql));
if ( $result instanceof \WP_Error ) {
$this->last_error = $result;
return false;
}

return 1 === (int) $result;
}

private function prune_deadline( mixed $duration ): ?float {
$duration = trim( (string) $duration );
if ( '' === $duration || ! preg_match('/^(\d+)([smh])$/', $duration, $matches) ) {
return null;
}
$seconds = (int) $matches[1];
if ( $seconds < 1 ) {
return null;
}

$seconds *= match ( $matches[2] ) {
'h' => 3600,
'm' => 60,
default => 1,
};
return microtime(true) + $seconds;
}

/**
* Fetch all rows, optionally filtered by repo.
*
Expand Down
Loading