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
21 changes: 12 additions & 9 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1695,15 +1695,18 @@ private function registerAbilities(): void {
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'mode' => array( 'type' => 'string' ),
'applied' => array( 'type' => 'boolean' ),
'destructive' => array( 'type' => 'boolean' ),
'summary' => array( 'type' => 'object' ),
'blockers' => array( 'type' => 'array' ),
'evidence' => array( 'type' => 'object' ),
'steps' => array( 'type' => 'object' ),
'state' => array( 'type' => 'string' ),
'success' => array( 'type' => 'boolean' ),
'mode' => array( 'type' => 'string' ),
'run_id' => array( 'type' => 'string' ),
'applied' => array( 'type' => 'boolean' ),
'destructive' => array( 'type' => 'boolean' ),
'summary' => array( 'type' => 'object' ),
'blockers' => array( 'type' => 'array' ),
'evidence' => array( 'type' => 'object' ),
'steps' => array( 'type' => 'object' ),
'commands' => array( 'type' => 'object' ),
'continuation' => array( 'type' => 'object' ),
'state' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( self::class, 'workspaceCleanupSafe' ),
Expand Down
6 changes: 6 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,12 @@ private function run_cleanup_safe( array $assoc_args ): void {
if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) {
$input['until_budget'] = trim( (string) $assoc_args['until-budget']);
}
if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
$input['progress_callback'] = function ( array $progress ) use ( $assoc_args ): void {
$this->render_cleanup_safe_result($progress, $assoc_args);
$this->flush_cli_output();
};
}

$ability = wp_get_ability('datamachine-code/workspace-cleanup-safe');
if ( ! $ability ) {
Expand Down
8 changes: 5 additions & 3 deletions inc/Storage/CleanupRunRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
require_once dirname(__DIR__) . '/Support/JsonCodec.php';
}

