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
249 changes: 16 additions & 233 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5346,6 +5346,9 @@ private function render_worktree_metadata_reconciliation_result( array $result,
private function render_worktree_active_no_signal_report_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
if ( empty($assoc_args['verbose']) ) {
$result = WorkspaceCompactOutput::cleanup_result($result);
}
$this->renderer()->json($result);
return;
}
Expand Down Expand Up @@ -5436,6 +5439,9 @@ private function render_worktree_active_no_signal_report_result( array $result,
private function render_worktree_active_no_signal_finalized_apply_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
if ( empty($assoc_args['verbose']) ) {
$result = WorkspaceCompactOutput::cleanup_result($result);
}
$this->renderer()->json($result);
return;
}
Expand Down Expand Up @@ -5526,6 +5532,9 @@ private function render_worktree_active_no_signal_finalized_apply_result( array
private function render_worktree_active_no_signal_equivalent_clean_apply_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
if ( empty($assoc_args['verbose']) ) {
$result = WorkspaceCompactOutput::cleanup_result($result);
}
$this->renderer()->json($result);
return;
}
Expand Down Expand Up @@ -5616,6 +5625,9 @@ private function render_worktree_active_no_signal_equivalent_clean_apply_result(
private function render_worktree_active_no_signal_merged_apply_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
if ( empty($assoc_args['verbose']) ) {
$result = WorkspaceCompactOutput::cleanup_result($result);
}
$this->renderer()->json($result);
return;
}
Expand Down Expand Up @@ -5706,6 +5718,9 @@ private function render_worktree_active_no_signal_merged_apply_result( array $re
private function render_worktree_active_no_signal_remote_clean_apply_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
if ( empty($assoc_args['verbose']) ) {
$result = WorkspaceCompactOutput::cleanup_result($result);
}
$this->renderer()->json($result);
return;
}
Expand Down Expand Up @@ -5945,7 +5960,7 @@ private function flatten_artifact_cleanup_rows( array $rows ): array {
private function render_worktree_bounded_cleanup_eligible_apply_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
$report = ! empty($assoc_args['verbose']) ? $result : $this->compact_worktree_bounded_cleanup_eligible_apply_json($result);
$report = ! empty($assoc_args['verbose']) ? $result : WorkspaceCompactOutput::cleanup_result($result);
$this->renderer()->json($report);
return;
}
Expand Down Expand Up @@ -6183,57 +6198,6 @@ private function render_worktree_cleanup_eligible_drain_result( array $result, a
}
}

/**
* Compact bounded cleanup JSON for chat/operator output.
*
* @param array<string,mixed> $result Full bounded apply result.
* @return array<string,mixed>
*/
private function compact_worktree_bounded_cleanup_eligible_apply_json( array $result ): array {
$summary = (array) ( $result['summary'] ?? array() );
$candidates = (array) ( $result['candidates'] ?? array() );
$removed = (array) ( $result['removed'] ?? array() );
$skipped = (array) ( $result['skipped'] ?? array() );
$buckets = $this->build_cleanup_blocker_buckets($skipped);
$actions = $this->build_cleanup_next_actions($buckets, (array) ( $summary['skipped_next_commands'] ?? array() ));

$compact_summary = array_merge(
$summary,
array(
'processed' => (int) ( $summary['processed'] ?? $summary['inspected'] ?? count($candidates) ),
'would_remove' => (int) ( $summary['would_remove'] ?? ( ! empty($result['dry_run']) ? count($candidates) : 0 ) ),
'removed' => (int) ( $summary['removed'] ?? count($removed) ),
'skipped' => max( (int) ( $summary['skipped'] ?? 0 ), count($skipped) ),
'bytes_reclaimed' => (int) ( $summary['bytes_reclaimed'] ?? 0 ),
'skipped_by_reason' => array_map(fn( $bucket ) => (int) ( $bucket['count'] ?? 0 ), $buckets),
'blocker_bucket_count' => count($buckets),
)
);

$report = array(
'success' => (bool) ( $result['success'] ?? true ),
'mode' => (string) ( $result['mode'] ?? 'bounded_cleanup_eligible_apply' ),
'dry_run' => ! empty($result['dry_run']),
'destructive' => ! empty($result['destructive']),
'workspace_path' => $result['workspace_path'] ?? null,
'generated_at' => $result['generated_at'] ?? null,
'summary' => $compact_summary,
'blocker_buckets' => $buckets,
'next_actions' => $actions,
'active_no_signal_triage' => (array) ( $result['active_no_signal_triage'] ?? array() ),
'candidates' => $this->compact_cleanup_rows($candidates, 25),
'removed' => $this->compact_cleanup_rows($removed, 25),
'continuation' => $this->compact_cleanup_continuation( (array) ( $result['continuation'] ?? $result['pagination'] ?? array() ) ),
'evidence' => $this->compact_cleanup_evidence( (array) ( $result['evidence'] ?? array() ), $skipped ),
);

if ( ! empty($result['job_backed']) ) {
$report['job_backed'] = true;
}

return array_filter($report, fn( $value ) => null !== $value);
}

