diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index ba80d81c..07218efa 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -29,6 +29,7 @@ use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorkspaceMutationLock; use DataMachineCode\Workspace\StandaloneWorktreeProvider; +use DataMachineCode\Workspace\WorktreeRetentionProvider; defined( 'ABSPATH' ) || exit; @@ -1588,6 +1589,23 @@ public function cleanup( array $args, array $assoc_args ): void { } } + /** + * Serve the versioned Homeboy worktree-retention provider protocol. + * + * Reads one JSON request from stdin and writes one JSON response to stdout. + * + * @subcommand retention-provider + */ + public function retention_provider( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $provider = new WorktreeRetentionProvider(); + $request = stream_get_contents( STDIN ); + $response = $provider->handle_json( false === $request ? '' : $request ); + fwrite( STDOUT, $provider->encode_response( $response ) . "\n" ); + if ( 'failed' === ( $response['state'] ?? '' ) ) { + WP_CLI::halt( 1 ); + } + } + private function run_cleanup_safe( array $assoc_args ): void { $input = array( 'dry_run' => ! empty( $assoc_args['dry-run'] ), diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index 7ac1f354..a314f167 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -77,7 +77,14 @@ public function plan( array $opts = array() ): array|\WP_Error { array( 'expected_status' => 'planning', 'status' => 'planned', - 'policy' => $plan['safety_policy'] ?? array(), + 'policy' => array_merge( + (array) ( $plan['safety_policy'] ?? array() ), + array( + 'plan_id' => (string) ( $plan['plan_id'] ?? '' ), + 'inventory_continuation' => (array) ( $plan['continuation'] ?? array() ), + 'retention_blockers' => (array) ( $plan['summary']['blockers'] ?? array() ), + ) + ), 'summary' => $plan['summary'], ), 'planned' diff --git a/inc/Workspace/WorktreeRetentionProvider.php b/inc/Workspace/WorktreeRetentionProvider.php new file mode 100644 index 00000000..94079adb --- /dev/null +++ b/inc/Workspace/WorktreeRetentionProvider.php @@ -0,0 +1,302 @@ +cleanup ??= new CleanupRunService(); + $this->clock_ms = $clock_ms ?? static fn(): int => (int) floor( microtime( true ) * 1000 ); + } + + /** + * Execute one strict provider request. + * + * @return array + */ + public function handle_json( string $json ): array { + if ( strlen( $json ) > self::MAX_REQUEST_BYTES ) { + return $this->failure( 'request_too_large' ); + } + + try { + $request = json_decode( $json, true, 32, JSON_THROW_ON_ERROR ); + } catch ( \JsonException ) { + return $this->failure( 'invalid_json' ); + } + if ( ! is_array( $request ) || array_is_list( $request ) ) { + return $this->failure( 'invalid_request' ); + } + + $error = $this->validate_request( $request ); + if ( null !== $error ) { + return $this->failure( + $error, + (string) ( $request['run_id'] ?? 'unavailable' ), + (string) ( $request['plan_id'] ?? 'unavailable' ) + ); + } + + return match ( $request['operation'] ) { + 'plan' => $this->plan( $request ), + 'apply' => $this->apply( $request ), + 'status' => $this->read( $request, false ), + 'evidence' => $this->read( $request, true ), + }; + } + + /** Return one protocol-bounded JSON document. */ + public function encode_response( array $response ): string { + $json = json_encode( $this->wire_response( $response ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ); + if ( strlen( $json ) > self::MAX_OUTPUT_BYTES ) { + $json = json_encode( $this->wire_response( $this->failure( 'response_too_large' ) ), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ); + } + return $json; + } + + /** @return array */ + private function wire_response( array $response ): array { + $response['effects'] = (object) (array) ( $response['effects'] ?? array() ); + if ( isset( $response['blockers'] ) && is_array( $response['blockers'] ) ) { + $response['blockers']['by_reason'] = (object) (array) ( $response['blockers']['by_reason'] ?? array() ); + } + return $response; + } + + /** @param array $request */ + private function validate_request( array $request ): ?string { + $allowed = array( 'schema', 'provider_id', 'operation', 'request_id', 'idempotency_key', 'run_id', 'plan_id', 'bounds', 'deadline_unix_ms' ); + if ( array() !== array_diff( array_keys( $request ), $allowed ) ) { + return 'unknown_request_field'; + } + if ( self::SCHEMA !== ( $request['schema'] ?? null ) ) { + return 'unexpected_schema'; + } + if ( self::PROVIDER_ID !== ( $request['provider_id'] ?? null ) ) { + return 'unexpected_provider'; + } + if ( ! in_array( $request['operation'] ?? null, array( 'plan', 'apply', 'status', 'evidence' ), true ) ) { + return 'unsupported_operation'; + } + if ( ! is_string( $request['request_id'] ?? null ) || '' === trim( $request['request_id'] ) ) { + return 'missing_request_id'; + } + foreach ( array( 'idempotency_key', 'run_id', 'plan_id' ) as $field ) { + if ( isset( $request[ $field ] ) && ( ! is_string( $request[ $field ] ) || '' === trim( $request[ $field ] ) ) ) { + return 'invalid_' . $field; + } + } + if ( 'plan' !== $request['operation'] && ( empty( $request['run_id'] ) || empty( $request['plan_id'] ) ) ) { + return 'missing_plan_identity'; + } + if ( isset( $request['bounds'] ) ) { + if ( ! is_array( $request['bounds'] ) || array_is_list( $request['bounds'] ) || array() !== array_diff( array_keys( $request['bounds'] ), array( 'max_items', 'timeout_ms' ) ) ) { + return 'invalid_bounds'; + } + foreach ( array( 'max_items', 'timeout_ms' ) as $field ) { + if ( isset( $request['bounds'][ $field ] ) && ( ! is_int( $request['bounds'][ $field ] ) || $request['bounds'][ $field ] <= 0 ) ) { + return 'invalid_' . $field; + } + } + } + if ( isset( $request['deadline_unix_ms'] ) ) { + if ( ! is_int( $request['deadline_unix_ms'] ) || $request['deadline_unix_ms'] <= 0 ) { + return 'invalid_deadline'; + } + if ( $request['deadline_unix_ms'] <= ( $this->clock_ms )() ) { + return 'deadline_elapsed'; + } + } + return null; + } + + /** @param array $request @return array */ + private function plan( array $request ): array { + $options = array( + 'mode' => 'retention', + 'include_artifacts' => false, + 'include_worktrees' => true, + 'include_resolvers' => false, + 'limit' => $this->limit( $request ), + ); + $budget = $this->remaining_budget_seconds( $request ); + if ( null !== $budget ) { + $options['until_budget'] = $budget . 's'; + } + + $plan = $this->cleanup->plan( $options ); + if ( $plan instanceof \WP_Error ) { + return $this->failure( (string) $plan->get_error_code() ); + } + $run_id = (string) ( $plan['run_id'] ?? '' ); + $plan_id = (string) ( $plan['plan_id'] ?? '' ); + if ( '' === $run_id || '' === $plan_id ) { + return $this->failure( 'provider_plan_identity_missing' ); + } + + return $this->response( + $run_id, + $plan_id, + 'planned', + ! empty( $plan['continuation']['partial'] ) + ? array( 'complete' => false, 'resume_operation' => 'plan', 'reason' => 'inventory_page' ) + : null, + array(), + $this->blockers( (array) ( $plan['summary']['blockers'] ?? array() ) ) + ); + } + + /** @param array $request @return array */ + private function apply( array $request ): array { + $identity = $this->verified_status( $request ); + if ( $identity instanceof \WP_Error ) { + return $this->failure( (string) $identity->get_error_code(), (string) $request['run_id'], (string) $request['plan_id'] ); + } + + $result = $this->cleanup->apply( (string) $request['run_id'], array( 'limit' => $this->limit( $request ) ) ); + if ( $result instanceof \WP_Error ) { + return $this->failure( (string) $result->get_error_code(), (string) $request['run_id'], (string) $request['plan_id'] ); + } + + return $this->from_status( $result, $identity ); + } + + /** @param array $request @return array */ + private function read( array $request, bool $evidence ): array { + $status = $this->verified_status( $request, $evidence ); + if ( $status instanceof \WP_Error ) { + return $this->failure( (string) $status->get_error_code(), (string) $request['run_id'], (string) $request['plan_id'] ); + } + return $this->from_status( $status, $status ); + } + + /** @param array $request @return array|\WP_Error */ + private function verified_status( array $request, bool $evidence = false ): array|\WP_Error { + $status = $evidence + ? $this->cleanup->evidence( (string) $request['run_id'] ) + : $this->cleanup->status( (string) $request['run_id'] ); + if ( $status instanceof \WP_Error ) { + return $status; + } + $persisted = (string) ( $status['run']['policy']['plan_id'] ?? '' ); + if ( '' === $persisted || ! hash_equals( $persisted, (string) $request['plan_id'] ) ) { + return new \WP_Error( 'plan_identity_mismatch', 'The reviewed cleanup plan identity does not match the persisted run.', array( 'status' => 409 ) ); + } + return $status; + } + + /** @param array $status @param array $identity @return array */ + private function from_status( array $status, array $identity ): array { + $run = (array) ( $status['run'] ?? $identity['run'] ?? array() ); + $policy = (array) ( $run['policy'] ?? array() ); + $run_id = (string) ( $status['run_id'] ?? $run['run_id'] ?? '' ); + $plan_id = (string) ( $policy['plan_id'] ?? '' ); + $state = (string) ( $status['state'] ?? $status['status'] ?? $run['status'] ?? '' ); + $pending = (int) ( $status['summary']['pending_or_failed'] ?? 0 ); + $continuation = null; + if ( in_array( $state, array( 'failed', 'planning_failed', 'cancelled' ), true ) ) { + $state = 'failed'; + } elseif ( $pending > 0 || in_array( $state, array( 'applying', 'needs_resume' ), true ) ) { + $state = 'continuing'; + $continuation = array( 'complete' => false, 'resume_operation' => 'apply', 'reason' => 'pending_rows' ); + } elseif ( 'planned' === $state && ! empty( $policy['inventory_continuation']['partial'] ) ) { + $continuation = array( 'complete' => false, 'resume_operation' => 'plan', 'reason' => 'inventory_page' ); + } elseif ( ! empty( $policy['inventory_continuation']['partial'] ) ) { + $state = 'continuing'; + $continuation = array( 'complete' => false, 'resume_operation' => 'plan', 'reason' => 'inventory_page' ); + } elseif ( 'planned' !== $state && 'completed' !== $state ) { + $state = 'blocked'; + } + + $summary = (array) ( $status['summary'] ?? array() ); + return $this->response( + $run_id, + $plan_id, + $state, + $continuation, + array( + 'worktrees_removed' => (int) ( $summary['items_by_status']['applied'] ?? 0 ), + 'bytes_reclaimed' => (int) ( $summary['bytes_reclaimed'] ?? 0 ), + ), + $this->blockers( (array) ( $policy['retention_blockers'] ?? array() ) ) + ); + } + + /** @return array{count:int,by_reason:array} */ + private function blockers( array $blockers ): array { + $by_reason = array(); + foreach ( $blockers as $reason => $bucket ) { + $count = max( 0, (int) ( is_array( $bucket ) ? ( $bucket['count'] ?? 0 ) : 0 ) ); + if ( $count > 0 ) { + $by_reason[ (string) $reason ] = $count; + } + } + ksort( $by_reason ); + return array( 'count' => array_sum( $by_reason ), 'by_reason' => $by_reason ); + } + + /** @param array $request */ + private function limit( array $request ): int { + return max( 1, min( self::MAX_ITEMS, (int) ( $request['bounds']['max_items'] ?? 25 ) ) ); + } + + /** @param array $request */ + private function remaining_budget_seconds( array $request ): ?int { + $milliseconds = isset( $request['deadline_unix_ms'] ) ? (int) $request['deadline_unix_ms'] - ( $this->clock_ms )() : 0; + if ( isset( $request['bounds']['timeout_ms'] ) ) { + $bound = (int) $request['bounds']['timeout_ms']; + $milliseconds = $milliseconds > 0 ? min( $milliseconds, $bound ) : $bound; + } + return $milliseconds > 0 ? max( 1, (int) floor( $milliseconds / 1000 ) ) : null; + } + + /** @return array */ + private function response( string $run_id, string $plan_id, string $state, mixed $continuation, array $effects, array $blockers ): array { + $partial = is_array( $continuation ) && empty( $continuation['complete'] ); + return array( + 'schema' => self::SCHEMA, + 'provider_id' => self::PROVIDER_ID, + 'run_id' => '' !== $run_id ? $run_id : 'unavailable', + 'plan_id' => '' !== $plan_id ? $plan_id : 'unavailable', + 'state' => $state, + 'inventory_completeness' => $partial ? 'partial' : 'complete', + 'continuation' => $partial ? $continuation : null, + 'status_ref' => array( 'command' => sprintf( 'studio wp datamachine-code workspace cleanup status %s --format=json', $run_id ) ), + 'evidence_ref' => array( 'command' => sprintf( 'studio wp datamachine-code workspace cleanup evidence %s --format=json', $run_id ) ), + 'effects' => array_filter( $effects, static fn( $value ): bool => null !== $value ), + 'blockers' => $blockers, + ); + } + + /** @return array */ + private function failure( string $reason, string $run_id = 'unavailable', string $plan_id = 'unavailable' ): array { + return $this->response( + $run_id, + $plan_id, + 'failed', + null, + array(), + array( 'count' => 1, 'by_reason' => array( $reason => 1 ) ) + ); + } +} diff --git a/tests/cleanup-run-durable-planning.php b/tests/cleanup-run-durable-planning.php index 28fc8597..cbaf240b 100644 --- a/tests/cleanup-run-durable-planning.php +++ b/tests/cleanup-run-durable-planning.php @@ -17,8 +17,13 @@ public function workspace_cleanup_plan(array $opts): array|\WP_Error { if ('success' === $this->outcome) { return array( 'safety_policy' => array('applies_inline' => false), + 'plan_id' => 'cleanup-plan-stable', + 'continuation' => array('partial' => true, 'next_offset' => 25), 'rows' => array(), - 'summary' => array('apply_command' => 'studio wp datamachine-code workspace cleanup apply '), + 'summary' => array( + 'apply_command' => 'studio wp datamachine-code workspace cleanup apply ', + 'blockers' => array('dirty_worktree' => array('count' => 2)), + ), ); } return new \WP_Error('workspace_cleanup_plan_timeout', 'Workspace discovery exceeded its deadline.', array('status' => 504)); @@ -100,6 +105,9 @@ function durable_planning_assert(mixed $expected, mixed $actual, string $message $success_plan = $success_service->plan(array('mode' => 'artifacts')); durable_planning_assert('planned', $success_repository->runs['cleanup-run-timeout']['status'] ?? null, 'Successful discovery must transition the run to planned.'); durable_planning_assert('cleanup-run-timeout', $success_plan['run_id'] ?? null, 'Successful planning must return its durable run ID.'); + durable_planning_assert('cleanup-plan-stable', $success_repository->runs['cleanup-run-timeout']['policy']['plan_id'] ?? null, 'Stable plan identity must be persisted with the reviewed run.'); + durable_planning_assert(25, $success_repository->runs['cleanup-run-timeout']['policy']['inventory_continuation']['next_offset'] ?? null, 'Inventory continuation must survive for provider status and resume.'); + durable_planning_assert(2, $success_repository->runs['cleanup-run-timeout']['policy']['retention_blockers']['dirty_worktree']['count'] ?? null, 'Normalized retention blockers must survive apply summary replacement.'); $success_apply = $success_service->apply('cleanup-run-timeout'); durable_planning_assert('completed', $success_apply['state'] ?? null, 'A successfully planned empty run must transition through apply to completed.'); diff --git a/tests/worktree-retention-provider.php b/tests/worktree-retention-provider.php new file mode 100644 index 00000000..6b23156f --- /dev/null +++ b/tests/worktree-retention-provider.php @@ -0,0 +1,154 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } +} + +require_once dirname(__DIR__) . '/inc/Workspace/CleanupRunService.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeRetentionProvider.php'; + +final class RetentionProviderCleanupService extends DataMachineCode\Workspace\CleanupRunService { + public array $plan_options = array(); + public int $apply_calls = 0; + public int $status_calls = 0; + public int $evidence_calls = 0; + public string $persisted_plan_id = 'plan-1'; + public string $apply_state = 'completed'; + public int $pending = 0; + public bool $inventory_partial = true; + + public function __construct() {} + + public function plan(array $opts = array()): array|WP_Error { + $this->plan_options = $opts; + return array( + 'run_id' => 'run-1', + 'plan_id' => 'plan-1', + 'continuation' => $this->inventory_partial ? array('partial' => true, 'next_offset' => 25) : array(), + 'summary' => array( + 'blockers' => array( + 'dirty_worktree' => array('count' => 2), + ), + ), + ); + } + + public function apply(string $run_id, array $opts = array()): array|WP_Error { + ++$this->apply_calls; + return $this->status_result($this->apply_state); + } + + public function status(string $run_id): array|WP_Error { + ++$this->status_calls; + return $this->status_result('planned'); + } + + public function evidence(string $run_id): array|WP_Error { + ++$this->evidence_calls; + return $this->status_result('completed'); + } + + private function status_result(string $state): array { + return array( + 'success' => true, + 'state' => $state, + 'run_id' => 'run-1', + 'run' => array( + 'run_id' => 'run-1', + 'status' => $state, + 'policy' => array( + 'plan_id' => $this->persisted_plan_id, + 'inventory_continuation' => $this->inventory_partial ? array('partial' => true) : array(), + 'retention_blockers' => array('dirty_worktree' => array('count' => 2)), + ), + ), + 'summary' => array( + 'pending_or_failed' => $this->pending, + 'items_by_status' => array('applied' => 3), + 'bytes_reclaimed' => 4096, + ), + ); + } +} + +function retention_provider_assert(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true)); + } +} + +function retention_provider_request(string $operation, array $extra = array()): string { + $request = array( + 'schema' => 'homeboy/worktree-retention/v1', + 'provider_id' => 'data-machine-code', + 'operation' => $operation, + 'request_id' => 'request-1', + 'bounds' => array('max_items' => 7, 'timeout_ms' => 5000), + 'deadline_unix_ms' => 15000, + ); + if ('plan' !== $operation) { + $request['run_id'] = 'run-1'; + $request['plan_id'] = 'plan-1'; + } + return json_encode(array_merge($request, $extra), JSON_THROW_ON_ERROR); +} + +$service = new RetentionProviderCleanupService(); +$provider = new DataMachineCode\Workspace\WorktreeRetentionProvider($service, static fn(): int => 10000); + +$plan = $provider->handle_json(retention_provider_request('plan')); +$plan_wire = json_decode($provider->encode_response($plan)); +retention_provider_assert(true, is_object($plan_wire->effects ?? null), 'Empty effects must encode as a JSON object for the strict Homeboy contract.'); +retention_provider_assert(true, is_object($plan_wire->blockers->by_reason ?? null), 'Empty blocker maps must encode as JSON objects for the strict Homeboy contract.'); +retention_provider_assert('planned', $plan['state'] ?? null, 'Plan must expose a reviewed provider state.'); +retention_provider_assert('partial', $plan['inventory_completeness'] ?? null, 'A bounded plan must preserve partial inventory evidence.'); +retention_provider_assert('plan', $plan['continuation']['resume_operation'] ?? null, 'A bounded inventory page must point to the next plan pass.'); +retention_provider_assert(2, $plan['blockers']['by_reason']['dirty_worktree'] ?? null, 'Plan blockers must be normalized by reason.'); +retention_provider_assert(false, $service->plan_options['include_artifacts'] ?? null, 'Provider planning must not route through artifact cleanup.'); +retention_provider_assert(true, $service->plan_options['include_worktrees'] ?? null, 'Provider planning must delegate to worktree cleanup.'); +retention_provider_assert(7, $service->plan_options['limit'] ?? null, 'Homeboy item bounds must reach CleanupRunService.'); +retention_provider_assert('5s', $service->plan_options['until_budget'] ?? null, 'Homeboy deadlines must bound provider discovery.'); + +$apply = $provider->handle_json(retention_provider_request('apply')); +retention_provider_assert(1, $service->apply_calls, 'Matching apply must delegate exactly once.'); +retention_provider_assert('continuing', $apply['state'] ?? null, 'A completed page with more inventory must request another plan pass.'); +retention_provider_assert('plan', $apply['continuation']['resume_operation'] ?? null, 'Completed bounded apply must continue with planning.'); +retention_provider_assert(3, $apply['effects']['worktrees_removed'] ?? null, 'Apply receipt must normalize removed worktrees.'); +retention_provider_assert(4096, $apply['effects']['bytes_reclaimed'] ?? null, 'Apply receipt must normalize reclaimed bytes.'); + +$service->persisted_plan_id = 'different-plan'; +$mismatch = $provider->handle_json(retention_provider_request('apply')); +retention_provider_assert('failed', $mismatch['state'] ?? null, 'Mismatched plans must fail closed.'); +retention_provider_assert(1, $service->apply_calls, 'Mismatched plans must fail before mutation.'); +retention_provider_assert(1, $mismatch['blockers']['by_reason']['plan_identity_mismatch'] ?? null, 'Mismatch failure must remain machine-readable.'); +$service->persisted_plan_id = 'plan-1'; + +$status = $provider->handle_json(retention_provider_request('status')); +retention_provider_assert('planned', $status['state'] ?? null, 'Status must preserve reviewed plan state.'); +$evidence = $provider->handle_json(retention_provider_request('evidence')); +retention_provider_assert(1, $service->evidence_calls, 'Evidence must delegate to the durable cleanup evidence service.'); +retention_provider_assert(4096, $evidence['effects']['bytes_reclaimed'] ?? null, 'Evidence must remain bounded and normalized.'); + +$unknown = $provider->handle_json(retention_provider_request('plan', array('unexpected' => true))); +retention_provider_assert(1, $unknown['blockers']['by_reason']['unknown_request_field'] ?? null, 'Unknown request fields must be rejected.'); +$expired = $provider->handle_json(retention_provider_request('plan', array('deadline_unix_ms' => 9999))); +retention_provider_assert(1, $expired['blockers']['by_reason']['deadline_elapsed'] ?? null, 'Expired deadlines must fail before planning.'); +$oversized = $provider->handle_json(str_repeat('x', 262145)); +retention_provider_assert(1, $oversized['blockers']['by_reason']['request_too_large'] ?? null, 'Oversized input must fail before decoding.'); + +$encoded = $provider->encode_response(array('oversized' => str_repeat('x', 65536))); +$bounded = json_decode($encoded, true, 32, JSON_THROW_ON_ERROR); +retention_provider_assert(1, $bounded['blockers']['by_reason']['response_too_large'] ?? null, 'Oversized output must collapse to a bounded failure receipt.'); + +fwrite(STDOUT, "worktree-retention-provider ok\n");