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
253 changes: 253 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,10 @@ public function adopt_repo( array $args, array $assoc_args ): void {
* [--verbose]
* : Include full diagnostic child job ID lists in task-backed cleanup status output.
*
* [--summary]
* : For `status` and `evidence`, print compact operator-focused cleanup counts,
* blockers, examples, and next commands instead of full nested evidence.
*
* [--drain]
* : For `cleanup run`, drain the queued parent job, drain active child cleanup
* jobs discovered from cleanup status, then print verified bytes reclaimed.
Expand Down Expand Up @@ -695,6 +699,9 @@ public function adopt_repo( array $args, array $assoc_args ): void {
* # Print recorded evidence / engine data
* wp datamachine-code workspace cleanup evidence cleanup-run-123 --format=json
*
* # Print compact evidence summary for chat/operator follow-up
* wp datamachine-code workspace cleanup evidence cleanup-run-123 --summary
*
* @subcommand cleanup
*/
public function cleanup( array $args, array $assoc_args ): void {
Expand Down Expand Up @@ -1276,6 +1283,9 @@ private function cleanup_run_control_job_ids( string $operation, int $job_id ):
private function render_cleanup_control_result( array $result, array $assoc_args ): void {
$result = $this->attach_current_workspace_lock_status($result);
$format = (string) ( $assoc_args['format'] ?? 'table' );
if ( ! empty($assoc_args['summary']) ) {
$result = $this->build_cleanup_operator_summary($result);
}
if ( 'json' === $format ) {
WP_CLI::log( (string) wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return;
Expand All @@ -1291,6 +1301,10 @@ private function render_cleanup_control_result( array $result, array $assoc_args
WP_CLI::log(sprintf('%s: %s', ucfirst(str_replace('_', ' ', $key)), (string) $result[ $key ]));
}
}
if ( ! empty($assoc_args['summary']) ) {
$this->render_cleanup_operator_summary($result);
return;
}
if ( ! empty($result['progress']) && is_array($result['progress']) ) {
$this->render_cleanup_progress_summary( (array) $result['progress']);
}
Expand All @@ -1311,6 +1325,245 @@ private function render_cleanup_control_result( array $result, array $assoc_args
}
}

/**
* Render compact cleanup status/evidence summary tables.
*
* @param array<string,mixed> $summary Compact cleanup summary.
* @return void
*/
private function render_cleanup_operator_summary( array $summary ): void {
WP_CLI::log('');
WP_CLI::log('Cleanup operator summary:');
$cleanup_counts = (array) ( $summary['cleanup_counts'] ?? array() );
$artifacts = (array) ( $summary['artifact_cleanup'] ?? array() );
$this->format_items(
array(
array(
'metric' => 'planned_rows',
'value' => (int) ( $cleanup_counts['planned'] ?? 0 ),
),
array(
'metric' => 'applied_rows',
'value' => (int) ( $cleanup_counts['applied'] ?? 0 ),
),
array(
'metric' => 'skipped_rows',
'value' => (int) ( $cleanup_counts['skipped'] ?? 0 ),
),
array(
'metric' => 'failed_rows',
'value' => (int) ( $cleanup_counts['failed'] ?? 0 ),
),
array(
'metric' => 'bytes_reclaimed',
'value' => $this->format_bytes($cleanup_counts['bytes_reclaimed'] ?? 0),
),
array(
'metric' => 'remaining_reclaimable_artifacts',
'value' => $this->format_bytes($artifacts['remaining_reclaimable_artifact_bytes'] ?? 0),
),
),
array( 'metric', 'value' ),
array( 'format' => 'table' ),
'metric'
);

$this->render_cleanup_summary_reason_rows('Skipped rows by reason:', (array) ( $summary['skipped_by_reason'] ?? array() ));
$this->render_cleanup_summary_reason_rows('Failed rows by reason:', (array) ( $summary['failed_by_reason'] ?? array() ));

$examples = (array) ( $summary['top_blocked_examples'] ?? array() );
if ( array() !== $examples ) {
WP_CLI::log('');
WP_CLI::log('Top blocked examples:');
$rows = array_map(
fn( $row ) => array(
'size' => $this->format_bytes(is_array($row) ? ( $row['size_bytes'] ?? 0 ) : 0),
'reason' => is_array($row) ? (string) ( $row['reason'] ?? '' ) : '',
'handle' => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '',
'artifact_path' => is_array($row) ? (string) ( $row['artifact_path'] ?? '' ) : '',
'path' => is_array($row) ? (string) ( $row['path'] ?? '' ) : '',
),
array_slice($examples, 0, 10)
);
$this->format_items($rows, array( 'size', 'reason', 'handle', 'artifact_path', 'path' ), array( 'format' => 'table' ), 'size');
}

$commands = (array) ( $summary['recommended_commands'] ?? array() );
if ( array() !== $commands ) {
WP_CLI::log('');
WP_CLI::log('Recommended next commands:');
$rows = array_map(
fn( $row ) => array(
'bucket' => is_array($row) ? (string) ( $row['bucket'] ?? '' ) : '',
'review_command' => is_array($row) ? (string) ( $row['command'] ?? '' ) : '',
'apply_command' => is_array($row) ? (string) ( $row['apply'] ?? '' ) : '',
'apply_destructive' => is_array($row) && ! empty($row['apply_destructive']) ? 'yes' : 'no',
),
array_slice($commands, 0, 10)
);
$this->format_items($rows, array( 'bucket', 'review_command', 'apply_command', 'apply_destructive' ), array( 'format' => 'table' ), 'bucket');
}
}

/**
* Build compact cleanup status/evidence output for chat/operator workflows.
*
* @param array<string,mixed> $result Cleanup status/evidence result.
* @return array<string,mixed>
*/
private function build_cleanup_operator_summary( array $result ): array {
$cleanup_items = (array) ( $result['cleanup_items'] ?? $result['evidence']['cleanup_items'] ?? array() );
$artifacts = (array) ( $result['artifact_cleanup'] ?? $result['evidence']['artifact_cleanup'] ?? array() );
$remaining = (array) ( $result['remaining_work_summary'] ?? array() );

return array_filter(
array(
'success' => (bool) ( $result['success'] ?? false ),
'run_id' => (string) ( $result['run_id'] ?? '' ),
'job_id' => isset($result['job_id']) ? (int) $result['job_id'] : null,
'mode' => (string) ( $result['mode'] ?? $result['evidence']['engine_data']['cleanup_run']['mode'] ?? '' ),
'state' => (string) ( $result['state'] ?? '' ),
'status' => (string) ( $result['status'] ?? '' ),
'parent_status' => (string) ( $result['parent_status'] ?? '' ),
'created_at' => (string) ( $result['created_at'] ?? '' ),
'completed_at' => (string) ( $result['completed_at'] ?? $result['parent_completed_at'] ?? '' ),
'cleanup_counts' => array(
'planned' => (int) ( $cleanup_items['planned_rows'] ?? 0 ),
'applied' => (int) ( $cleanup_items['applied_rows'] ?? 0 ),
'skipped' => (int) ( $cleanup_items['skipped_rows'] ?? 0 ),
'failed' => (int) ( $cleanup_items['failed_rows'] ?? 0 ),
'bytes_reclaimed' => (int) ( $cleanup_items['bytes_reclaimed'] ?? 0 ),
'freed_human' => (string) ( $cleanup_items['freed_human'] ?? $this->format_bytes($cleanup_items['bytes_reclaimed'] ?? 0) ),
),
'artifact_cleanup' => array(
'planned' => (int) ( $artifacts['planned_rows'] ?? 0 ),
'applied' => (int) ( $artifacts['applied_rows'] ?? 0 ),
'skipped' => (int) ( $artifacts['skipped_rows'] ?? 0 ),
'failed' => (int) ( $artifacts['failed_rows'] ?? 0 ),
'bytes_reclaimed' => (int) ( $artifacts['bytes_reclaimed'] ?? 0 ),
'remaining_reclaimable_artifact_bytes' => (int) ( $remaining['remaining_reclaimable_artifact_bytes'] ?? $artifacts['remaining_reclaimable_artifact_bytes'] ?? 0 ),
'remaining_reclaimable_human' => $this->format_bytes($remaining['remaining_reclaimable_artifact_bytes'] ?? $artifacts['remaining_reclaimable_artifact_bytes'] ?? 0),
),
'children' => $this->build_cleanup_operator_child_summary( (array) ( $result['children'] ?? $result['evidence']['children'] ?? array() ) ),
'by_type' => (array) ( $cleanup_items['by_type'] ?? array() ),
'skipped_by_reason' => (array) ( $remaining['skipped_by_reason'] ?? $cleanup_items['skipped_examples_by_reason'] ?? array() ),
'failed_by_reason' => (array) ( $cleanup_items['failed_by_reason'] ?? $artifacts['failed_by_reason'] ?? array() ),
'top_blocked_examples' => $this->cleanup_operator_blocked_examples($result),
'recommended_commands' => (array) ( $remaining['recommended_commands'] ?? array() ),
'locks' => (array) ( $result['locks'] ?? array() ),
),
fn( $value ) => null !== $value && array() !== $value && '' !== $value
);
}

/**
* Summarize child cleanup jobs without unbounded ID lists.
*
* @param array<string,mixed> $children Child job aggregate.
* @return array<string,mixed>
*/
private function build_cleanup_operator_child_summary( array $children ): array {
return array(
'total' => (int) ( $children['total'] ?? 0 ),
'running' => (int) ( $children['running'] ?? 0 ),
'completed' => (int) ( $children['completed'] ?? 0 ),
'failed' => (int) ( $children['failed'] ?? 0 ),
'skipped' => (int) ( $children['skipped'] ?? 0 ),
'statuses' => (array) ( $children['statuses'] ?? array() ),
'batch_jobs' => isset($children['batch_total']) ? (int) $children['batch_total'] : count( (array) ( $children['batch_job_ids'] ?? array() ) ),
'chunk_jobs' => isset($children['chunk_total']) ? (int) $children['chunk_total'] : count( (array) ( $children['chunk_job_ids'] ?? array() ) ),
);
}

/**
* Extract largest blocked cleanup examples from compact summaries and full evidence when available.
*
* @param array<string,mixed> $result Cleanup status/evidence result.
* @return array<int,array<string,mixed>>
*/
private function cleanup_operator_blocked_examples( array $result ): array {
$examples = array();
foreach ( (array) ( $result['remaining_work_summary']['skipped_by_reason'] ?? array() ) as $reason => $bucket ) {
foreach ( (array) ( is_array($bucket) ? ( $bucket['examples'] ?? array() ) : array() ) as $row ) {
if ( is_array($row) ) {
$examples[] = $this->cleanup_operator_example_row($row, (string) $reason);
}
}
}

foreach ( (array) ( $result['evidence']['child_jobs'] ?? array() ) as $job ) {
$engine_data = (array) ( is_array($job) ? ( $job['engine_data'] ?? array() ) : array() );
foreach ( array( 'skipped', 'failed' ) as $bucket ) {
foreach ( (array) ( $engine_data[ $bucket ] ?? array() ) as $row ) {
if ( is_array($row) ) {
$examples[] = $this->cleanup_operator_example_row($row, (string) ( $row['reason_code'] ?? $bucket ));
}
}
}
}

usort($examples, fn( $a, $b ) => (int) ( $b['size_bytes'] ?? 0 ) <=> (int) ( $a['size_bytes'] ?? 0 ));
$seen = array();
$deduped = array_values(array_filter(
$examples,
function ( array $row ) use ( &$seen ): bool {
$key = (string) ( $row['handle'] ?? '' ) . '|' . (string) ( $row['reason'] ?? '' ) . '|' . (string) ( $row['path'] ?? '' );
if ( isset($seen[ $key ]) ) {
return false;
}
$seen[ $key ] = true;
return true;
}
));
return array_slice($deduped, 0, 10);
}

/**
* Normalize one blocked cleanup example row for compact output.
*
* @param array<string,mixed> $row Cleanup row.
* @param string $reason Fallback reason code.
* @return array<string,mixed>
*/
private function cleanup_operator_example_row( array $row, string $reason ): array {
$artifact_path = (string) ( $row['artifact_path'] ?? '' );
$artifacts = (array) ( $row['artifacts'] ?? array() );
if ( '' === $artifact_path && isset($artifacts[0]) && is_array($artifacts[0]) ) {
$artifact_path = (string) ( $artifacts[0]['path'] ?? '' );
}

return array_filter(
array(
'handle' => (string) ( $row['handle'] ?? '' ),
'reason' => (string) ( $row['reason_code'] ?? $row['reason'] ?? $reason ),
'path' => (string) ( $row['path'] ?? '' ),
'artifact_path' => $artifact_path,
'size_bytes' => $this->cleanup_operator_row_bytes($row),
'size' => $this->format_bytes($this->cleanup_operator_row_bytes($row)),
),
fn( $value ) => '' !== $value && 0 !== $value
);
}

/**
* Return best-known reclaimable bytes for one cleanup row.
*
* @param array<string,mixed> $row Cleanup row.
* @return int
*/
private function cleanup_operator_row_bytes( array $row ): int {
foreach ( array( 'artifact_size_bytes', 'size_bytes', 'bytes_reclaimed' ) as $field ) {
if ( isset($row[ $field ]) ) {
return max(0, (int) $row[ $field ]);
}
}
$total = 0;
foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) {
$total += max(0, (int) ( is_array($artifact) ? ( $artifact['size_bytes'] ?? 0 ) : 0 ));
}
return $total;
}

/**
* Attach live workspace lock status to cleanup triage surfaces when available.
*
Expand Down
26 changes: 25 additions & 1 deletion tests/smoke-worktree-cleanup-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -1225,7 +1225,8 @@ public function execute( array $input ): array
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'Control task-backed workspace cleanup runs.'), 'workspace cleanup command documents task-backed controller surface');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '<plan|apply|until-empty|run|status|resume|cancel|evidence>'), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--dry-run]'), 'task-backed cleanup synopsis keeps synchronous dry-run review');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--drain]'), 'task-backed cleanup synopsis exposes drain option');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--drain]'), 'task-backed cleanup synopsis exposes drain option');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--summary]'), 'task-backed cleanup synopsis exposes compact summary option');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '[--max-passes=<count>]'), 'workspace cleanup synopsis exposes until-empty pass budget');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'workspace cleanup until-empty --mode=artifacts'), 'workspace cleanup examples include artifact until-empty loop');
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'stale-worktrees'), 'workspace cleanup synopsis exposes stale-worktrees destructive profile');
Expand Down Expand Up @@ -1532,6 +1533,29 @@ public function execute( array $input ): array
datamachine_code_cleanup_assert(1 === (int) ( $evidence_json['evidence']['cleanup_items']['failed_by_reason']['apply_failed'] ?? 0 ), 'cleanup evidence reconstructs failed cleanup item reasons from job rows');
datamachine_code_cleanup_assert(4 === count($evidence_json['evidence']['child_jobs'] ?? array()), 'cleanup evidence emits descendant child jobs');

WP_CLI::$logs = array();
WP_CLI::$successes = array();
$command->cleanup(array( 'evidence', 'cleanup-run-123' ), array( 'summary' => true, 'format' => 'json' ));
$summary_json = json_decode(WP_CLI::$logs[0] ?? '', true);
datamachine_code_cleanup_assert(! isset($summary_json['evidence']), 'cleanup evidence --summary omits full nested evidence');
datamachine_code_cleanup_assert('cleanup-run-123' === ( $summary_json['run_id'] ?? '' ), 'cleanup evidence --summary keeps run id');
datamachine_code_cleanup_assert(4 === (int) ( $summary_json['cleanup_counts']['planned'] ?? 0 ), 'cleanup evidence --summary reports planned cleanup rows');
datamachine_code_cleanup_assert(4096 === (int) ( $summary_json['cleanup_counts']['bytes_reclaimed'] ?? 0 ), 'cleanup evidence --summary reports reclaimed bytes');
datamachine_code_cleanup_assert(10240 === (int) ( $summary_json['artifact_cleanup']['remaining_reclaimable_artifact_bytes'] ?? 0 ), 'cleanup evidence --summary reports remaining reclaimable artifact bytes');
datamachine_code_cleanup_assert(1 === (int) ( $summary_json['skipped_by_reason']['dirty_worktree']['count'] ?? 0 ), 'cleanup evidence --summary groups skipped reasons');
datamachine_code_cleanup_assert('repo@dirty' === (string) ( $summary_json['top_blocked_examples'][0]['handle'] ?? '' ), 'cleanup evidence --summary includes largest blocked example');
datamachine_code_cleanup_assert('dirty_worktree' === (string) ( $summary_json['top_blocked_examples'][0]['reason'] ?? '' ), 'cleanup evidence --summary includes example reason');
datamachine_code_cleanup_assert(8192 === (int) ( $summary_json['top_blocked_examples'][0]['size_bytes'] ?? 0 ), 'cleanup evidence --summary includes example size');
datamachine_code_cleanup_assert(str_contains((string) wp_json_encode($summary_json['recommended_commands'] ?? array()), 'workspace cleanup run --mode=artifacts'), 'cleanup evidence --summary includes recommended commands');

WP_CLI::$logs = array();
WP_CLI::$successes = array();
$command->cleanup(array( 'evidence', 'cleanup-run-123' ), array( 'summary' => true ));
datamachine_code_cleanup_assert(in_array('Cleanup operator summary:', WP_CLI::$logs, true), 'cleanup evidence --summary human output prints operator heading');
datamachine_code_cleanup_assert(in_array('table:6:metric,value', WP_CLI::$logs, true), 'cleanup evidence --summary human output prints compact metric table');
datamachine_code_cleanup_assert(in_array('Top blocked examples:', WP_CLI::$logs, true), 'cleanup evidence --summary human output prints blocked examples');
datamachine_code_cleanup_assert(array() !== array_filter(WP_CLI::$logs, fn( $log ) => str_contains((string) $log, ':size,reason,handle,artifact_path,path')), 'cleanup evidence --summary human output prints blocked example table');

WP_CLI::$logs = array();
WP_CLI::$successes = array();
$command->cleanup(array( 'resume', 'cleanup-run-123' ), array( 'force' => true, 'format' => 'json' ));
Expand Down
Loading