/**
* Render concise active/no-signal triage preview from bounded cleanup output.
*
Expand Down Expand Up @@ -6283,187 +6247,6 @@ private function render_active_no_signal_triage_preview( array $preview ): void
}
}

/**
* Build skipped blocker buckets with bounded examples.
*
* @param array<int,array<string,mixed>> $rows Skipped rows.
* @return array<string,array<string,mixed>>
*/
private function build_cleanup_blocker_buckets( array $rows ): array {
$buckets = array();
foreach ( $rows as $row ) {
$reason_code = (string) ( $row['reason_code'] ?? 'unknown' );
if ( ! isset($buckets[ $reason_code ]) ) {
$buckets[ $reason_code ] = array(
'count' => 0,
'examples' => array(),
'reason' => (string) ( $row['reason'] ?? '' ),
);
}
++$buckets[ $reason_code ]['count'];
if ( count($buckets[ $reason_code ]['examples']) < 3 ) {
$buckets[ $reason_code ]['examples'][] = $this->compact_cleanup_row($row);
}
}

ksort($buckets);
return $buckets;
}

/**
* Build compact next actions for unresolved blocker classes.
*
* @param array<string,array<string,mixed>> $buckets Blocker buckets.
* @param array<int,array<string,mixed>> $commands Existing cleanup command hints.
* @return array<int,array<string,mixed>>
*/
private function build_cleanup_next_actions( array $buckets, array $commands ): array {
$by_reason = array();
foreach ( $commands as $command ) {
$reason_code = (string) ( $command['reason_code'] ?? '' );
if ( '' !== $reason_code ) {
$by_reason[ $reason_code ] = $command;
}
}

$defaults = array(
'active_no_signal' => array(
'command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=25 --offset=0 --format=json',
'destructive' => false,
),
'needs_metadata_reconcile' => array(
'command' => 'studio wp datamachine-code workspace worktree reconcile-metadata --dry-run --limit=25 --offset=0 --until-budget=30s --format=json',
'destructive' => false,
),
'lifecycle_reconciliation_candidate' => array(
'command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --format=json',
'destructive' => false,
),
'dirty_worktree' => array(
'command' => 'git -C <worktree-path> status --short --branch --untracked-files=normal',
'destructive' => false,
),
'unpushed_commits' => array(
'command' => 'git -C <worktree-path> log --oneline --decorate @{u}..HEAD',
'destructive' => false,
),
'stale_worktree_marker' => array(
'command' => 'git -C <primary-path> worktree prune --dry-run --verbose',
'destructive' => false,
),
'primary_missing' => array(
'command' => 'studio wp datamachine-code workspace show <repo>',
'destructive' => false,
),
'submodule_worktree' => array(
'command' => 'git -C <worktree-path> submodule status --recursive',
'destructive' => false,
),
'remove_timeout' => array(
'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25 --remove-timeout=<seconds>',
'destructive' => true,
),
);

$actions = array();
foreach ( $buckets as $reason_code => $bucket ) {
$hint = $by_reason[ $reason_code ] ?? $defaults[ $reason_code ] ?? array(
'command' => 'Re-run with --verbose --format=json and inspect this reason_code before retrying cleanup.',
'destructive' => false,
);
$actions[] = array(
'reason_code' => $reason_code,
'count' => (int) ( $bucket['count'] ?? 0 ),
'command' => (string) ( $hint['command'] ?? '' ),
'alternative' => (string) ( $hint['alternative'] ?? '' ),
'destructive' => ! empty($hint['destructive']),
);
}

return $actions;
}