class CleanupRunRepository {

if ( ! interface_exists(CleanupRunRepositoryInterface::class) ) {
require_once __DIR__ . '/CleanupRunRepositoryInterface.php';
}

class CleanupRunRepository implements CleanupRunRepositoryInterface {

/**
* Create a cleanup run.
Expand Down Expand Up @@ -60,7 +62,7 @@ public function create_run( array $run ): string|\WP_Error {
* Insert planned cleanup items.
*
* @param string $run_id Run ID.
* @param array<int,array> $items Items.
* @param array<int,mixed> $items Items.
* @return int|\WP_Error
*/
public function add_items( string $run_id, array $items ): int|\WP_Error {
Expand Down
29 changes: 29 additions & 0 deletions inc/Storage/CleanupRunRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
/**
* Cleanup run repository contract.
*
* @package DataMachineCode\Storage
*/

namespace DataMachineCode\Storage;

defined('ABSPATH') || exit;

interface CleanupRunRepositoryInterface {

/**
* Create a cleanup run.
*
* @param array<string,mixed> $run Run fields.
* @return string|\WP_Error
*/
public function create_run( array $run ): string|\WP_Error;

/**
* Update a cleanup run.
*
* @param string $run_id Run ID.
* @param array<string,mixed> $fields Run fields.
*/
public function update_run( string $run_id, array $fields ): bool;
}
20 changes: 18 additions & 2 deletions inc/Workspace/CleanupRunService.php
Original file line number Diff line number Diff line change
Expand Up @@ -626,9 +626,10 @@ private function run_progress( array $run, array $items, array $summary ): array
'applying_rows' => count($applying),
'applying_examples' => $examples,
'pending_or_failed' => (int) ( $summary['pending_or_failed'] ?? 0 ),
'safe_cleanup' => $run['summary']['safe_cleanup_progress'] ?? null,
'started_at' => $started_at,
'age_seconds' => $age,
'resumable' => $resumable,
'resumable' => $resumable || ( 'safe_workspace_cleanup' === (string) ( $run['mode'] ?? '' ) && 'applying' === $run_status ),
'note' => count($applying) > 0 ? 'Rows marked applying are safe to retry with workspace cleanup resume if the previous apply process was interrupted.' : '',
);
}
Expand All @@ -642,7 +643,22 @@ private function run_progress( array $run, array $items, array $summary ): array
* @return array<string,mixed>
*/
private function remaining_work_summary( string $run_id, array $items, array $progress ): array {
$summary = CleanupRemainingWorkSummary::from_items($items);
$summary = CleanupRemainingWorkSummary::from_items($items);
$safe_cleanup = is_array($progress['safe_cleanup'] ?? null) ? (array) $progress['safe_cleanup'] : array();
$safe_commands = is_array($safe_cleanup['commands'] ?? null) ? (array) $safe_cleanup['commands'] : array();
if ( isset($safe_commands['status'], $safe_commands['resume']) ) {
$resume_command = array(
'bucket' => 'safe_cleanup_continuation',
'command' => (string) $safe_commands['status'],
'apply' => (string) $safe_commands['resume'],
'destructive' => false,
'apply_destructive' => true,
'why' => 'Inspect or continue the DMC safe cleanup run from durable progress evidence.',
);
array_unshift($summary['recommended_commands'], $resume_command);
array_unshift($summary['next_commands'], (string) $resume_command['command'], (string) $resume_command['apply']);
$summary['next_commands'] = array_values(array_unique($summary['next_commands']));
}
if ( ! empty($progress['resumable']) ) {
$resume_command = array(
'bucket' => 'current_run_resume',
Expand Down
163 changes: 145 additions & 18 deletions inc/Workspace/WorkspaceSafeCleanupOrchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ class WorkspaceSafeCleanupOrchestrator {
/** @var callable */
private $lock_pruner;

/** @var \DataMachineCode\Storage\CleanupRunRepositoryInterface|null */
private $run_repository;

/**
* @param callable|null $ability_resolver Resolver receiving an ability name.
* @param callable|null $lock_pruner Callback receiving a dry-run bool.
* @param \DataMachineCode\Storage\CleanupRunRepositoryInterface|null $run_repository Cleanup run repository override for tests.
*/
public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null ) {
public function __construct( ?callable $ability_resolver = null, ?callable $lock_pruner = null, ?\DataMachineCode\Storage\CleanupRunRepositoryInterface $run_repository = null ) {
$this->ability_resolver = $ability_resolver ? $ability_resolver : static fn( string $name ) => function_exists('wp_get_ability') ? wp_get_ability($name) : null;
$this->lock_pruner = $lock_pruner ? $lock_pruner : array( $this, 'prune_locks' );
$this->run_repository = $run_repository;
}

/**
Expand All @@ -45,11 +50,12 @@ public function run( array $input ): array|\WP_Error {
return new \WP_Error('safe_cleanup_refuses_unpushed_discard', 'Safe workspace cleanup refuses unpushed commit discard. Unpushed worktrees remain blockers.', array( 'status' => 400 ));
}

$dry_run = ! empty($input['dry_run']);
$limit = isset($input['limit']) ? max(1, min(200, (int) $input['limit'])) : 25;
$passes = isset($input['passes']) ? max(1, min(100, (int) $input['passes'])) : 10;
$cycles = isset($input['cycles']) ? max(1, min(25, (int) $input['cycles'])) : 5;
$source = isset($input['source']) && '' !== trim( (string) $input['source']) ? trim( (string) $input['source']) : self::DEFAULT_SOURCE;
$dry_run = ! empty($input['dry_run']);
$limit = isset($input['limit']) ? max(1, min(200, (int) $input['limit'])) : 25;
$passes = isset($input['passes']) ? max(1, min(100, (int) $input['passes'])) : 10;
$cycles = isset($input['cycles']) ? max(1, min(25, (int) $input['cycles'])) : 5;
$source = isset($input['source']) && '' !== trim( (string) $input['source']) ? trim( (string) $input['source']) : self::DEFAULT_SOURCE;
$progress_callback = isset($input['progress_callback']) && is_callable($input['progress_callback']) ? $input['progress_callback'] : null;

$cleanup_eligible = $this->resolve_ability('datamachine-code/workspace-worktree-cleanup-eligible-drain');
if ( is_wp_error($cleanup_eligible) ) {
Expand Down Expand Up @@ -88,12 +94,31 @@ public function run( array $input ): array|\WP_Error {
),
);

$run_id = $this->create_progress_run($result, $dry_run, $limit, $passes, $cycles, $source);
if ( $run_id instanceof \WP_Error ) {
return $run_id;
}
$result['run_id'] = $run_id;
$result['commands'] = $this->progress_commands($run_id, $dry_run, $limit, $passes, $cycles, $input);
$result['continuation'] = array(
'run_id' => $run_id,
'status_command' => $result['commands']['status'],
'evidence_command' => $result['commands']['evidence'],
'resume_command' => $result['commands']['resume'],
'note' => 'If the client disconnects, inspect this run_id and rerun the resume command. Safe cleanup remains bounded and preserves dirty/unpushed blockers.',
);
$this->checkpoint_progress($run_id, $result, 'applying');
if ( null !== $progress_callback ) {
$progress_callback($this->early_progress_result($result));
}

$lock_start = ( $this->lock_pruner )($dry_run);
if ( is_wp_error($lock_start) ) {
return $lock_start;
}
$result['steps']['lock_prune_start'] = $this->summarize_lock_step($lock_start);
$result['steps']['lock_prune_start'] = $this->summarize_lock_step($lock_start);
$result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_start']['removed_count'] ?? 0 );
$this->checkpoint_progress($run_id, $result, 'applying');

$common = array(
'apply' => ! $dry_run,
Expand All @@ -109,21 +134,23 @@ public function run( array $input ): array|\WP_Error {

for ( $cycle = 1; $cycle <= $cycles; ++$cycle ) {
$result['summary']['cycles'] = $cycle;
$cycle_progress = 0;
$cycle_progress = 0;

$eligible = $this->execute_ability($cleanup_eligible, $common);
if ( is_wp_error($eligible) ) {
return $eligible;
}
$result['steps'][ 'cleanup_eligible_' . $cycle ] = $this->summarize_cleanup_step($eligible);
$cycle_progress += $this->accumulate_cleanup_step($result, $eligible);
$cycle_progress += $this->accumulate_cleanup_step($result, $eligible);
$this->checkpoint_progress($run_id, $result, 'applying');

$active = $this->execute_ability($active_no_signal, $common);
if ( is_wp_error($active) ) {
return $active;
}
$result['steps'][ 'active_no_signal_' . $cycle ] = $this->summarize_cleanup_step($active);
$cycle_progress += $this->accumulate_cleanup_step($result, $active);
$cycle_progress += $this->accumulate_cleanup_step($result, $active);
$this->checkpoint_progress($run_id, $result, 'applying');

if ( $dry_run || 0 === $cycle_progress ) {
break;
Expand All @@ -134,21 +161,117 @@ public function run( array $input ): array|\WP_Error {
if ( is_wp_error($lock_end) ) {
return $lock_end;
}
$result['steps']['lock_prune_end'] = $this->summarize_lock_step($lock_end);
$result['steps']['lock_prune_end'] = $this->summarize_lock_step($lock_end);
$result['summary']['lock_files_removed'] += (int) ( $result['steps']['lock_prune_end']['removed_count'] ?? 0 );
$this->checkpoint_progress($run_id, $result, 'applying');

$result['blockers'] = $this->compact_blockers($result['blockers']);
$result['summary']['blocker_count'] = array_sum(array_map(static fn( array $row ): int => (int) ( $row['count'] ?? 0 ), $result['blockers']));
$result['blockers'] = $this->compact_blockers($result['blockers']);
$result['summary']['blocker_count'] = array_sum(array_map(static fn( array $row ): int => (int) ( $row['count'] ?? 0 ), $result['blockers']));
$result['summary']['blockers_by_reason'] = array_column($result['blockers'], 'count', 'reason_code');
if ( ! $dry_run && $result['summary']['blocker_count'] > 0 ) {
$result['state'] = 'complete_with_blockers';
} else {
$result['state'] = 'complete';
}
$this->checkpoint_progress($run_id, $result, $result['state']);

return $result;
}

/** @return string|\WP_Error */
private function create_progress_run( array $result, bool $dry_run, int $limit, int $passes, int $cycles, string $source ): string|\WP_Error {
$repository = $this->progress_repository();
$run_id = $repository->create_run(
array(
'mode' => 'safe_workspace_cleanup',
'status' => 'applying',
'started_at' => gmdate('Y-m-d H:i:s'),
'policy' => array(
'dry_run' => $dry_run,
'force' => false,
'discard_unpushed' => false,
'limit' => $limit,
'passes' => $passes,
'cycles' => $cycles,
'source' => $source,
),
'summary' => $this->progress_summary($result),
)
);

return $run_id;
}

private function checkpoint_progress( string $run_id, array $result, string $status ): void {
$repository = $this->progress_repository();
$fields = array(
'status' => $status,
'summary' => $this->progress_summary($result),
);
if ( in_array($status, array( 'complete', 'complete_with_blockers' ), true) ) {
$fields['completed_at'] = gmdate('Y-m-d H:i:s');
}
$repository->update_run($run_id, $fields);
}

private function progress_repository(): \DataMachineCode\Storage\CleanupRunRepositoryInterface {
if ( null === $this->run_repository ) {
$this->run_repository = new \DataMachineCode\Storage\CleanupRunRepository();
}

return $this->run_repository;
}

/** @return array<string,mixed> */
private function progress_summary( array $result ): array {
return array(
'safe_cleanup_progress' => array(
'generated_at' => gmdate('c'),
'state' => (string) ( $result['state'] ?? 'applying' ),
'applied' => (bool) ( $result['applied'] ?? false ),
'destructive' => (bool) ( $result['destructive'] ?? false ),
'summary' => (array) ( $result['summary'] ?? array() ),
'blockers' => (array) ( $result['blockers'] ?? array() ),
'steps' => (array) ( $result['steps'] ?? array() ),
'commands' => (array) ( $result['commands'] ?? array() ),
'continuation' => (array) ( $result['continuation'] ?? array() ),
),
);
}

/** @return array<string,string> */
private function progress_commands( string $run_id, bool $dry_run, int $limit, int $passes, int $cycles, array $input ): array {
$resume = sprintf('studio wp datamachine-code workspace cleanup safe --limit=%d --passes=%d --cycles=%d', $limit, $passes, $cycles);
if ( $dry_run ) {
$resume .= ' --dry-run';
}
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
$resume .= ' --until-budget=' . trim( (string) $input['until_budget']);
}

return array(
'status' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id),
'evidence' => sprintf('studio wp datamachine-code workspace cleanup evidence %s --format=json', $run_id),
'resume' => $resume . ' --format=json',
);
}

/** @return array<string,mixed> */
private function early_progress_result( array $result ): array {
return array(
'success' => true,
'mode' => (string) ( $result['mode'] ?? 'safe_workspace_cleanup' ),
'state' => 'applying',
'run_id' => (string) ( $result['run_id'] ?? '' ),
'applied' => (bool) ( $result['applied'] ?? false ),
'destructive' => (bool) ( $result['destructive'] ?? false ),
'generated_at' => gmdate('c'),
'summary' => (array) ( $result['summary'] ?? array() ),
'commands' => (array) ( $result['commands'] ?? array() ),
'continuation' => (array) ( $result['continuation'] ?? array() ),
);
}

private function resolve_ability( string $name ): mixed {
$ability = ( $this->ability_resolver )($name);
if ( ! is_object($ability) || ! is_callable(array( $ability, 'execute' )) ) {
Expand All @@ -159,11 +282,15 @@ private function resolve_ability( string $name ): mixed {
}

private function execute_ability( object $ability, array $input ): array|\WP_Error {
$result = $ability->execute($input);
$executor = array( $ability, 'execute' );
if ( ! is_callable($executor) ) {
return new \WP_Error('safe_cleanup_ability_missing', 'Safe cleanup ability is not executable.', array( 'status' => 500 ));
}
$result = $executor($input);
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 ));
}

private function prune_locks( bool $dry_run ): array|\WP_Error {
private function prune_locks( bool $dry_run ): array {
$workspace = new Workspace();
return WorkspaceMutationLock::prune_stale($workspace->get_path(), $dry_run);
}
Expand Down Expand Up @@ -202,7 +329,7 @@ private function accumulate_cleanup_step( array &$result, array $step ): int {

/** @return array<string,int> */
private function extract_blocker_counts( array $step ): array {
$counts = array();
$counts = array();
$summary = (array) ( $step['summary'] ?? array() );
foreach ( (array) ( $summary['blocked_by_reason'] ?? $summary['skipped_by_reason'] ?? array() ) as $reason => $count ) {
$counts[ (string) $reason ] = (int) $count;
Expand Down Expand Up @@ -239,8 +366,8 @@ private function summarize_lock_step( array $step ): array {
private function compact_blockers( array $rows ): array {
$blockers = array();
foreach ( $rows as $row ) {
$reason = (string) ( $row['reason_code'] ?? 'unknown' );
$blockers[ $reason ] ??= array(
$reason = (string) ( $row['reason_code'] ?? 'unknown' );
$blockers[ $reason ] ??= array(
'reason_code' => $reason,
'count' => 0,
);
Expand Down
Loading
Loading