/**
* Compact a bounded cleanup continuation without full handle lists.
*
* @param array<string,mixed> $continuation Continuation payload.
* @return array<string,mixed>
*/
private function compact_cleanup_continuation( array $continuation ): array {
if ( empty($continuation) ) {
return array();
}

$handles = array_values(array_filter(array_map('strval', (array) ( $continuation['remaining_handles'] ?? array() ))));
unset($continuation['remaining_handles']);
if ( ! isset($continuation['remaining_total']) && isset($continuation['total']) ) {
$continuation['remaining_total'] = (int) $continuation['total'];
}
$continuation['remaining_handles_count'] = count($handles);
$continuation['remaining_handles_examples'] = array_slice($handles, 0, 10);
if ( count($handles) > 10 ) {
$continuation['remaining_handles_truncated'] = true;
}

return $continuation;
}

/**
* Compact evidence while removing full skipped handle lists.
*
* @param array<string,mixed> $evidence Evidence payload.
* @param array<int,array<string,mixed>> $skipped Skipped rows.
* @return array<string,mixed>
*/
private function compact_cleanup_evidence( array $evidence, array $skipped ): array {
$skipped_handles = array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped)));
unset($evidence['skipped_handles']);
$evidence['skipped_handles_count'] = count($skipped_handles);
$evidence['skipped_handles_examples'] = array_slice($skipped_handles, 0, 10);
if ( count($skipped_handles) > 10 ) {
$evidence['skipped_handles_truncated'] = true;
}
$evidence['full_detail_hint'] = 'Re-run with --verbose --format=json for full skipped rows and handle lists.';

return $evidence;
}

/**
* Compact cleanup rows to the fields operators need first.
*
* @param array<int,array<string,mixed>> $rows Rows to compact.
* @param int $limit Maximum rows.
* @return array<int,array<string,mixed>>
*/
private function compact_cleanup_rows( array $rows, int $limit ): array {
return array_map(fn( $row ) => $this->compact_cleanup_row( (array) $row ), array_slice($rows, 0, $limit));
}

/**
* Compact one cleanup row.
*
* @param array<string,mixed> $row Cleanup row.
* @return array<string,mixed>
*/
private function compact_cleanup_row( array $row ): array {
$compact = array(
'handle' => $row['handle'] ?? null,
'repo' => $row['repo'] ?? null,
'branch' => $row['branch'] ?? null,
'reason_code' => $row['reason_code'] ?? $row['signal'] ?? null,
'reason' => isset($row['reason']) ? $this->shorten_cleanup_reason( (string) $row['reason'] ) : null,
'path' => $row['path'] ?? null,
);

foreach ( array( 'size_bytes', 'artifact_size_bytes', 'dirty', 'unpushed', 'created_at', 'liveness', 'pr_url' ) as $field ) {
if ( array_key_exists($field, $row) ) {
$compact[ $field ] = $row[ $field ];
}
}

return array_filter($compact, fn( $value ) => null !== $value && '' !== $value && array() !== $value);
}

private function render_worktree_emergency_cleanup_result( array $result, array $assoc_args ): void {
$format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
Expand Down
14 changes: 11 additions & 3 deletions inc/Cli/WorkspaceCompactOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ class WorkspaceCompactOutput {

public static function cleanup_result( array $result ): array {
$summary = (array) ( $result['summary'] ?? array() );
$candidates = (array) ( $result['candidates'] ?? $result['artifact_candidates'] ?? array() );
$removed = (array) ( $result['removed'] ?? $result['removed_worktrees'] ?? $result['removed_artifacts'] ?? array() );
$candidates = (array) ( $result['candidates'] ?? $result['artifact_candidates'] ?? $result['worktree_candidates'] ?? $result['rows'] ?? $result['planned'] ?? array() );
$removed = (array) ( $result['removed'] ?? $result['removed_worktrees'] ?? $result['removed_artifacts'] ?? $result['written'] ?? array() );
$skipped = (array) ( $result['skipped'] ?? array() );

return self::filter_empty(
Expand Down Expand Up @@ -144,7 +144,7 @@ public static function lock_result( array $result ): array {

private static function row_counts( array $result ): array {
$counts = array();
foreach ( array( 'candidates', 'artifact_candidates', 'worktree_candidates', 'removed', 'removed_artifacts', 'removed_worktrees', 'skipped', 'written', 'proposals', 'pass_results' ) as $key ) {
foreach ( array( 'candidates', 'artifact_candidates', 'worktree_candidates', 'rows', 'planned', 'removed', 'removed_artifacts', 'removed_worktrees', 'written', 'skipped', 'proposals', 'pass_results' ) as $key ) {
if ( isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
$counts[ $key ] = count( $result[ $key ] );
}
Expand Down Expand Up @@ -204,6 +204,14 @@ private static function next_commands( array $result, array $summary ): array {
$commands[] = (string) $summary[ $field ];
}
}
foreach ( array( 'pagination', 'continuation' ) as $bucket ) {
if ( ! empty($result[ $bucket ]['next_command']) ) {
$commands[] = (string) $result[ $bucket ]['next_command'];
}
if ( ! empty($summary[ $bucket ]['next_command']) ) {
$commands[] = (string) $summary[ $bucket ]['next_command'];
}
}
$deduped = array();
$seen = array();
foreach ( $commands as $command ) {
Expand Down
53 changes: 53 additions & 0 deletions tests/workspace-compact-output.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,59 @@ function compact_output_large_rows( int $count ): array {
compact_output_assert(count((array) ( $cleanup['samples']['skipped'] ?? array() )) <= 5, 'Compact cleanup output must sample skipped rows.');
compact_output_assert(! empty($cleanup['next_commands']), 'Compact cleanup output must preserve next commands.');

$active_report = WorkspaceCompactOutput::cleanup_result(
array(
'success' => true,
'mode' => 'active_no_signal_report',
'rows' => $large_rows,
'summary' => array(
'total_active_no_signal' => 40,
'inspected' => 40,
'by_suggested_action' => array( 'remote_tracking_clean' => 40 ),
),
'pagination' => array(
'total' => 80,
'offset' => 0,
'limit' => 40,
'next_offset' => 40,
'next_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=40 --offset=40 --format=json',
),
)
);

compact_output_assert(! isset($active_report['rows']), 'Compact active/no-signal report must omit full rows array.');
compact_output_assert(40 === ( $active_report['row_counts']['rows'] ?? null ), 'Compact active/no-signal report must preserve row count.');
compact_output_assert(40 === ( $active_report['pagination']['next_offset'] ?? null ), 'Compact active/no-signal report must preserve pagination.');
compact_output_assert(in_array('studio wp datamachine-code workspace worktree active-no-signal-report --limit=40 --offset=40 --format=json', (array) ( $active_report['next_commands'] ?? array() ), true), 'Compact active/no-signal report must expose next page command.');

$active_apply = WorkspaceCompactOutput::cleanup_result(
array(
'success' => true,
'mode' => 'active_no_signal_remote_clean_apply',
'dry_run' => true,
'planned' => $large_rows,
'written' => $large_rows,
'skipped' => $large_rows,
'summary' => array(
'inspected' => 40,
'planned' => 40,
'written' => 40,
'skipped' => 40,
'skipped_by_reason' => array( 'not_remote_tracking_clean' => 40 ),
),
'pagination' => array(
'next_command' => 'studio wp datamachine-code workspace worktree active-no-signal-remote-clean-apply --dry-run --limit=40 --offset=40 --format=json',
),
)
);

compact_output_assert(! isset($active_apply['planned']), 'Compact active/no-signal apply must omit full planned array.');
compact_output_assert(! isset($active_apply['written']), 'Compact active/no-signal apply must omit full written array.');
compact_output_assert(40 === ( $active_apply['row_counts']['planned'] ?? null ), 'Compact active/no-signal apply must preserve planned count.');
compact_output_assert(40 === ( $active_apply['row_counts']['written'] ?? null ), 'Compact active/no-signal apply must preserve written count.');
compact_output_assert(40 === ( $active_apply['blockers']['not_remote_tracking_clean']['count'] ?? null ), 'Compact active/no-signal apply must preserve blocker counts from summary.');
compact_output_assert(in_array('studio wp datamachine-code workspace worktree active-no-signal-remote-clean-apply --dry-run --limit=40 --offset=40 --format=json', (array) ( $active_apply['next_commands'] ?? array() ), true), 'Compact active/no-signal apply must expose next page command.');

$locks = WorkspaceCompactOutput::lock_result(
array(
'active' => 2,
Expand Down
Loading