From e9d06729cb2d6926e4bfbfd5925ca57acbc14619 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 01:53:36 -0400 Subject: [PATCH 01/10] refactor(worktree): type allocation requests --- inc/Abilities/WorkspaceAbilities.php | 149 ++++--------------- inc/Workspace/WorkspaceWorktreeLifecycle.php | 84 ++++++++++- inc/Workspace/WorktreeAllocationRequest.php | 80 ++++++++++ tests/worktree-allocation-request.php | 85 +++++++++++ 4 files changed, 278 insertions(+), 120 deletions(-) create mode 100644 inc/Workspace/WorktreeAllocationRequest.php create mode 100644 tests/worktree-allocation-request.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 9e20236d..a9f4cf1f 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -28,6 +28,7 @@ use DataMachineCode\Workspace\WorkspaceWriter; use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorktreeDiskBudget; +use DataMachineCode\Workspace\WorktreeAllocationRequest; use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\RuntimeCapabilities; use DataMachineCode\Runtime\RuntimeSourceSkewDiagnostic; @@ -4603,70 +4604,20 @@ public static function prRebase( array $input ): array|\WP_Error { * @return array */ public static function worktreeAdd( array $input ): array|\WP_Error { - // Default inject_context=true; only false when explicitly provided. - $inject_context = array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true; - // Default bootstrap=true; only false when explicitly provided. - $bootstrap = array_key_exists( 'bootstrap', $input ) ? (bool) $input['bootstrap'] : true; - // Default allow_stale=false (gate enforced); only true when explicitly opted in. - $allow_stale = array_key_exists( 'allow_stale', $input ) ? (bool) $input['allow_stale'] : false; - // Default allow_unverified_freshness=false (fetch-failure gate enforced). - $allow_unverified_freshness = array_key_exists( 'allow_unverified_freshness', $input ) ? (bool) $input['allow_unverified_freshness'] : false; - // Default rebase_base=false; only true when explicitly requested. - $rebase_base = array_key_exists( 'rebase_base', $input ) ? (bool) $input['rebase_base'] : false; - $force = ! empty( $input['force'] ); - $remediate_capacity = ! empty( $input['remediate_capacity'] ); - $remediate_capacity_dry_run = ! empty( $input['remediate_capacity_dry_run'] ); - $allow_percentage_byte_floor_exception = ! empty( $input['allow_percentage_byte_floor_exception'] ); - $progress_callback = isset( $input['progress_callback'] ) && is_callable( $input['progress_callback'] ) ? $input['progress_callback'] : null; - $require_task_tracker = array_key_exists( 'require_task_tracker', $input ) ? (bool) $input['require_task_tracker'] : true; - $task = array(); - $intent = array(); - $reuse_policy = isset( $input['reuse_policy'] ) ? (string) $input['reuse_policy'] : 'reuse_compatible'; - if ( isset( $input['task_url'] ) && '' !== trim( (string) $input['task_url'] ) ) { - $task['task_url'] = (string) $input['task_url']; - } - if ( isset( $input['task_ref'] ) && '' !== trim( (string) $input['task_ref'] ) ) { - $task['task_ref'] = (string) $input['task_ref']; - } - foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) { - if ( array_key_exists( $key, $input ) ) { - $intent[ $key ] = $input[ $key ]; - } - } - + $request = WorktreeAllocationRequest::from_input($input); $workspace = new Workspace(); - $task = WorktreeContextInjector::resolve_task_metadata( $task ) ?? array(); - if ( $require_task_tracker && empty( $task ) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout( $workspace, (string) ( $input['repo'] ?? '' ) ) ) { + if ( $request->require_task_tracker && empty($request->task) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout($workspace, $request->repo) ) { return new \WP_Error( 'worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 ) ); } - if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout( $workspace, (string) ( $input['repo'] ?? '' ) ) ) { + if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout($workspace, $request->repo) ) { return self::worktree_add_response( - $workspace->worktree_add( - $input['repo'] ?? '', - $input['branch'] ?? '', - $input['from'] ?? null, - $inject_context, - $bootstrap, - $allow_stale, - $rebase_base, - $force, - $task, - $allow_unverified_freshness, - $require_task_tracker, - $intent, - $reuse_policy, - $remediate_capacity, - $remediate_capacity_dry_run, - $progress_callback, - array(), - $allow_percentage_byte_floor_exception - ), + $workspace->worktree_add_request($request), $input ); } if ( RemoteWorkspaceBackend::should_handle() ) { - if ( $allow_percentage_byte_floor_exception ) { + if ( $request->allow_percentage_byte_floor_exception ) { return new \WP_Error( 'remote_worktree_percentage_byte_floor_exception_unsupported', 'Percentage-byte-floor admission requires a local workspace with measured capacity semantics.', @@ -4679,51 +4630,32 @@ public static function worktreeAdd( array $input ): array|\WP_Error { ) ); } - if ( $remediate_capacity || $remediate_capacity_dry_run ) { + if ( $request->remediate_capacity || $request->remediate_capacity_dry_run ) { return new \WP_Error( 'remote_worktree_capacity_remediation_unsupported', 'Capacity remediation requires a local workspace because remote workspace allocation has no filesystem capacity or cleanup lifecycle.', array( 'status' => 400, - 'remediate_capacity' => $remediate_capacity, - 'remediate_capacity_dry_run' => $remediate_capacity_dry_run, + 'remediate_capacity' => $request->remediate_capacity, + 'remediate_capacity_dry_run' => $request->remediate_capacity_dry_run, ) ); } $result = ( new RemoteWorkspaceBackend() )->worktree_add( - $input['repo'] ?? '', - $input['branch'] ?? '', - $input['from'] ?? null, - $task, - $intent, - $reuse_policy, - $allow_unverified_freshness + $request->repo, + $request->branch, + $request->from, + $request->task, + $request->intent, + $request->reuse_policy, + $request->allow_unverified_freshness ); if ( ! self::shouldFallbackToLocalWorkspace( $result ) ) { return self::worktree_add_response( self::decorate_remote_workspace_result( 'worktree_add', $result ), $input ); } } - $result = $workspace->worktree_add( - $input['repo'] ?? '', - $input['branch'] ?? '', - $input['from'] ?? null, - $inject_context, - $bootstrap, - $allow_stale, - $rebase_base, - $force, - $task, - $allow_unverified_freshness, - $require_task_tracker, - $intent, - $reuse_policy, - $remediate_capacity, - $remediate_capacity_dry_run, - $progress_callback, - array(), - $allow_percentage_byte_floor_exception - ); + $result = $workspace->worktree_add_request($request); return self::worktree_add_response( $result, $input ); } @@ -4766,8 +4698,7 @@ private static function worktree_add_response( array|\WP_Error $result, array $i /** Plan a local worktree using the same typed fields and defaults as add. */ public static function worktreePlan( array $input ): array|\WP_Error { - $request = self::worktreeIntentRequest( $input ); - return ( new Workspace() )->worktree_plan( ...$request ); + return ( new Workspace() )->worktree_plan_request(WorktreeAllocationRequest::from_input($input)); } /** Apply a previously returned local worktree plan. */ @@ -4785,7 +4716,7 @@ public static function worktreeHandoffRevalidate( array $input ): array|\WP_Erro } public static function worktreeAttachTracker( array $input ): array|\WP_Error { - return ( new Workspace() )->worktree_attach_tracker((string) ( $input['handle'] ?? '' ), array_filter(array( + return ( new Workspace() )->worktree_attach_tracker( (string) ( $input['handle'] ?? '' ), array_filter(array( 'task_url' => $input['task_url'] ?? null, 'task_ref' => $input['task_ref'] ?? null, ), static fn( mixed $value ): bool => is_string($value) && '' !== trim($value)), ! empty($input['dry_run'])); @@ -4804,41 +4735,23 @@ public static function worktreeLegacyHandoffApply( array $input ): array|\WP_Err return ( new Workspace() )->worktree_apply_legacy_handoff( (array) ( $input['plan'] ?? array() ), (string) ( $input['mode'] ?? '' ) ); } - /** @return array */ - private static function worktreeIntentRequest( array $input ): array { - $task = array_filter( - array( - 'task_url' => $input['task_url'] ?? null, - 'task_ref' => $input['task_ref'] ?? null, - ), - static fn( $value ): bool => is_string( $value ) && '' !== trim( $value ) - ); - $intent = array(); - foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) { - if ( array_key_exists( $key, $input ) ) { - $intent[ $key ] = $input[ $key ]; - } - } - return array( (string) ( $input['repo'] ?? '' ), (string) ( $input['branch'] ?? '' ), $input['from'] ?? null, array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true, array_key_exists( 'bootstrap', $input ) ? (bool) $input['bootstrap'] : true, ! empty( $input['allow_stale'] ), ! empty( $input['rebase_base'] ), ! empty( $input['force'] ), $task, ! empty( $input['allow_unverified_freshness'] ), array_key_exists( 'require_task_tracker', $input ) ? (bool) $input['require_task_tracker'] : true, $intent, (string) ( $input['reuse_policy'] ?? 'reuse_compatible' ), ! empty( $input['allow_percentage_byte_floor_exception'] ) ); - } - /** @return array> */ private static function worktreeIntentSchemaProperties(): array { $policy = WorktreeContextInjector::worktree_add_policy_schema_properties(); return array( - 'repo' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'from' => array( 'type' => 'string' ), - 'inject_context' => array( 'type' => 'boolean' ), - 'bootstrap' => array( 'type' => 'boolean' ), - 'allow_stale' => array( 'type' => 'boolean' ), - 'allow_unverified_freshness' => array( 'type' => 'boolean' ), - 'rebase_base' => array( 'type' => 'boolean' ), - 'force' => array( 'type' => 'boolean' ), + 'repo' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'from' => array( 'type' => 'string' ), + 'inject_context' => array( 'type' => 'boolean' ), + 'bootstrap' => array( 'type' => 'boolean' ), + 'allow_stale' => array( 'type' => 'boolean' ), + 'allow_unverified_freshness' => array( 'type' => 'boolean' ), + 'rebase_base' => array( 'type' => 'boolean' ), + 'force' => array( 'type' => 'boolean' ), 'allow_percentage_byte_floor_exception' => array( 'type' => 'boolean' ), - 'task_url' => array( 'type' => 'string' ), - 'task_ref' => array( 'type' => 'string' ), - 'require_task_tracker' => array( 'type' => 'boolean' ), + 'task_url' => array( 'type' => 'string' ), + 'task_ref' => array( 'type' => 'string' ), + 'require_task_tracker' => array( 'type' => 'boolean' ), ...$policy, ); } diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 595f1dbb..a2956142 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -39,7 +39,43 @@ trait WorkspaceWorktreeLifecycle { * @return array|\WP_Error */ public function worktree_plan( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false, bool $require_task_tracker = false, array $intent = array(), string $reuse_policy = 'reuse_compatible', bool $allow_percentage_byte_floor_exception = false ): array|\WP_Error { - $visible = $this->require_workspace_visible(); + return $this->worktree_plan_request( + new WorktreeAllocationRequest( + repo: $repo, + branch: $branch, + from: $from, + inject_context: $inject_context, + bootstrap: $bootstrap, + allow_stale: $allow_stale, + rebase_base: $rebase_base, + force: $force, + task: $task, + allow_unverified_freshness: $allow_unverified_freshness, + require_task_tracker: $require_task_tracker, + intent: $intent, + reuse_policy: $reuse_policy, + allow_percentage_byte_floor_exception: $allow_percentage_byte_floor_exception + ) + ); + } + + /** Execute worktree planning from one explicit allocation contract. */ + public function worktree_plan_request( WorktreeAllocationRequest $request ): array|\WP_Error { + $repo = $request->repo; + $branch = $request->branch; + $from = $request->from; + $inject_context = $request->inject_context; + $bootstrap = $request->bootstrap; + $allow_stale = $request->allow_stale; + $rebase_base = $request->rebase_base; + $force = $request->force; + $task = $request->task; + $allow_unverified_freshness = $request->allow_unverified_freshness; + $require_task_tracker = $request->require_task_tracker; + $intent = $request->intent; + $reuse_policy = $request->reuse_policy; + $allow_percentage_byte_floor_exception = $request->allow_percentage_byte_floor_exception; + $visible = $this->require_workspace_visible(); if ( null !== $visible ) { return $visible; } @@ -1114,7 +1150,51 @@ private function worktree_plan_sort( mixed $value ): mixed { * @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, disk_budget?: array, context_injected?: bool, context_files?: string[], context_skip_reason?: string, bootstrap?: array, fetch_failed?: bool, fetch_error?: string, fetch_attempts?: int, stale_commits_behind?: int, upstream?: string, base_stale_commits_behind?: int, base_upstream?: string, default_branch_commits_behind?: int, default_branch_ref?: string, gate_threshold?: int, rebase_attempted?: bool, rebase_succeeded?: bool, rebase_error?: string, rebase_target?: string}|\WP_Error */ public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false, bool $require_task_tracker = false, array $intent = array(), string $reuse_policy = 'reuse_compatible', bool $remediate_capacity = false, bool $remediate_capacity_dry_run = false, ?callable $progress_callback = null, array $expected_freshness_identity = array(), bool $allow_percentage_byte_floor_exception = false ): array|\WP_Error { - $visible = $this->require_workspace_visible(); + return $this->worktree_add_request( + new WorktreeAllocationRequest( + repo: $repo, + branch: $branch, + from: $from, + inject_context: $inject_context, + bootstrap: $bootstrap, + allow_stale: $allow_stale, + rebase_base: $rebase_base, + force: $force, + task: $task, + allow_unverified_freshness: $allow_unverified_freshness, + require_task_tracker: $require_task_tracker, + intent: $intent, + reuse_policy: $reuse_policy, + remediate_capacity: $remediate_capacity, + remediate_capacity_dry_run: $remediate_capacity_dry_run, + progress_callback: $progress_callback, + expected_freshness_identity: $expected_freshness_identity, + allow_percentage_byte_floor_exception: $allow_percentage_byte_floor_exception + ) + ); + } + + /** Execute worktree allocation from one explicit allocation contract. */ + public function worktree_add_request( WorktreeAllocationRequest $request ): array|\WP_Error { + $repo = $request->repo; + $branch = $request->branch; + $from = $request->from; + $inject_context = $request->inject_context; + $bootstrap = $request->bootstrap; + $allow_stale = $request->allow_stale; + $rebase_base = $request->rebase_base; + $force = $request->force; + $task = $request->task; + $allow_unverified_freshness = $request->allow_unverified_freshness; + $require_task_tracker = $request->require_task_tracker; + $intent = $request->intent; + $reuse_policy = $request->reuse_policy; + $remediate_capacity = $request->remediate_capacity; + $remediate_capacity_dry_run = $request->remediate_capacity_dry_run; + $progress_callback = is_callable($request->progress_callback) ? $request->progress_callback : null; + $expected_freshness_identity = $request->expected_freshness_identity; + $allow_percentage_byte_floor_exception = $request->allow_percentage_byte_floor_exception; + $visible = $this->require_workspace_visible(); if ( null !== $visible ) { return $visible; } diff --git a/inc/Workspace/WorktreeAllocationRequest.php b/inc/Workspace/WorktreeAllocationRequest.php new file mode 100644 index 00000000..403795d0 --- /dev/null +++ b/inc/Workspace/WorktreeAllocationRequest.php @@ -0,0 +1,80 @@ + $task + * @param array $intent + * @param callable|null $progress_callback + * @param array $expected_freshness_identity + */ + public function __construct( + public string $repo, + public string $branch, + public ?string $from = null, + public bool $inject_context = true, + public bool $bootstrap = true, + public bool $allow_stale = false, + public bool $rebase_base = false, + public bool $force = false, + public array $task = array(), + public bool $allow_unverified_freshness = false, + public bool $require_task_tracker = true, + public array $intent = array(), + public string $reuse_policy = 'reuse_compatible', + public bool $remediate_capacity = false, + public bool $remediate_capacity_dry_run = false, + public mixed $progress_callback = null, + public array $expected_freshness_identity = array(), + public bool $allow_percentage_byte_floor_exception = false + ) {} + + /** Build the canonical request accepted by ability and operation adapters. */ + public static function from_input( array $input, bool $require_task_tracker_default = true ): self { + $task = array_filter( + array( + 'task_url' => $input['task_url'] ?? null, + 'task_ref' => $input['task_ref'] ?? null, + ), + static fn( mixed $value ): bool => is_string($value) && '' !== trim($value) + ); + $task = WorktreeContextInjector::resolve_task_metadata($task) ?? array(); + + $intent = array(); + foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) { + if ( array_key_exists($key, $input) ) { + $intent[ $key ] = $input[ $key ]; + } + } + + return new self( + repo: (string) ( $input['repo'] ?? '' ), + branch: (string) ( $input['branch'] ?? '' ), + from: isset($input['from']) ? (string) $input['from'] : null, + inject_context: array_key_exists('inject_context', $input) ? (bool) $input['inject_context'] : true, + bootstrap: array_key_exists('bootstrap', $input) ? (bool) $input['bootstrap'] : true, + allow_stale: ! empty($input['allow_stale']), + rebase_base: ! empty($input['rebase_base']), + force: ! empty($input['force']), + task: $task, + allow_unverified_freshness: ! empty($input['allow_unverified_freshness']), + require_task_tracker: array_key_exists('require_task_tracker', $input) ? (bool) $input['require_task_tracker'] : $require_task_tracker_default, + intent: $intent, + reuse_policy: isset($input['reuse_policy']) ? (string) $input['reuse_policy'] : 'reuse_compatible', + remediate_capacity: ! empty($input['remediate_capacity']), + remediate_capacity_dry_run: ! empty($input['remediate_capacity_dry_run']), + progress_callback: isset($input['progress_callback']) && is_callable($input['progress_callback']) ? $input['progress_callback'] : null, + expected_freshness_identity: is_array($input['expected_freshness_identity'] ?? null) ? $input['expected_freshness_identity'] : array(), + allow_percentage_byte_floor_exception: ! empty($input['allow_percentage_byte_floor_exception']) + ); + } +} diff --git a/tests/worktree-allocation-request.php b/tests/worktree-allocation-request.php new file mode 100644 index 00000000..5a9e2872 --- /dev/null +++ b/tests/worktree-allocation-request.php @@ -0,0 +1,85 @@ + 'data-machine-code', + 'branch' => 'refactor/1243', + ) + ); + + allocation_request_assert('data-machine-code' === $defaults->repo, 'Repository should be preserved.'); + allocation_request_assert('refactor/1243' === $defaults->branch, 'Branch should be preserved.'); + allocation_request_assert($defaults->inject_context, 'Context injection should default on.'); + allocation_request_assert($defaults->bootstrap, 'Bootstrap should default on.'); + allocation_request_assert($defaults->require_task_tracker, 'Managed allocations should require a tracker by default.'); + allocation_request_assert('reuse_compatible' === $defaults->reuse_policy, 'Compatible reuse should remain the default.'); + + $progress = static function (): void {}; + $request = WorktreeAllocationRequest::from_input( + array( + 'repo' => 'data-machine-code', + 'branch' => 'refactor/1243', + 'from' => 'origin/main', + 'inject_context' => false, + 'bootstrap' => false, + 'allow_stale' => true, + 'rebase_base' => true, + 'force' => true, + 'task_url' => 'https://github.com/Extra-Chill/data-machine-code/issues/1243/?source=test#fragment', + 'task_ref' => 'Extra-Chill/data-machine-code#1243', + 'allow_unverified_freshness' => true, + 'require_task_tracker' => false, + 'purpose' => 'allocation-contract', + 'owner_run_ref' => 'test-run', + 'cleanup_policy' => 'remove_on_success', + 'reuse_policy' => 'isolated', + 'remediate_capacity' => true, + 'remediate_capacity_dry_run' => true, + 'progress_callback' => $progress, + 'expected_freshness_identity' => array( 'target_head' => 'abc123' ), + 'allow_percentage_byte_floor_exception' => true, + ) + ); + + allocation_request_assert('origin/main' === $request->from, 'Base ref should be preserved.'); + allocation_request_assert(! $request->inject_context && ! $request->bootstrap, 'Explicit false defaults should be preserved.'); + allocation_request_assert($request->allow_stale && $request->rebase_base && $request->force, 'Admission overrides should be preserved.'); + allocation_request_assert( + 'https://github.com/Extra-Chill/data-machine-code/issues/1243' === $request->task['task_url'], + 'Task URLs should use the canonical lifecycle identity.' + ); + allocation_request_assert('Extra-Chill/data-machine-code#1243' === $request->task['task_ref'], 'Task refs should be preserved.'); + allocation_request_assert('allocation-contract' === $request->intent['purpose'], 'Lifecycle purpose should be grouped into intent.'); + allocation_request_assert('isolated' === $request->reuse_policy, 'Explicit reuse policy should be preserved.'); + allocation_request_assert($progress === $request->progress_callback, 'Progress callbacks should be preserved.'); + allocation_request_assert(array( 'target_head' => 'abc123' ) === $request->expected_freshness_identity, 'Freshness identity should be preserved.'); +} finally { + false === $previous_task_url ? putenv('DATAMACHINE_TASK_URL') : putenv('DATAMACHINE_TASK_URL=' . $previous_task_url); + false === $previous_task_ref ? putenv('DATAMACHINE_TASK_REF') : putenv('DATAMACHINE_TASK_REF=' . $previous_task_ref); +} + +echo "worktree-allocation-request ok\n"; From edb5783950d8267380f33f6c61116e01f9c5f985 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:07:39 -0400 Subject: [PATCH 02/10] refactor(worktree): extract allocation operation --- inc/Abilities/WorkspaceAbilities.php | 70 +----------- inc/Workspace/WorktreeAllocationOperation.php | 107 ++++++++++++++++++ 2 files changed, 112 insertions(+), 65 deletions(-) create mode 100644 inc/Workspace/WorktreeAllocationOperation.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index a9f4cf1f..9c5ed27f 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -29,6 +29,7 @@ use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorktreeDiskBudget; use DataMachineCode\Workspace\WorktreeAllocationRequest; +use DataMachineCode\Workspace\WorktreeAllocationOperation; use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\RuntimeCapabilities; use DataMachineCode\Runtime\RuntimeSourceSkewDiagnostic; @@ -4604,58 +4605,10 @@ public static function prRebase( array $input ): array|\WP_Error { * @return array */ public static function worktreeAdd( array $input ): array|\WP_Error { - $request = WorktreeAllocationRequest::from_input($input); - $workspace = new Workspace(); - if ( $request->require_task_tracker && empty($request->task) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout($workspace, $request->repo) ) { - return new \WP_Error( 'worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 ) ); - } - if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout($workspace, $request->repo) ) { - return self::worktree_add_response( - $workspace->worktree_add_request($request), - $input - ); - } - - if ( RemoteWorkspaceBackend::should_handle() ) { - if ( $request->allow_percentage_byte_floor_exception ) { - return new \WP_Error( - 'remote_worktree_percentage_byte_floor_exception_unsupported', - 'Percentage-byte-floor admission requires a local workspace with measured capacity semantics.', - array( - 'status' => 400, - 'remediation' => array( - 'code' => 'local_workspace_capacity_required', - 'message' => 'Run the request against a local managed workspace, where byte and inode capacity can be measured and revalidated.', - ), - ) - ); - } - if ( $request->remediate_capacity || $request->remediate_capacity_dry_run ) { - return new \WP_Error( - 'remote_worktree_capacity_remediation_unsupported', - 'Capacity remediation requires a local workspace because remote workspace allocation has no filesystem capacity or cleanup lifecycle.', - array( - 'status' => 400, - 'remediate_capacity' => $request->remediate_capacity, - 'remediate_capacity_dry_run' => $request->remediate_capacity_dry_run, - ) - ); - } - $result = ( new RemoteWorkspaceBackend() )->worktree_add( - $request->repo, - $request->branch, - $request->from, - $request->task, - $request->intent, - $request->reuse_policy, - $request->allow_unverified_freshness - ); - if ( ! self::shouldFallbackToLocalWorkspace( $result ) ) { - return self::worktree_add_response( self::decorate_remote_workspace_result( 'worktree_add', $result ), $input ); - } + $result = ( new WorktreeAllocationOperation() )->add(WorktreeAllocationRequest::from_input($input)); + if ( is_array($result) && 'github_api' === ( $result['backend'] ?? null ) ) { + $result = self::decorate_remote_workspace_result('worktree_add', $result); } - - $result = $workspace->worktree_add_request($request); return self::worktree_add_response( $result, $input ); } @@ -4698,7 +4651,7 @@ private static function worktree_add_response( array|\WP_Error $result, array $i /** Plan a local worktree using the same typed fields and defaults as add. */ public static function worktreePlan( array $input ): array|\WP_Error { - return ( new Workspace() )->worktree_plan_request(WorktreeAllocationRequest::from_input($input)); + return ( new WorktreeAllocationOperation() )->plan(WorktreeAllocationRequest::from_input($input)); } /** Apply a previously returned local worktree plan. */ @@ -4932,19 +4885,6 @@ private static function worktreeHandoffAllocationIdentitySchemaRequired(): array return array( 'version', 'allocation_id', 'handle', 'path', 'branch', 'worktree_sha', 'resolved_base_ref', 'metadata_digest', 'digest' ); } - /** - * Whether a repo argument resolves to an editable local primary checkout. - */ - private static function hasLocalPrimaryCheckout( Workspace $workspace, string $repo ): bool { - $result = self::showLocalWorkspaceHandleIfPresent( $workspace, $repo ); - if ( null === $result ) { - return false; - } - - $path = (string) ( $result['path'] ?? '' ); - return ! str_contains( basename( $path ), '@' ); - } - /** * Return local workspace details for an existing local handle, if present. */ diff --git a/inc/Workspace/WorktreeAllocationOperation.php b/inc/Workspace/WorktreeAllocationOperation.php new file mode 100644 index 00000000..889021b6 --- /dev/null +++ b/inc/Workspace/WorktreeAllocationOperation.php @@ -0,0 +1,107 @@ +workspace ??= new Workspace(); + $this->remote ??= new RemoteWorkspaceBackend(); + $this->remote_enabled = $remote_enabled ?? static fn(): bool => RemoteWorkspaceBackend::should_handle(); + } + + /** Execute a read-only allocation plan. */ + public function plan( WorktreeAllocationRequest $request ): array|\WP_Error { + return $this->workspace->worktree_plan_request($request); + } + + /** Execute allocation against the authoritative available backend. */ + public function add( WorktreeAllocationRequest $request ): array|\WP_Error { + $remote_enabled = ( $this->remote_enabled )(); + $local_primary = $remote_enabled && $this->has_local_primary($request->repo); + + if ( $request->require_task_tracker && empty($request->task) && $remote_enabled && ! $local_primary ) { + return new \WP_Error('worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 )); + } + + if ( ! $remote_enabled || $local_primary ) { + return $this->workspace->worktree_add_request($request); + } + + if ( $request->allow_percentage_byte_floor_exception ) { + return new \WP_Error( + 'remote_worktree_percentage_byte_floor_exception_unsupported', + 'Percentage-byte-floor admission requires a local workspace with measured capacity semantics.', + array( + 'status' => 400, + 'remediation' => array( + 'code' => 'local_workspace_capacity_required', + 'message' => 'Run the request against a local managed workspace, where byte and inode capacity can be measured and revalidated.', + ), + ) + ); + } + + if ( $request->remediate_capacity || $request->remediate_capacity_dry_run ) { + return new \WP_Error( + 'remote_worktree_capacity_remediation_unsupported', + 'Capacity remediation requires a local workspace because remote workspace allocation has no filesystem capacity or cleanup lifecycle.', + array( + 'status' => 400, + 'remediate_capacity' => $request->remediate_capacity, + 'remediate_capacity_dry_run' => $request->remediate_capacity_dry_run, + ) + ); + } + + $result = $this->remote->worktree_add( + $request->repo, + $request->branch, + $request->from, + $request->task, + $request->intent, + $request->reuse_policy, + $request->allow_unverified_freshness + ); + if ( $this->should_fallback_to_local($result) ) { + return $this->workspace->worktree_add_request($request); + } + + return $result; + } + + private function has_local_primary( string $repo ): bool { + if ( '' === trim($repo) ) { + return false; + } + + $result = $this->workspace->show_repo($repo); + if ( is_wp_error($result) ) { + return false; + } + + $path = (string) ( $result['path'] ?? '' ); + return '' !== $path && ! str_starts_with($path, 'github://') && ! str_contains(basename($path), '@'); + } + + private function should_fallback_to_local( mixed $result ): bool { + return is_wp_error($result) && in_array($result->get_error_code(), array( 'remote_workspace_repo_not_found', 'unsupported_remote_workspace_repo_argument' ), true); + } +} From 1a4b47a3f7d26445389defec0750e2a1b0d4e7d2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:16:03 -0400 Subject: [PATCH 03/10] refactor(worktree): migrate allocation callers --- inc/CodeTask/CodeTaskCreator.php | 22 +++++++------- inc/CodeTask/CodeTaskWorkspaceInterface.php | 3 +- inc/CodeTask/WorkspaceCodeTaskWorkspace.php | 5 ++-- .../WorkspaceRepositoryLifecycle.php | 30 +++++++++---------- inc/Workspace/WorkspaceWorktreeLifecycle.php | 12 +++++--- inc/Workspace/WorktreeAllocationRequest.php | 12 ++++---- tests/worktree-allocation-request.php | 17 +++++++++++ 7 files changed, 62 insertions(+), 39 deletions(-) diff --git a/inc/CodeTask/CodeTaskCreator.php b/inc/CodeTask/CodeTaskCreator.php index 55cf5052..467447c7 100644 --- a/inc/CodeTask/CodeTaskCreator.php +++ b/inc/CodeTask/CodeTaskCreator.php @@ -10,6 +10,7 @@ use DataMachineCode\Support\GitHubRemote; use DataMachineCode\Workspace\Workspace; use DataMachineCode\Workspace\WorkspaceWriter; +use DataMachineCode\Workspace\WorktreeAllocationRequest; defined('ABSPATH') || exit; @@ -62,18 +63,15 @@ public function create( EvidencePacket $packet, array $args = array() ): array|\ ? (string) $args['base_ref'] : 'origin/main'; - $worktree = $this->workspace->worktree_add( - $repo['name'], - $branch, - $base_ref, - true, - true, - ! empty($args['allow_stale']), - false, - ! empty($args['force']), - array( 'task_url' => $packet->source_url() ), - true - ); + $worktree = $this->workspace->worktree_add(new WorktreeAllocationRequest( + repo: $repo['name'], + branch: $branch, + from: $base_ref, + task: array( 'task_url' => $packet->source_url() ), + allow_stale: ! empty($args['allow_stale']), + force: ! empty($args['force']), + require_task_tracker: true + )); if ( $worktree instanceof \WP_Error ) { return $worktree; diff --git a/inc/CodeTask/CodeTaskWorkspaceInterface.php b/inc/CodeTask/CodeTaskWorkspaceInterface.php index a143442a..f2540fd3 100644 --- a/inc/CodeTask/CodeTaskWorkspaceInterface.php +++ b/inc/CodeTask/CodeTaskWorkspaceInterface.php @@ -8,6 +8,7 @@ namespace DataMachineCode\CodeTask; use DataMachineCode\Workspace\Workspace; +use DataMachineCode\Workspace\WorktreeAllocationRequest; defined('ABSPATH') || exit; @@ -26,7 +27,7 @@ public function clone_repo( string $url, string $name ): array|\WP_Error; /** * @return array|\WP_Error */ - public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error; + public function worktree_add( WorktreeAllocationRequest $request ): array|\WP_Error; public function get_repo_path( string $handle ): string; } diff --git a/inc/CodeTask/WorkspaceCodeTaskWorkspace.php b/inc/CodeTask/WorkspaceCodeTaskWorkspace.php index 3caa4af7..050f3303 100644 --- a/inc/CodeTask/WorkspaceCodeTaskWorkspace.php +++ b/inc/CodeTask/WorkspaceCodeTaskWorkspace.php @@ -8,6 +8,7 @@ namespace DataMachineCode\CodeTask; use DataMachineCode\Workspace\Workspace; +use DataMachineCode\Workspace\WorktreeAllocationRequest; defined('ABSPATH') || exit; @@ -29,8 +30,8 @@ public function clone_repo( string $url, string $name ): array|\WP_Error { return $this->workspace->clone_repo($url, $name); } - public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error { - return $this->workspace->worktree_add($repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force, $task, false, $require_task_tracker); + public function worktree_add( WorktreeAllocationRequest $request ): array|\WP_Error { + return $this->workspace->worktree_add_request($request); } public function get_repo_path( string $handle ): string { diff --git a/inc/Workspace/WorkspaceRepositoryLifecycle.php b/inc/Workspace/WorkspaceRepositoryLifecycle.php index 632f3602..170507a7 100644 --- a/inc/Workspace/WorkspaceRepositoryLifecycle.php +++ b/inc/Workspace/WorkspaceRepositoryLifecycle.php @@ -500,24 +500,24 @@ public function materialize_remote_workspace( array $remote, array $options = ar ? 'origin/' . $branch : ( '' !== trim( (string) ( $remote['base_ref'] ?? '' ) ) ? (string) $remote['base_ref'] : null ); - $result = $this->worktree_add( - $repo_name, - $branch, - $from, - array_key_exists('inject_context', $options) ? (bool) $options['inject_context'] : true, - array_key_exists('bootstrap', $options) ? (bool) $options['bootstrap'] : true, - ! empty($options['allow_stale']), - ! empty($options['rebase_base']), - ! empty($options['force']), - (array) ( $remote['task'] ?? array() ), - ! empty($options['allow_unverified_freshness']), - array_key_exists('require_task_tracker', $options) ? (bool) $options['require_task_tracker'] : true, - array_filter(array( + $result = $this->worktree_add_request(WorktreeAllocationRequest::from_input(array( + 'repo' => $repo_name, + 'branch' => $branch, + 'from' => $from, + 'inject_context' => array_key_exists('inject_context', $options) ? (bool) $options['inject_context'] : true, + 'bootstrap' => array_key_exists('bootstrap', $options) ? (bool) $options['bootstrap'] : true, + 'allow_stale' => ! empty($options['allow_stale']), + 'rebase_base' => ! empty($options['rebase_base']), + 'force' => ! empty($options['force']), + 'task' => (array) ( $remote['task'] ?? array() ), + 'allow_unverified_freshness' => ! empty($options['allow_unverified_freshness']), + 'require_task_tracker' => array_key_exists('require_task_tracker', $options) ? (bool) $options['require_task_tracker'] : true, + 'intent' => array_filter(array( 'purpose' => $remote['purpose'] ?? null, 'owner_run_ref' => $remote['owner_run_ref'] ?? null, 'cleanup_policy' => $remote['cleanup_policy'] ?? null, - ), static fn( $value ) => null !== $value) - ); + ), static fn( $value ) => null !== $value), + ))); if ( is_wp_error($result) ) { return $result; } diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index a2956142..2387378d 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -272,7 +272,7 @@ public function worktree_apply_plan( array $plan ): array|\WP_Error { if ( '' === $expected || array() === $input ) { return new \WP_Error('invalid_worktree_plan', 'A digest-addressed worktree plan with apply_intent is required.', array( 'status' => 400 )); } - $current = $this->worktree_plan( (string) ( $input['repo'] ?? '' ), (string) ( $input['branch'] ?? '' ), $input['from'] ?? null, ! empty($input['inject_context']), ! empty($input['bootstrap']), ! empty($input['allow_stale']), ! empty($input['rebase_base']), ! empty($input['force']), (array) ( $input['task'] ?? array() ), ! empty($input['allow_unverified_freshness']), ! empty($input['require_task_tracker']), (array) ( $input['intent'] ?? array() ), (string) ( $input['reuse_policy'] ?? 'reuse_compatible' ), ! empty($input['allow_percentage_byte_floor_exception']) ); + $current = $this->worktree_plan_request(WorktreeAllocationRequest::from_input($input)); if ( is_wp_error($current) ) { return $current; } @@ -284,7 +284,9 @@ public function worktree_apply_plan( array $plan ): array|\WP_Error { 'disposition' => $current['disposition'] ?? null, )); } - $result = $this->worktree_add( (string) $input['repo'], (string) $input['branch'], $input['from'] ?? null, ! empty($input['inject_context']), ! empty($input['bootstrap']), ! empty($input['allow_stale']), ! empty($input['rebase_base']), ! empty($input['force']), (array) ( $input['task'] ?? array() ), ! empty($input['allow_unverified_freshness']), ! empty($input['require_task_tracker']), (array) ( $input['intent'] ?? array() ), (string) ( $input['reuse_policy'] ?? 'reuse_compatible' ), false, false, null, (array) ( $plan['freshness']['identity'] ?? array() ), ! empty($input['allow_percentage_byte_floor_exception']) ); + $result = $this->worktree_add_request(WorktreeAllocationRequest::from_input($input + array( + 'expected_freshness_identity' => (array) ( $plan['freshness']['identity'] ?? array() ), + ))); if ( is_wp_error($result) && 'stale_worktree_freshness' === $result->get_error_code() ) { $error_data = (array) $result->get_error_data(); return new \WP_Error( @@ -919,7 +921,7 @@ public function worktree_apply_legacy_handoff( array $plan, string $mode ): arra if ( 'replace_isolated' === $mode && array() !== WorktreeContextInjector::missing_isolation_intent( (array) ( $input['intent'] ?? array() )) ) { return new \WP_Error('legacy_handoff_isolation_intent_required', 'An isolated replacement requires purpose, owner_run_ref, and cleanup_policy=remove_on_success before the old candidate can be superseded.', array( 'status' => 400 )); } - $current = $this->worktree_plan( (string) ( $input['repo'] ?? '' ), (string) ( $input['branch'] ?? '' ), $input['from'] ?? null, ! empty($input['inject_context']), ! empty($input['bootstrap']), ! empty($input['allow_stale']), ! empty($input['rebase_base']), ! empty($input['force']), (array) ( $input['task'] ?? array() ), ! empty($input['allow_unverified_freshness']), ! empty($input['require_task_tracker']), (array) ( $input['intent'] ?? array() ), (string) ( $input['reuse_policy'] ?? 'reuse_compatible' ), ! empty($input['allow_percentage_byte_floor_exception']) ); + $current = $this->worktree_plan_request(WorktreeAllocationRequest::from_input($input)); if ( is_wp_error($current) || ! hash_equals($expected, (string) ( $current['digest'] ?? '' )) || 'legacy_handoff_required' !== ( $current['disposition'] ?? null ) ) { return new \WP_Error('stale_legacy_handoff_plan', 'The legacy handoff plan no longer has complete safety proof.', array( 'status' => 409, @@ -971,7 +973,9 @@ public function worktree_apply_legacy_handoff( array $plan, string $mode ): arra if ( is_wp_error($stored) ) { return $stored; } - $result = $this->worktree_add( (string) $input['repo'], (string) $input['branch'], $input['from'] ?? null, ! empty($input['inject_context']), ! empty($input['bootstrap']), ! empty($input['allow_stale']), ! empty($input['rebase_base']), ! empty($input['force']), (array) ( $input['task'] ?? array() ), ! empty($input['allow_unverified_freshness']), ! empty($input['require_task_tracker']), (array) ( $input['intent'] ?? array() ), 'isolated', false, false, null, array(), ! empty($input['allow_percentage_byte_floor_exception']) ); + $result = $this->worktree_add_request(WorktreeAllocationRequest::from_input(array_merge($input, array( + 'reuse_policy' => 'isolated', + )))); if ( is_wp_error($result) ) { return $result; } diff --git a/inc/Workspace/WorktreeAllocationRequest.php b/inc/Workspace/WorktreeAllocationRequest.php index 403795d0..5ff7fd65 100644 --- a/inc/Workspace/WorktreeAllocationRequest.php +++ b/inc/Workspace/WorktreeAllocationRequest.php @@ -40,19 +40,21 @@ public function __construct( /** Build the canonical request accepted by ability and operation adapters. */ public static function from_input( array $input, bool $require_task_tracker_default = true ): self { + $task_input = is_array($input['task'] ?? null) ? $input['task'] : $input; $task = array_filter( array( - 'task_url' => $input['task_url'] ?? null, - 'task_ref' => $input['task_ref'] ?? null, + 'task_url' => $task_input['task_url'] ?? null, + 'task_ref' => $task_input['task_ref'] ?? null, ), static fn( mixed $value ): bool => is_string($value) && '' !== trim($value) ); $task = WorktreeContextInjector::resolve_task_metadata($task) ?? array(); - $intent = array(); + $intent_input = is_array($input['intent'] ?? null) ? $input['intent'] : $input; + $intent = array(); foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) { - if ( array_key_exists($key, $input) ) { - $intent[ $key ] = $input[ $key ]; + if ( array_key_exists($key, $intent_input) ) { + $intent[ $key ] = $intent_input[ $key ]; } } diff --git a/tests/worktree-allocation-request.php b/tests/worktree-allocation-request.php index 5a9e2872..f0ff7d9e 100644 --- a/tests/worktree-allocation-request.php +++ b/tests/worktree-allocation-request.php @@ -77,6 +77,23 @@ function allocation_request_assert( bool $condition, string $message ): void { allocation_request_assert('isolated' === $request->reuse_policy, 'Explicit reuse policy should be preserved.'); allocation_request_assert($progress === $request->progress_callback, 'Progress callbacks should be preserved.'); allocation_request_assert(array( 'target_head' => 'abc123' ) === $request->expected_freshness_identity, 'Freshness identity should be preserved.'); + + $apply_request = WorktreeAllocationRequest::from_input(array( + 'repo' => 'data-machine-code', + 'branch' => 'refactor/1243', + 'task' => array( + 'task_url' => 'https://github.com/Extra-Chill/data-machine-code/issues/1243', + 'task_ref' => 'Extra-Chill/data-machine-code#1243', + ), + 'intent' => array( + 'purpose' => 'plan-apply', + 'owner_run_ref' => 'test-run', + 'cleanup_policy' => 'remove_on_success', + ), + )); + + allocation_request_assert('Extra-Chill/data-machine-code#1243' === $apply_request->task['task_ref'], 'Nested plan task metadata should be preserved.'); + allocation_request_assert('plan-apply' === $apply_request->intent['purpose'], 'Nested plan intent should be preserved.'); } finally { false === $previous_task_url ? putenv('DATAMACHINE_TASK_URL') : putenv('DATAMACHINE_TASK_URL=' . $previous_task_url); false === $previous_task_ref ? putenv('DATAMACHINE_TASK_REF') : putenv('DATAMACHINE_TASK_REF=' . $previous_task_ref); From 0b59f721b1e8f6e5cf1da2a656be6b9eafc72f22 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:22:32 -0400 Subject: [PATCH 04/10] refactor(cleanup): extract run control operation --- inc/Cleanup/CleanupRunControlOperation.php | 181 ++++++++++++++++++ inc/Cli/Commands/WorkspaceCommand.php | 172 ++--------------- .../workspace-cleanup-drainability-repair.php | 26 +++ 3 files changed, 224 insertions(+), 155 deletions(-) create mode 100644 inc/Cleanup/CleanupRunControlOperation.php diff --git a/inc/Cleanup/CleanupRunControlOperation.php b/inc/Cleanup/CleanupRunControlOperation.php new file mode 100644 index 00000000..85d21c13 --- /dev/null +++ b/inc/Cleanup/CleanupRunControlOperation.php @@ -0,0 +1,181 @@ +command_runner = \Closure::fromCallable($command_runner); + $this->ability_resolver = \Closure::fromCallable($ability_resolver); + } + + /** Drain a queued parent and its active child jobs, then return terminal evidence. */ + public function drain( array $result, bool $verbose = false ): array { + $job_id = (int) ( $result['job_id'] ?? 0 ); + $run_id = (string) ( $result['run_id'] ?? ( $job_id > 0 ? self::run_id($job_id) : '' ) ); + if ( $job_id <= 0 || '' === $run_id ) { + $result['drain'] = array( + 'success' => false, + 'error' => 'Cleanup run did not return a job id to drain.', + ); + return $result; + } + + $commands = array(); + $errors = array(); + $drainability_repairs = array(); + $repaired_child_ids = array(); + $parent_command = sprintf('datamachine drain --job-id=%d', $job_id); + $commands[] = 'studio wp ' . $parent_command; + $error = (string) ( $this->command_runner )($parent_command); + if ( '' !== $error ) { + $errors[] = $error; + } + + for ( $pass = 0; $pass < 10; ++$pass ) { + $status = $this->evidence_store->read($run_id, true, true); + if ( $status instanceof \WP_Error ) { + $errors[] = $status->get_error_message(); + break; + } + + $children = (array) ( $status['evidence']['children'] ?? array() ); + $undrainable_child_ids = self::job_ids((array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); + if ( array() !== $undrainable_child_ids ) { + $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_child_ids); + $pass_repaired_child_ids = array_values(array_diff($undrainable_child_ids, (array) $repair['unrepairable'])); + $repaired_child_ids = array_values(array_unique(array_merge($repaired_child_ids, $pass_repaired_child_ids))); + $drainability_repairs[] = array( + 'pass' => $pass + 1, + 'detected_child_job_ids' => $undrainable_child_ids, + 'repaired_child_job_ids' => $pass_repaired_child_ids, + 'unrepairable_child_job_ids' => (array) $repair['unrepairable'], + ); + } + + $active_child_ids = self::job_ids(array_merge( + (array) ( $children['pending_job_ids'] ?? array() ), + (array) ( $children['processing_job_ids'] ?? array() ) + )); + if ( array() === $active_child_ids ) { + break; + } + + $child_command = sprintf('datamachine drain --job-id=%s', implode(',', $active_child_ids)); + $commands[] = 'studio wp ' . $child_command; + $error = (string) ( $this->command_runner )($child_command); + if ( '' !== $error ) { + $errors[] = $error; + break; + } + } + + $final = $this->evidence_store->read($run_id, false, $verbose); + $output = $final instanceof \WP_Error ? $result : $final; + $output['initial_run'] = $result; + $output['drain'] = array( + 'success' => array() === $errors, + 'commands' => $commands, + 'errors' => $errors, + 'verify_command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), + 'bytes_reclaimed' => (int) ( $output['cleanup_items']['bytes_reclaimed'] ?? 0 ), + 'freed_human' => (string) ( $output['cleanup_items']['freed_human'] ?? '0 B' ), + 'completion_state' => (string) ( $output['state'] ?? 'unknown' ), + 'drainability_repairs' => $drainability_repairs, + 'repaired_child_job_ids' => $repaired_child_ids, + ); + + return $output; + } + + /** Resume or cancel the parent and active child jobs for a cleanup run. */ + public function control( string $operation, int $job_id, bool $force = false ): array|\WP_Error { + if ( ! in_array($operation, array( 'resume', 'cancel' ), true) || $job_id <= 0 ) { + return new \WP_Error('invalid_cleanup_run_control', 'A supported cleanup control operation and positive job id are required.', array( 'status' => 400 )); + } + + $ability_name = 'resume' === $operation ? 'datamachine-code/retry-job' : 'datamachine-code/fail-job'; + $ability = ( $this->ability_resolver )($ability_name); + if ( ! is_object($ability) || ! method_exists($ability, 'execute') ) { + return new \WP_Error('cleanup_job_control_ability_missing', sprintf('Job control ability not registered: %s', $ability_name), array( 'status' => 500 )); + } + + $target_job_ids = $this->control_job_ids($operation, $job_id); + $results = array(); + foreach ( $target_job_ids as $target_job_id ) { + $input = array( 'job_id' => $target_job_id ); + if ( 'resume' === $operation ) { + $input['force'] = $force; + } else { + $input['reason'] = 'cleanup_cancelled'; + } + + $result = $ability->execute($input); + if ( $result instanceof \WP_Error ) { + return $result; + } + if ( ! is_array($result) || ! ( $result['success'] ?? false ) ) { + $message = is_array($result) ? (string) ( $result['error'] ?? 'Cleanup run control failed.' ) : 'Cleanup run control returned an invalid result.'; + return new \WP_Error('cleanup_run_control_failed', $message, array( 'status' => 500 )); + } + $results[] = $result; + } + + $output = $results[0] ?? array( 'success' => true, 'job_id' => $job_id ); + $output['run_id'] = self::run_id($job_id); + $output['state'] = 'resume' === $operation ? 'running' : 'cancelled'; + $output['controlled_job_ids'] = $target_job_ids; + $output['results'] = $results; + return $output; + } + + /** Resolve which Data Machine jobs should be controlled for the cleanup run. */ + private function control_job_ids( string $operation, int $job_id ): array { + $output = $this->evidence_store->read(self::run_id($job_id), true, true); + if ( $output instanceof \WP_Error ) { + return array( $job_id ); + } + + $children = (array) ( $output['evidence']['children'] ?? array() ); + $processing_ids = self::job_ids((array) ( $children['processing_job_ids'] ?? array() )); + $failed_ids = self::job_ids((array) ( $children['failed_job_ids'] ?? array() )); + $pending_ids = self::job_ids((array) ( $children['pending_job_ids'] ?? array() )); + $undrainable_ids = self::job_ids((array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); + + if ( 'resume' === $operation ) { + $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_ids); + $child_targets = self::job_ids(array_merge($processing_ids, $failed_ids)); + if ( array() === $child_targets && (int) $repair['repaired'] > 0 ) { + return array(); + } + return array() !== $child_targets ? $child_targets : array( $job_id ); + } + + return self::job_ids(array_merge(array( $job_id ), $pending_ids, $processing_ids)); + } + + /** @return array */ + private static function job_ids( array $job_ids ): array { + return array_values(array_unique(array_filter(array_map('intval', $job_ids)))); + } + + private static function run_id( int $job_id ): string { + return 'cleanup-run-' . $job_id; + } +} diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index af49c9b1..c498eb0d 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -24,7 +24,7 @@ use DataMachineCode\Cli\WorkspaceCompactOutput; use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; -use DataMachineCode\Support\SystemTaskDrainability; +use DataMachineCode\Cleanup\CleanupRunControlOperation; use DataMachineCode\Workspace\Workspace; use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorkspaceMutationLock; @@ -654,6 +654,7 @@ public static function standalone_worktree_provider_capabilities(): array { } private ?CleanupRunEvidenceStoreInterface $cleanup_run_evidence_store = null; + private ?CleanupRunControlOperation $cleanup_run_control_operation = null; /** * Show the workspace directory path. @@ -1867,99 +1868,7 @@ private function attach_cleanup_run_commands( array $result, string $mode ): arr * @return array */ private function drain_cleanup_run_to_status( array $result, array $assoc_args ): array { - $job_id = (int) ( $result['job_id'] ?? 0 ); - $run_id = (string) ( $result['run_id'] ?? ( $job_id > 0 ? $this->cleanup_run_id( $job_id ) : '' ) ); - if ( $job_id <= 0 || '' === $run_id ) { - $result['drain'] = array( - 'success' => false, - 'error' => 'Cleanup run did not return a job id to drain.', - ); - return $result; - } - - $commands = array(); - $errors = array(); - $drainability_repairs = array(); - $repaired_child_ids = array(); - $max_passes = 10; - - $parent_command = sprintf( 'datamachine drain --job-id=%d', $job_id ); - $commands[] = 'studio wp ' . $parent_command; - $error = $this->run_wp_cli_command( $parent_command ); - if ( '' !== $error ) { - $errors[] = $error; - } - - for ( $pass = 0; $pass < $max_passes; ++$pass ) { - $status = $this->cleanup_run_evidence_store()->read( $run_id, true, true ); - if ( $status instanceof \WP_Error ) { - $errors[] = $status->get_error_message(); - break; - } - - $children = (array) ( $status['evidence']['children'] ?? array() ); - $undrainable_child_ids = array_values( - array_unique( - array_filter( - array_map( 'intval', (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() ) ) - ) - ) - ); - if ( array() !== $undrainable_child_ids ) { - $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions( $undrainable_child_ids ); - $pass_repaired_child_ids = array_values( - array_diff( $undrainable_child_ids, (array) $repair['unrepairable'] ) - ); - $repaired_child_ids = array_values( array_unique( array_merge( $repaired_child_ids, $pass_repaired_child_ids ) ) ); - $drainability_repairs[] = array( - 'pass' => $pass + 1, - 'detected_child_job_ids' => $undrainable_child_ids, - 'repaired_child_job_ids' => $pass_repaired_child_ids, - 'unrepairable_child_job_ids' => (array) $repair['unrepairable'], - ); - } - $active_child_ids = array_values( - array_unique( - array_filter( - array_map( - 'intval', - array_merge( - (array) ( $children['pending_job_ids'] ?? array() ), - (array) ( $children['processing_job_ids'] ?? array() ) - ) - ) - ) - ) - ); - if ( array() === $active_child_ids ) { - break; - } - - $child_command = sprintf( 'datamachine drain --job-id=%s', implode( ',', $active_child_ids ) ); - $commands[] = 'studio wp ' . $child_command; - $error = $this->run_wp_cli_command( $child_command ); - if ( '' !== $error ) { - $errors[] = $error; - break; - } - } - - $final = $this->cleanup_run_evidence_store()->read( $run_id, false, ! empty( $assoc_args['verbose'] ) ); - $output = $final instanceof \WP_Error ? $result : $final; - $output['initial_run'] = $result; - $output['drain'] = array( - 'success' => array() === $errors, - 'commands' => $commands, - 'errors' => $errors, - 'verify_command' => sprintf( 'studio wp datamachine-code workspace cleanup status %s --format=json', $run_id ), - 'bytes_reclaimed' => (int) ( $output['cleanup_items']['bytes_reclaimed'] ?? 0 ), - 'freed_human' => (string) ( $output['cleanup_items']['freed_human'] ?? $this->format_bytes( 0 ) ), - 'completion_state' => (string) ( $output['state'] ?? 'unknown' ), - 'drainability_repairs' => $drainability_repairs, - 'repaired_child_job_ids' => $repaired_child_ids, - ); - - return $output; + return $this->cleanup_run_control_operation()->drain($result, ! empty($assoc_args['verbose'])); } /** @@ -2240,72 +2149,25 @@ private function cleanup_run_evidence_store(): CleanupRunEvidenceStoreInterface return $this->cleanup_run_evidence_store; } - private function control_cleanup_run_job( string $operation, int $job_id, array $assoc_args ): void { - $ability_name = 'resume' === $operation ? 'datamachine-code/retry-job' : 'datamachine-code/fail-job'; - $ability = wp_get_ability( $ability_name ); - if ( ! $ability ) { - WP_CLI::error( sprintf( 'Job control ability not registered: %s', $ability_name ) ); - return; - } - - $target_job_ids = $this->cleanup_run_control_job_ids( $operation, $job_id ); - $results = array(); - foreach ( $target_job_ids as $target_job_id ) { - $input = array( 'job_id' => $target_job_id ); - if ( 'resume' === $operation ) { - $input['force'] = ! empty( $assoc_args['force'] ); - } else { - $input['reason'] = 'cleanup_cancelled'; - } - - $result = $ability->execute( $input ); - if ( ! ( $result['success'] ?? false ) ) { - WP_CLI::error( (string) ( $result['error'] ?? 'Cleanup run control failed.' ) ); - return; - } - $results[] = $result; + private function cleanup_run_control_operation(): CleanupRunControlOperation { + if ( null === $this->cleanup_run_control_operation ) { + $this->cleanup_run_control_operation = new CleanupRunControlOperation( + $this->cleanup_run_evidence_store(), + fn( string $command ): string => $this->run_wp_cli_command($command), + static fn( string $ability_name ): mixed => wp_get_ability($ability_name) + ); } - $output = $results[0] ?? array( - 'success' => true, - 'job_id' => $job_id, - ); - $output['run_id'] = $this->cleanup_run_id( $job_id ); - $output['state'] = 'resume' === $operation ? 'running' : 'cancelled'; - $output['controlled_job_ids'] = $target_job_ids; - $output['results'] = $results; - $this->render_cleanup_control_result( $output, $assoc_args ); + return $this->cleanup_run_control_operation; } - /** - * Resolve which Data Machine jobs should be controlled for a job-backed cleanup run. - * - * @param string $operation Cleanup control operation. - * @param int $job_id Cleanup parent job ID. - * @return array - */ - private function cleanup_run_control_job_ids( string $operation, int $job_id ): array { - $output = $this->cleanup_run_evidence_store()->read( $this->cleanup_run_id( $job_id ), true, true ); - if ( $output instanceof \WP_Error ) { - return array( $job_id ); - } - - $children = (array) ( $output['evidence']['children'] ?? array() ); - $processing_ids = array_map( 'intval', (array) ( $children['processing_job_ids'] ?? array() ) ); - $failed_ids = array_map( 'intval', (array) ( $children['failed_job_ids'] ?? array() ) ); - $pending_ids = array_map( 'intval', (array) ( $children['pending_job_ids'] ?? array() ) ); - $undrainable_ids = array_map( 'intval', (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() ) ); - - if ( 'resume' === $operation ) { - $repair = \DataMachineCode\Support\SystemTaskDrainability::ensure_jobs_have_execute_step_actions( $undrainable_ids ); - $child_targets = array_values( array_unique( array_filter( array_merge( $processing_ids, $failed_ids ) ) ) ); - if ( array() === $child_targets && (int) $repair['repaired'] > 0 ) { - return array(); - } - return array() !== $child_targets ? $child_targets : array( $job_id ); + private function control_cleanup_run_job( string $operation, int $job_id, array $assoc_args ): void { + $output = $this->cleanup_run_control_operation()->control($operation, $job_id, ! empty($assoc_args['force'])); + if ( is_wp_error($output) ) { + WP_CLI::error($output->get_error_message()); + return; } - - return array_values( array_unique( array_filter( array_merge( array( $job_id ), $pending_ids, $processing_ids ) ) ) ); + $this->render_cleanup_control_result( $output, $assoc_args ); } private function render_cleanup_control_result( array $result, array $assoc_args, bool $full_evidence = false ): void { diff --git a/tests/workspace-cleanup-drainability-repair.php b/tests/workspace-cleanup-drainability-repair.php index 2f0faf62..d8d853f3 100644 --- a/tests/workspace-cleanup-drainability-repair.php +++ b/tests/workspace-cleanup-drainability-repair.php @@ -36,9 +36,11 @@ public static function runcommand( string $command, array $args ): string { require_once dirname(__DIR__) . '/inc/Cleanup/CleanupRunEvidenceStoreInterface.php'; require_once dirname(__DIR__) . '/inc/Support/SystemTaskDrainability.php'; + require_once dirname(__DIR__) . '/inc/Cleanup/CleanupRunControlOperation.php'; require_once dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php'; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; + use DataMachineCode\Cleanup\CleanupRunControlOperation; use DataMachineCode\Cli\Commands\WorkspaceCommand; $GLOBALS['workspace_cleanup_drainability_jobs'] = array( @@ -82,6 +84,16 @@ public function execute( array $input ): array { } } + final class WorkspaceCleanupControlAbility { + /** @var array> */ + public array $calls = array(); + + public function execute( array $input ): array { + $this->calls[] = $input; + return array( 'success' => true, 'job_id' => (int) $input['job_id'] ); + } + } + function wp_get_ability( string $name ): ?WorkspaceCleanupDrainabilityJobsAbility { return 'datamachine/get-jobs' === $name ? new WorkspaceCleanupDrainabilityJobsAbility() : null; } @@ -158,5 +170,19 @@ function workspace_cleanup_drainability_assert_same( mixed $expected, mixed $act workspace_cleanup_drainability_assert_same(true, $store->child_completion_observed, 'Drain should observe child terminal completion before reading terminal parent status.'); workspace_cleanup_drainability_assert_same('completed', $result['state'] ?? null, 'Terminal parent status should follow child completion.'); + $control_ability = new WorkspaceCleanupControlAbility(); + $control_operation = new CleanupRunControlOperation( + $store, + static fn( string $command ): string => '', + static fn( string $ability_name ): WorkspaceCleanupControlAbility => $control_ability + ); + $resumed = $control_operation->control('resume', 71, true); + workspace_cleanup_drainability_assert_same(array( 'job_id' => 71, 'force' => true ), $control_ability->calls[0] ?? null, 'Resume should target the parent with the explicit force policy.'); + workspace_cleanup_drainability_assert_same('running', $resumed instanceof \WP_Error ? null : $resumed['state'] ?? null, 'Resume should return running state.'); + + $cancelled = $control_operation->control('cancel', 71); + workspace_cleanup_drainability_assert_same(array( 'job_id' => 71, 'reason' => 'cleanup_cancelled' ), $control_ability->calls[1] ?? null, 'Cancel should target the parent with the cleanup cancellation reason.'); + workspace_cleanup_drainability_assert_same('cancelled', $cancelled instanceof \WP_Error ? null : $cancelled['state'] ?? null, 'Cancel should return cancelled state.'); + echo "workspace cleanup drainability repair test passed.\n"; } From 7fe9ecd1e4b9f0954c1a278d80d2201957968d94 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:34:34 -0400 Subject: [PATCH 05/10] refactor(tools): require ability projections --- README.md | 4 +- data-machine-code.php | 1 - inc/Tools/AbilityToolProjections.php | 8 + inc/Tools/GitHubTools.php | 1504 +-------------- inc/Tools/WorkspaceTools.php | 1793 ------------------ tests/ability-tool-projections.php | 114 ++ tests/workspace-list-cli-format-contract.php | 15 - tests/workspace-tool-error-envelope.php | 58 - tests/worktree-add-tool-tracker-contract.php | 89 - 9 files changed, 217 insertions(+), 3369 deletions(-) delete mode 100644 inc/Tools/WorkspaceTools.php create mode 100644 tests/ability-tool-projections.php delete mode 100644 tests/workspace-tool-error-envelope.php delete mode 100644 tests/worktree-add-tool-tracker-contract.php diff --git a/README.md b/README.md index 0bc61634..d1c4b183 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ on it are how parallel agents corrupt each other's work. - WordPress 6.9+ - PHP 8.2+ -- [Data Machine](https://github.com/Extra-Chill/data-machine) plugin (core) +- [Data Machine](https://github.com/Extra-Chill/data-machine) 0.139.6+ when using model-facing ability tool projections - A driver for the abilities you plan to use — at least one of: - An external coding-agent runtime on the same host; see [`wp-coding-agents`](https://github.com/Extra-Chill/wp-coding-agents) for an opinionated setup. - A Data Machine flow on the site that calls DMC's tools / abilities (in-process driver). @@ -288,6 +288,8 @@ on it are how parallel agents corrupt each other's work. - Shell-backed workspace/git features require `exec()`, a local `git` binary, and a visible writable workspace path. DMC's abilities still register without a co-located runtime. API-first flows can exercise GitHub abilities directly; an idle workspace is only relevant when using workspace/git abilities. +Model-facing workspace tools and one-to-one GitHub tools are projected directly from their canonical abilities through Data Machine. The remaining `GitHubTools` wrappers are intentionally bespoke: `manage_github_issue` composes update, close, and comment abilities, while `add_label_to_issue` adapts a single model-facing `label` to the canonical ability's `labels` collection. + ## Installation 1. Install and activate Data Machine core diff --git a/data-machine-code.php b/data-machine-code.php index 800391e3..fc12e2de 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -480,7 +480,6 @@ function datamachine_code_load_chat_tools() { new \DataMachineCode\Tools\GitHubIssueTool(); new \DataMachineCode\Tools\GitHubPullRequestTool(); new \DataMachineCode\Tools\GitHubTools(); - new \DataMachineCode\Tools\WorkspaceTools(); new \DataMachineCode\Tools\WorkspaceDiffTools(); new \DataMachineCode\Tools\WordPressRuntimeTools(); } diff --git a/inc/Tools/AbilityToolProjections.php b/inc/Tools/AbilityToolProjections.php index f271a0c0..67506f5f 100644 --- a/inc/Tools/AbilityToolProjections.php +++ b/inc/Tools/AbilityToolProjections.php @@ -65,7 +65,9 @@ public static function projected_tools(): array { 'workspace_run_runner_command' => self::workspace_write('datamachine-code/run-runner-workspace-command'), 'workspace_git_rebase' => self::workspace_write('datamachine-code/workspace-git-rebase'), 'workspace_git_reset' => self::workspace_write('datamachine-code/workspace-git-reset'), + 'workspace_worktree_plan' => self::workspace('datamachine-code/workspace-worktree-plan'), 'workspace_worktree_add' => self::workspace_write('datamachine-code/workspace-worktree-add'), + 'workspace_publish_runner' => self::workspace_write('datamachine-code/publish-runner-workspace'), 'workspace_pr_status' => self::workspace_write('datamachine-code/workspace-pr-status'), 'workspace_pr_rebase' => self::workspace_write('datamachine-code/workspace-pr-rebase'), @@ -83,6 +85,12 @@ public static function projected_tools(): array { 'list_github_tree' => self::github('datamachine-code/list-github-tree'), 'get_github_file' => self::github('datamachine-code/get-github-file'), 'list_github_repos' => self::github('datamachine-code/list-github-repos'), + 'remove_label_from_issue' => self::github('datamachine-code/remove-github-label'), + 'comment_github_pull_request' => self::github('datamachine-code/comment-github-pull-request'), + 'upsert_github_pull_review_comment' => self::github('datamachine-code/upsert-github-pull-review-comment'), + 'merge_github_pull_request' => self::github('datamachine-code/merge-github-pull-request'), + 'cleanup_github_pull_request' => self::github('datamachine-code/cleanup-github-pull-request'), + 'create_or_update_github_file' => self::github('datamachine-code/create-or-update-github-file'), ); } diff --git a/inc/Tools/GitHubTools.php b/inc/Tools/GitHubTools.php index fc1dccf9..72e94a1d 100644 --- a/inc/Tools/GitHubTools.php +++ b/inc/Tools/GitHubTools.php @@ -1,13 +1,10 @@ registerProjectedToolFallback('list_github_issues', array( $this, 'getListIssuesDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-issues' )); - $this->registerProjectedToolFallback('get_github_issue', array( $this, 'getGetIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-issue' )); - $this->registerTool('manage_github_issue', array( $this, 'getManageIssueDefinition' ), $contexts, array( 'access_level' => 'editor' )); - $this->registerTool('add_label_to_issue', array( $this, 'getAddLabelToIssueDefinition' ), $contexts, array( 'access_level' => 'editor' )); - $this->registerTool('remove_label_from_issue', array( $this, 'getRemoveLabelFromIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/remove-github-label' )); - $this->registerTool('comment_github_pull_request', array( $this, 'getCommentPullRequestDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/comment-github-pull-request' )); - $this->registerTool( - 'upsert_github_pull_review_comment', array( $this, 'getUpsertPullReviewCommentDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/upsert-github-pull-review-comment', - ) - ); - $this->registerTool( - 'merge_github_pull_request', array( $this, 'getMergePullRequestDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/merge-github-pull-request', - ) - ); - $this->registerTool( - 'cleanup_github_pull_request', array( $this, 'getCleanupPullRequestDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/cleanup-github-pull-request', - ) - ); - $this->registerProjectedToolFallback('list_github_pulls', array( $this, 'getListPullsDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-pulls' )); - $this->registerProjectedToolFallback( - 'get_github_pull', array( $this, 'getGetPullDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-pull', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_pull_files', array( $this, 'getPullFilesDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/list-github-pull-files', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_check_runs', array( $this, 'getCheckRunsDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-check-runs', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_commit_statuses', array( $this, 'getCommitStatusesDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-commit-statuses', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_actions_artifact', array( $this, 'getActionsArtifactDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-actions-artifact', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_pull_review_context', array( $this, 'getPullReviewContextDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-pull-review-context', - ) - ); - $this->registerProjectedToolFallback( - 'github_repo_review_profile', array( $this, 'getRepoReviewProfileDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-repo-review-profile', - ) - ); - $this->registerProjectedToolFallback( - 'github_pr_documentation_impact', array( $this, 'getPullDocumentationImpactDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-pr-documentation-impact', - ) - ); - $this->registerProjectedToolFallback( - 'list_github_tree', array( $this, 'getListTreeDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/list-github-tree', - ) - ); - $this->registerProjectedToolFallback( - 'get_github_file', array( $this, 'getGetFileDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/get-github-file', - ) - ); - $this->registerTool( - 'create_or_update_github_file', array( $this, 'getCreateOrUpdateFileDefinition' ), $contexts, array( - 'access_level' => 'editor', - 'ability' => 'datamachine-code/create-or-update-github-file', - ) - ); - $this->registerProjectedToolFallback('list_github_repos', array( $this, 'getListReposDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-repos' )); - } - - /** - * Register a legacy wrapper only when Data Machine cannot project the ability directly. - * - * @param string $tool_id Model-facing tool name. - * @param callable $definition_callback Definition callback. - * @param array $contexts Tool contexts. - * @param array $options Tool metadata. - */ - private function registerProjectedToolFallback( string $tool_id, callable $definition_callback, array $contexts, array $options ): void - { - if ( class_exists(AbilityToolProjections::class) && AbilityToolProjections::is_projected($tool_id) ) { - return; - } - - $this->registerTool($tool_id, $definition_callback, $contexts, $options); - } - - /** - * Handle tool call — dispatches to the appropriate handler based on tool_def. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition with 'method' key. - * @return array - */ - public function handle_tool_call( array $parameters, array $tool_def = array() ): array - { - $method = $tool_def['method'] ?? 'handleListIssues'; - if (method_exists($this, $method) ) { - return $this->{$method}($parameters, $tool_def); - } - return $this->buildErrorResponse("Unknown method: {$method}", 'github_tools'); - } - - /** - * Build a standard GitHub tool error response. - * - * @param string $message Error message. - * @param string $tool_name Tool name. - * @return array - */ - protected function buildErrorResponse( string $message, string $tool_name ): array - { - return array( - 'success' => false, - 'error' => $message, - 'tool_name' => $tool_name, - ); - } - - /** - * Check if GitHub tools are properly configured. - * - * @param bool $configured Current configuration status. - * @param string $tool_id Tool identifier to check. - * @return bool True if configured. - */ - public function check_configuration( $configured, $tool_id ) - { - $github_tools = array( - 'list_github_issues', - 'get_github_issue', - 'manage_github_issue', - 'add_label_to_issue', - 'remove_label_from_issue', - 'comment_github_pull_request', - 'upsert_github_pull_review_comment', - 'merge_github_pull_request', - 'cleanup_github_pull_request', - 'list_github_pulls', - 'get_github_pull', - 'get_github_pull_files', - 'get_github_check_runs', - 'get_github_commit_statuses', - 'get_github_actions_artifact', - 'get_github_pull_review_context', - 'github_repo_review_profile', - 'github_pr_documentation_impact', - 'list_github_tree', - 'get_github_file', - 'create_or_update_github_file', - 'list_github_repos', - ); - if (! in_array($tool_id, $github_tools, true) ) { - return $configured; - } - return GitHubAbilities::isConfigured(); - } - - /** - * Check if GitHub tools are configured. - * - * @return bool - */ - public static function is_configured(): bool - { - return GitHubAbilities::isConfigured(); - } - - /** - * Get tool definition — returns the primary tool definition (list issues). - * - * Individual tools use their own definition methods via registerTool. - * - * @return array Tool definition array. - */ - public function getToolDefinition(): array - { - return $this->getListIssuesDefinition(); - } - - // ------------------------------------------------------------------------- - // List Issues - // ------------------------------------------------------------------------- - - /** - * Handle list_github_issues tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleListIssues( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/list-github-issues', 'list_github_issues', $parameters); + public function __construct() { + $contexts = array( 'chat', 'pipeline' ); + $this->registerTool('manage_github_issue', array( $this, 'getManageIssueDefinition' ), $contexts, array( 'access_level' => 'editor' )); + $this->registerTool('add_label_to_issue', array( $this, 'getAddLabelToIssueDefinition' ), $contexts, array( 'access_level' => 'editor' )); } - /** - * Get tool definition for list_github_issues. - * - * @return array - */ - public function getListIssuesDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleListIssues', - 'description' => 'List issues from a GitHub repository. Returns issue numbers, titles, states, labels, assignees, comment counts, timestamps, and a generated_at timestamp. Use to review open issues, track progress, or find specific issues by label.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format (e.g., Extra-Chill/data-machine).', - ), - 'state' => array( - 'type' => 'string', - 'description' => 'Issue state: open, closed, or all. Default: open.', - ), - 'labels' => array( - 'type' => 'string', - 'description' => 'Comma-separated label names to filter by.', - ), - 'per_page' => array( - 'type' => 'integer', - 'description' => 'Results per page (max: 100). Default: 30.', - ), - ), - 'required' => array( 'repo' ), - ), - ); - } - - // ------------------------------------------------------------------------- - // Get Issue - // ------------------------------------------------------------------------- + /** Dispatch a bespoke tool definition to its handler. */ + public function handle_tool_call( array $parameters, array $tool_def = array() ): array { + $method = $tool_def['method'] ?? 'handleManageIssue'; + if ( method_exists($this, $method) ) { + return $this->{$method}($parameters, $tool_def); + } + return $this->buildErrorResponse('Unknown method: ' . $method, 'github_tools'); + } - /** - * Handle get_github_issue tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleGetIssue( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-issue', 'get_github_issue', $parameters); + public function check_configuration( mixed $configured, mixed $tool_id ): bool { + if ( ! in_array($tool_id, array( 'manage_github_issue', 'add_label_to_issue' ), true) ) { + return (bool) $configured; + } + return self::is_configured(); } - /** - * Get tool definition for get_github_issue. - * - * @return array - */ - public function getGetIssueDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleGetIssue', - 'description' => 'Get a single GitHub issue with full details including body, labels, assignees, comment count, timestamps, generated_at, and latest comment metadata when comments exist.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'issue_number' => array( - 'type' => 'integer', - 'description' => 'Issue number.', - ), - ), - 'required' => array( 'repo', 'issue_number' ), - ), - ) - ); - } + public static function is_configured(): bool { + return GitHubAbilities::isConfigured(); + } - // ------------------------------------------------------------------------- - // Manage Issue (update, close, comment) - // ------------------------------------------------------------------------- + public function getToolDefinition(): array { + return $this->getManageIssueDefinition(); + } - /** - * Handle manage_github_issue tool call. - * - * Supports three actions: update, close, comment. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleManageIssue( array $parameters, array $tool_def = array() ): array - { + /** Compose issue update, close, and comment abilities behind one stable tool. */ + public function handleManageIssue( array $parameters, array $tool_def = array() ): array { $action = $parameters['action'] ?? ''; - - if ('comment' === $action ) { - $comment_parameters = array( + if ( 'comment' === $action ) { + $input = array( 'repo' => $parameters['repo'] ?? '', 'issue_number' => $parameters['issue_number'] ?? 0, 'body' => $parameters['body'] ?? '', ); - - if (array_key_exists('allow_repeat_automation_comment', $parameters) ) { - $comment_parameters['allow_repeat_automation_comment'] = $parameters['allow_repeat_automation_comment']; + if ( array_key_exists('allow_repeat_automation_comment', $parameters) ) { + $input['allow_repeat_automation_comment'] = $parameters['allow_repeat_automation_comment']; } - - return $this->executeGitHubAbility('datamachine-code/comment-github-issue', 'manage_github_issue', $comment_parameters); - } elseif ('close' === $action ) { + return $this->executeGitHubAbility('datamachine-code/comment-github-issue', 'manage_github_issue', $input); + } + if ( 'close' === $action ) { $parameters['state'] = 'closed'; } - return $this->executeGitHubAbility('datamachine-code/update-github-issue', 'manage_github_issue', $parameters); } - /** - * Get tool definition for manage_github_issue. - * - * @return array - */ - public function getManageIssueDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleManageIssue', - 'description' => 'Update, close, or comment on a GitHub issue. Use action "update" to change title/body or replace the full labels set, "close" to close the issue, or "comment" to add a comment. For surgical label edits that preserve other labels, use add_label_to_issue or remove_label_from_issue instead - action=update replaces the full label set.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'issue_number' => array( - 'type' => 'integer', - 'description' => 'Issue number.', - ), - 'action' => array( - 'type' => 'string', - 'description' => 'Action: update, close, or comment.', - ), - 'title' => array( - 'type' => 'string', - 'description' => 'New issue title (update action).', - ), - 'body' => array( - 'type' => 'string', - 'description' => 'New issue body (update action) or comment text (comment action).', - ), - 'labels' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Labels to set (update action). REPLACES the entire existing label set. For surgical add/remove that preserves other labels, use add_label_to_issue / remove_label_from_issue.', - ), - 'allow_repeat_automation_comment' => array( - 'type' => 'boolean', - 'description' => 'For comment action only: allow a repeated automation comment when the latest issue comment is already from this automation actor. Default: false.', - ), - ), - 'required' => array( 'repo', 'issue_number', 'action' ), - ), - ) - ); - } - - // ------------------------------------------------------------------------- - // Surgical Issue Labels - // ------------------------------------------------------------------------- + public function getManageIssueDefinition(): array { + return $this->progressDefinition(array( + 'class' => self::class, + 'method' => 'handleManageIssue', + 'description' => 'Update, close, or comment on a GitHub issue. Use action "update" to change title, body, or the full labels set; "close" to close it; or "comment" to add a comment.', + 'parameters' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Repository in owner/repo format.' ), + 'issue_number' => array( 'type' => 'integer', 'description' => 'Issue number.' ), + 'action' => array( 'type' => 'string', 'enum' => array( 'update', 'close', 'comment' ) ), + 'title' => array( 'type' => 'string', 'description' => 'New issue title for update.' ), + 'body' => array( 'type' => 'string', 'description' => 'New issue body or comment text.' ), + 'labels' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Replacement labels set for update.' ), + 'allow_repeat_automation_comment' => array( 'type' => 'boolean', 'description' => 'Allow a repeated automation comment. Default false.' ), + ), + 'required' => array( 'repo', 'issue_number', 'action' ), + ), + )); + } - /** - * Handle add_label_to_issue tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleAddLabelToIssue( array $parameters, array $tool_def = array() ): array - { + /** Adapt one model-facing label to the canonical collection input. */ + public function handleAddLabelToIssue( array $parameters, array $tool_def = array() ): array { return $this->executeGitHubAbility( 'datamachine-code/add-github-labels', 'add_label_to_issue', @@ -439,1027 +100,46 @@ public function handleAddLabelToIssue( array $parameters, array $tool_def = arra ); } - /** - * Get tool definition for add_label_to_issue. - * - * @return array - */ - public function getAddLabelToIssueDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleAddLabelToIssue', - 'description' => 'Add a single label to an existing GitHub issue or pull request without replacing the rest of the label set. Use for surgical lifecycle transitions where preserving existing labels matters.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'issue_number' => array( - 'type' => 'integer', - 'description' => 'Issue or pull request number.', - ), - 'label' => array( - 'type' => 'string', - 'description' => 'Single label name to add. Existing labels are unchanged.', - ), - ), - 'required' => array( 'repo', 'issue_number', 'label' ), - ), - ); - } - - /** - * Handle remove_label_from_issue tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleRemoveLabelFromIssue( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/remove-github-label', 'remove_label_from_issue', $parameters); - } - - /** - * Get tool definition for remove_label_from_issue. - * - * @return array - */ - public function getRemoveLabelFromIssueDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleRemoveLabelFromIssue', - 'description' => 'Remove a single label from an existing GitHub issue or pull request without touching the rest of the label set. Use for surgical lifecycle transitions where preserving other labels matters. Returns success even if the label was already absent.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'issue_number' => array( - 'type' => 'integer', - 'description' => 'Issue or pull request number.', - ), - 'label' => array( - 'type' => 'string', - 'description' => 'Single label name to remove. Other labels are unchanged.', - ), - ), - 'required' => array( 'repo', 'issue_number', 'label' ), - ), - ); - } - - // ------------------------------------------------------------------------- - // Comment Pull Request - // ------------------------------------------------------------------------- - - /** - * Handle comment_github_pull_request tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleCommentPullRequest( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/comment-github-pull-request', 'comment_github_pull_request', $parameters); + public function getAddLabelToIssueDefinition(): array { + return array( + 'class' => self::class, + 'method' => 'handleAddLabelToIssue', + 'description' => 'Add one label to a GitHub issue or pull request without replacing its other labels.', + 'parameters' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Repository in owner/repo format.' ), + 'issue_number' => array( 'type' => 'integer', 'description' => 'Issue or pull request number.' ), + 'label' => array( 'type' => 'string', 'description' => 'Single label name to add.' ), + ), + 'required' => array( 'repo', 'issue_number', 'label' ), + ), + ); } - /** - * Get tool definition for comment_github_pull_request. - * - * @return array - */ - public function getCommentPullRequestDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleCommentPullRequest', - 'description' => 'Comment on a GitHub pull request without granting broader issue update or close capabilities.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'body' => array( - 'type' => 'string', - 'description' => 'Comment body (supports GitHub Markdown).', - ), - 'marker' => array( - 'type' => 'string', - 'description' => 'Optional stable marker appended as an HTML comment for future update-by-marker support.', - ), - ), - 'required' => array( 'repo', 'pull_number', 'body' ), - ), - ) - ); - } - - /** - * Handle upsert_github_pull_review_comment tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleUpsertPullReviewComment( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/upsert-github-pull-review-comment', 'upsert_github_pull_review_comment', $parameters); - } - - /** - * Get tool definition for upsert_github_pull_review_comment. - * - * @return array - */ - public function getUpsertPullReviewCommentDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleUpsertPullReviewComment', - 'description' => 'Create or update one managed bot-authored GitHub pull request review comment identified by a hidden marker.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'body' => array( - 'type' => 'string', - 'description' => 'Review comment body (supports GitHub Markdown). Hidden marker text is appended automatically.', - ), - 'marker' => array( - 'type' => 'string', - 'description' => 'Hidden HTML comment marker used to find the managed comment. Default: .', - ), - 'head_sha' => array( - 'type' => 'string', - 'description' => 'Optional pull request head SHA. Required to separate comments when mode is per_head_sha.', - ), - 'mode' => array( - 'type' => 'string', - 'description' => 'Comment policy: update_existing or per_head_sha. Default: update_existing.', - ), - ), - 'required' => array( 'repo', 'pull_number', 'body' ), - ), - ); - } - - /** - * Handle merge_github_pull_request tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleMergePullRequest( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/merge-github-pull-request', 'merge_github_pull_request', $parameters); - } - - /** - * Handle cleanup_github_pull_request tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleCleanupPullRequest( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/cleanup-github-pull-request', 'cleanup_github_pull_request', $parameters); - } - - /** - * Get tool definition for merge_github_pull_request. - * - * @return array - */ - public function getMergePullRequestDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleMergePullRequest', - 'description' => 'Merge an open GitHub pull request only when its current head SHA exactly matches expected_head_sha. Defaults to squash merge.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'expected_head_sha' => array( - 'type' => 'string', - 'description' => 'Exact head SHA expected immediately before merge.', - ), - 'merge_method' => array( - 'type' => 'string', - 'description' => 'GitHub merge method: merge, squash, or rebase. Default: squash.', - ), - 'delete_branch' => array( - 'type' => 'boolean', - 'description' => 'Delete the pull request head branch through the GitHub API after merge when the branch is in the same repository.', - ), - ), - 'required' => array( 'repo', 'pull_number', 'expected_head_sha' ), - ), - ); - } - - /** - * Get tool definition for cleanup_github_pull_request. - * - * @return array - */ - public function getCleanupPullRequestDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleCleanupPullRequest', - 'description' => 'Cleanup a merged pull request by finalizing the matching local DMC worktree before optional remote branch deletion. Supports dry_run previews and local_only cleanup.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'dry_run' => array( - 'type' => 'boolean', - 'description' => 'Preview the cleanup decision without deleting the branch.', - ), - 'local_only' => array( - 'type' => 'boolean', - 'description' => 'Finalize and remove the matching local DMC worktree without deleting the remote branch.', - ), - ), - 'required' => array( 'repo', 'pull_number' ), - ), - ); - } + private function executeGitHubAbility( string $ability_name, string $tool_name, array $parameters ): array { + $ability = function_exists('wp_get_ability') ? wp_get_ability($ability_name) : null; + if ( ! $ability ) { + return $this->buildErrorResponse(sprintf('GitHub ability %s is not available.', $ability_name), $tool_name); + } - // ------------------------------------------------------------------------- - // List Pulls - // ------------------------------------------------------------------------- + $result = $ability->execute($parameters); + if ( is_wp_error($result) ) { + return $this->buildErrorResponse($result->get_error_message(), $tool_name); + } - /** - * Handle list_github_pulls tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleListPulls( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/list-github-pulls', 'list_github_pulls', $parameters); + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => $tool_name, + ); } - /** - * Get tool definition for list_github_pulls. - * - * @return array - */ - public function getListPullsDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleListPulls', - 'description' => 'List pull requests from a GitHub repository. Returns PR numbers, titles, states, branches, merge status, comment counts, change counts, timestamps, and generated_at.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'state' => array( - 'type' => 'string', - 'description' => 'PR state: open, closed, or all. Default: open.', - ), - ), - 'required' => array( 'repo' ), - ), - ) - ); - } - - // ------------------------------------------------------------------------- - // Read-only PR and source context tools. - // ------------------------------------------------------------------------- - - /** - * Execute a registered GitHub ability for a tool call. - * - * @param string $ability_name Ability slug. - * @param string $tool_name Tool name for the response envelope. - * @param array $parameters Tool parameters. - * @return array - */ - private function executeGitHubAbility( string $ability_name, string $tool_name, array $parameters ): array - { - if (! function_exists('wp_get_ability') ) { - return $this->buildErrorResponse('WordPress Abilities API is not available.', $tool_name); - } - - $ability = wp_get_ability($ability_name); - if (! $ability ) { - return $this->buildErrorResponse(sprintf('GitHub ability %s is not available.', $ability_name), $tool_name); - } - - $result = $ability->execute($parameters); - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), $tool_name); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => $tool_name, - ); - } - - /** - * Handle get_github_pull tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleGetPull( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-pull', 'get_github_pull', $parameters); - } - - /** - * Get tool definition for get_github_pull. - * - * @return array - */ - public function getGetPullDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleGetPull', - 'description' => 'Get one GitHub pull request with normalized title, body, branch, SHA, labels, merge metadata, comment counts, change counts, timestamps, and generated_at.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - ), - 'required' => array( 'repo', 'pull_number' ), - ), - ) - ); - } - - /** - * Handle get_github_pull_files tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handlePullFiles( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/list-github-pull-files', 'get_github_pull_files', $parameters); - } - - /** - * Get tool definition for get_github_pull_files. - * - * @return array - */ - public function getPullFilesDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handlePullFiles', - 'description' => 'List files changed by a GitHub pull request, including filename, status, additions, deletions, and patch when available.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'per_page' => array( - 'type' => 'integer', - 'description' => 'Results per page (max: 100). Default: 100.', - ), - 'page' => array( - 'type' => 'integer', - 'description' => 'Page number. Default: 1.', - ), - ), - 'required' => array( 'repo', 'pull_number' ), - ), - ) - ); - } - - /** - * Handle get_github_check_runs tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleCheckRuns( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-check-runs', 'get_github_check_runs', $parameters); - } - - /** - * Get tool definition for get_github_check_runs. - * - * @return array - */ - public function getCheckRunsDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleCheckRuns', - 'description' => 'Get GitHub check runs for a commit SHA or ref, including aggregate state and failing check names/URLs.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'sha' => array( - 'type' => 'string', - 'description' => 'Commit SHA, branch, or tag ref.', - ), - 'per_page' => array( - 'type' => 'integer', - 'description' => 'Results per page (max: 100). Default: 30.', - ), - 'include_check_output' => array( - 'type' => 'boolean', - 'description' => 'Include bounded check output summaries and text.', - ), - ), - 'required' => array( 'repo', 'sha' ), - ), - ); - } - - /** - * Handle get_github_commit_statuses tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleCommitStatuses( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-commit-statuses', 'get_github_commit_statuses', $parameters); - } - - /** - * Handle get_github_actions_artifact tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleActionsArtifact( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-actions-artifact', 'get_github_actions_artifact', $parameters); - } - - /** - * Get tool definition for get_github_commit_statuses. - * - * @return array - */ - public function getCommitStatusesDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleCommitStatuses', - 'description' => 'Get unmanaged GitHub commit statuses for a commit SHA or ref, including aggregate state and failing contexts.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'sha' => array( - 'type' => 'string', - 'description' => 'Commit SHA, branch, or tag ref.', - ), - ), - 'required' => array( 'repo', 'sha' ), - ), - ); - } - - /** - * Get tool definition for get_github_actions_artifact. - * - * @return array - */ - public function getActionsArtifactDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleActionsArtifact', - 'description' => 'Download a GitHub Actions artifact by artifact name for a pull request or commit SHA and return generic artifact metadata plus parsed JSON files. Does not interpret producer-specific payload semantics.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'head_sha' => array( - 'type' => 'string', - 'description' => 'Commit SHA to match the artifact against.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number. Used to resolve head_sha when head_sha is omitted.', - ), - 'artifact_name' => array( - 'type' => 'string', - 'description' => 'GitHub Actions artifact name.', - ), - 'max_artifact_bytes' => array( - 'type' => 'integer', - 'description' => 'Maximum artifact ZIP bytes to download. Default: 2000000.', - ), - 'include_json' => array( - 'type' => 'boolean', - 'description' => 'Parse and include JSON files from the artifact ZIP. Default: true.', - ), - ), - 'required' => array( 'repo', 'artifact_name' ), - ), - ); - } - - /** - * Handle get_github_pull_review_context tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handlePullReviewContext( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-pull-review-context', 'get_github_pull_review_context', $parameters); - } - - /** - * Get tool definition for get_github_pull_review_context. - * - * @return array - */ - public function getPullReviewContextDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handlePullReviewContext', - 'description' => 'Build a review-ready context packet for a GitHub pull request, including normalized PR metadata and changed-file patches.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'head_sha' => array( - 'type' => 'string', - 'description' => 'Optional expected pull request head SHA. Returns an error if GitHub reports a different head SHA.', - ), - 'max_patch_chars' => array( - 'type' => 'integer', - 'description' => 'Maximum cumulative patch characters to include. Default: 200000.', - ), - 'include_file_contents' => array( - 'type' => 'boolean', - 'description' => 'Opt in to bounded full-file contents for changed files.', - ), - 'include_base_contents' => array( - 'type' => 'boolean', - 'description' => 'When changed file contents are enabled, also include bounded base-branch contents for comparison.', - ), - 'context_paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Additional repository paths to include from the PR head ref.', - ), - 'max_file_content_chars' => array( - 'type' => 'integer', - 'description' => 'Maximum characters included per expanded file content block. Default: 20000.', - ), - 'max_context_files' => array( - 'type' => 'integer', - 'description' => 'Maximum number of files included in expanded PR review context. Default: 10.', - ), - 'max_total_context_chars' => array( - 'type' => 'integer', - 'description' => 'Maximum cumulative characters included across expanded PR review context files. Default: 100000.', - ), - 'include_checks' => array( - 'type' => 'boolean', - 'description' => 'Include GitHub check runs for the PR head SHA.', - ), - 'include_statuses' => array( - 'type' => 'boolean', - 'description' => 'Include classic commit statuses for the PR head SHA.', - ), - 'max_check_runs' => array( - 'type' => 'integer', - 'description' => 'Maximum check runs to include. Default: 30.', - ), - 'include_check_output' => array( - 'type' => 'boolean', - 'description' => 'Include bounded check output summaries and text.', - ), - 'artifact_name' => array( - 'type' => 'string', - 'description' => 'Optional GitHub Actions artifact name to include through generic artifact consumers.', - ), - ), - 'required' => array( 'repo', 'pull_number' ), - ), - ) - ); - } - - /** - * Handle github_repo_review_profile tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleRepoReviewProfile( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-repo-review-profile', 'github_repo_review_profile', $parameters); - } - - /** - * Get tool definition for github_repo_review_profile. - * - * @return array - */ - public function getRepoReviewProfileDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleRepoReviewProfile', - 'description' => 'Build bounded repository-level review context from AGENTS.md, README, contributing docs, and small architecture/development docs. Use before reviewing to learn repo-specific rules and conventions.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'ref' => array( - 'type' => 'string', - 'description' => 'Branch, tag, or commit SHA. Defaults to HEAD.', - ), - 'max_profile_files' => array( - 'type' => 'integer', - 'description' => 'Maximum profile files to include. Default: 14.', - ), - 'max_file_chars' => array( - 'type' => 'integer', - 'description' => 'Maximum characters per profile file. Default: 12000.', - ), - 'max_total_chars' => array( - 'type' => 'integer', - 'description' => 'Maximum cumulative profile characters. Default: 60000.', - ), - 'max_architecture_docs' => array( - 'type' => 'integer', - 'description' => 'Maximum docs/** architecture/development files to include. Default: 8.', - ), - ), - 'required' => array( 'repo' ), - ), - ); - } - - /** - * Handle github_pr_documentation_impact tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handlePullDocumentationImpact( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-pr-documentation-impact', 'github_pr_documentation_impact', $parameters); - } - - /** - * Get tool definition for github_pr_documentation_impact. - * - * @return array - */ - public function getPullDocumentationImpactDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handlePullDocumentationImpact', - 'description' => 'Build a heuristic documentation-impact packet for a GitHub pull request. Use this before documentation/content freshness workflows to identify changed command, ability/tool, REST/webhook, settings, and public PHP surfaces with evidence.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'pull_number' => array( - 'type' => 'integer', - 'description' => 'Pull request number.', - ), - 'head_sha' => array( - 'type' => 'string', - 'description' => 'Optional expected pull request head SHA. Returns an error if GitHub reports a different head SHA.', - ), - 'base_ref' => array( - 'type' => 'string', - 'description' => 'Optional base ref used for docs tree lookup. Defaults to the PR base ref.', - ), - 'docs_paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Optional documentation path allow-list used when suggesting likely stale docs.', - ), - ), - 'required' => array( 'repo', 'pull_number' ), - ), - ); - } - - /** - * Handle list_github_tree tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleListTree( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/list-github-tree', 'list_github_tree', $parameters); - } - - /** - * Get tool definition for list_github_tree. - * - * @return array - */ - public function getListTreeDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleListTree', - 'description' => 'List files in a GitHub repository tree at a branch, tag, or commit SHA. Optionally filter to a path prefix.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'ref' => array( - 'type' => 'string', - 'description' => 'Branch, tag, or commit SHA. Defaults to HEAD.', - ), - 'path' => array( - 'type' => 'string', - 'description' => 'Optional path prefix to filter returned files.', - ), - ), - 'required' => array( 'repo' ), - ), - ) - ); - } - - /** - * Handle get_github_file tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleGetFile( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/get-github-file', 'get_github_file', $parameters); - } - - /** - * Get tool definition for get_github_file. - * - * @return array - */ - public function getGetFileDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleGetFile', - 'description' => 'Get decoded content for one or more files from a GitHub repository. Accepts path for one file or paths for one-or-many; always returns files[].', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'path' => array( - 'type' => 'string', - 'description' => 'Single file path within the repository. Use paths for multiple files.', - ), - 'paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'One or more file paths within the repository.', - ), - 'ref' => array( - 'type' => 'string', - 'description' => 'Branch, tag, or commit SHA. Defaults to the repository default branch.', - ), - 'max_total_size' => array( - 'type' => 'integer', - 'description' => 'Maximum cumulative decoded bytes to return across files. Default: 500000.', - ), - ), - 'required' => array( 'repo' ), - ), - ) - ); - } - - /** - * Handle create_or_update_github_file tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleCreateOrUpdateFile( array $parameters ): array - { - return $this->executeGitHubAbility('datamachine-code/create-or-update-github-file', 'create_or_update_github_file', $parameters); - } - - /** - * Get tool definition for create_or_update_github_file. - * - * @return array - */ - public function getCreateOrUpdateFileDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleCreateOrUpdateFile', - 'description' => 'Create or update a file in a GitHub repository using the Contents API. If branch is provided and does not exist, it is created from the repository default branch before committing.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Repository in owner/repo format.', - ), - 'file_path' => array( - 'type' => 'string', - 'description' => 'Path within the repository.', - ), - 'content' => array( - 'type' => 'string', - 'description' => 'Full file content to write.', - ), - 'commit_message' => array( - 'type' => 'string', - 'description' => 'Commit message for the file change.', - ), - 'branch' => array( - 'type' => 'string', - 'description' => 'Target branch. Defaults to the repository default branch. New branches are created from the default branch.', - ), - 'allowed_file_paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Optional allowlist of writable file paths or glob-like patterns, such as README.md or docs/**.', - ), - ), - 'required' => array( 'repo', 'file_path', 'content', 'commit_message' ), - ), - ) - ); - } - - // ------------------------------------------------------------------------- - // List Repos - // ------------------------------------------------------------------------- - - /** - * Handle list_github_repos tool call. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition. - * @return array - */ - public function handleListRepos( array $parameters, array $tool_def = array() ): array - { - return $this->executeGitHubAbility('datamachine-code/list-github-repos', 'list_github_repos', $parameters); + protected function buildErrorResponse( string $message, string $tool_name ): array { + return array( + 'success' => false, + 'error' => $message, + 'tool_name' => $tool_name, + ); } - - /** - * Get tool definition for list_github_repos. - * - * @return array - */ - public function getListReposDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleListRepos', - 'description' => 'List GitHub repositories for a user or organization. Shows repo names, languages, stars, open issues, and last push date.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'owner' => array( - 'type' => 'string', - 'description' => 'GitHub user or organization name.', - ), - 'sort' => array( - 'type' => 'string', - 'description' => 'Sort by: created, updated, pushed, full_name. Default: updated.', - ), - ), - 'required' => array( 'owner' ), - ), - ) - ); - } - - /** - * @param array $definition Tool definition. @return array - */ - private function repeatableDefinition( array $definition ): array - { - return $this->withRuntime($definition, array( 'duplicate_policy' => 'repeatable' )); - } - - /** - * @param array $definition Tool definition. @return array - */ - private function progressDefinition( array $definition ): array - { - return $this->withRuntime($definition, array( 'completion_signal' => 'progress' )); - } - - /** - * @param array $definition Tool definition. @param array $runtime Runtime metadata. @return array - */ - private function withRuntime( array $definition, array $runtime ): array - { - $definition['runtime'] = array_merge(is_array($definition['runtime'] ?? null) ? $definition['runtime'] : array(), $runtime); - return $definition; - } } diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php deleted file mode 100644 index 87814435..00000000 --- a/inc/Tools/WorkspaceTools.php +++ /dev/null @@ -1,1793 +0,0 @@ - true ); - $this->registerProjectedToolFallback('workspace_path', array( $this, 'getPathDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-path' )); - $this->registerProjectedToolFallback('workspace_capabilities', array( $this, 'getCapabilitiesDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-capabilities' )); - $this->registerProjectedToolFallback('workspace_list', array( $this, 'getListDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-list' )); - $this->registerProjectedToolFallback('workspace_show', array( $this, 'getShowDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-show' )); - $this->registerProjectedToolFallback('workspace_ls', array( $this, 'getLsDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-ls' )); - $this->registerProjectedToolFallback('workspace_read', array( $this, 'getReadDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-read' )); - $this->registerProjectedToolFallback('workspace_grep', array( $this, 'getGrepDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-grep' )); - $this->registerTool('workspace_write', array( $this, 'getWriteDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-write' )); - $this->registerTool('workspace_edit', array( $this, 'getEditDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-edit' )); - $this->registerTool('workspace_apply_patch', array( $this, 'getApplyPatchDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-apply-patch' )); - $this->registerTool('workspace_delete', array( $this, 'getDeleteDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-delete' )); - $this->registerTool('workspace_git_status', array( $this, 'getGitStatusDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-status' )); - $this->registerTool('workspace_git_log', array( $this, 'getGitLogDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-log' )); - $this->registerTool('workspace_git_diff', array( $this, 'getGitDiffDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-diff' )); - $this->registerTool('workspace_git_pull', array( $this, 'getGitPullDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-pull' )); - $this->registerProjectedToolFallback('workspace_worktree_plan', array( $this, 'getWorktreePlanDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-worktree-plan' )); - $this->registerTool('workspace_worktree_add', array( $this, 'getWorktreeAddDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-worktree-add' )); - $this->registerTool('workspace_git_add', array( $this, 'getGitAddDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-add' )); - $this->registerTool('workspace_git_commit', array( $this, 'getGitCommitDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-commit' )); - $this->registerTool('workspace_git_push', array( $this, 'getGitPushDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-push' )); - $this->registerTool('workspace_publish_runner', array( $this, 'getPublishRunnerDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/publish-runner-workspace' )); - $this->registerTool('workspace_run_runner_command', array( $this, 'getRunRunnerCommandDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/run-runner-workspace-command' )); - $this->registerTool('workspace_git_rebase', array( $this, 'getGitRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-rebase' )); - $this->registerTool('workspace_git_reset', array( $this, 'getGitResetDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-reset' )); - $this->registerTool('workspace_pr_status', array( $this, 'getPrStatusDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-pr-status' )); - $this->registerTool('workspace_pr_rebase', array( $this, 'getPrRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-pr-rebase' )); - } - - /** - * Register a legacy wrapper only when Data Machine cannot project the ability directly. - * - * @param string $tool_id Model-facing tool name. - * @param callable $definition_callback Definition callback. - * @param array $contexts Tool contexts. - * @param array $options Tool metadata. - */ - private function registerProjectedToolFallback( string $tool_id, callable $definition_callback, array $contexts, array $options ): void - { - if ( class_exists(AbilityToolProjections::class) && AbilityToolProjections::is_projected($tool_id) ) { - return; - } - - $this->registerTool($tool_id, $definition_callback, $contexts, $options); - } - - /** - * Dispatch tool calls to specific handlers. - * - * @param array $parameters Tool parameters. - * @param array $tool_def Tool definition with method key. - * @return array - */ - public function handle_tool_call( array $parameters, array $tool_def = array() ): array - { - $method = $tool_def['method'] ?? ''; - - if (! method_exists($this, $method) ) { - return $this->buildErrorResponse("Unknown workspace tool method: {$method}", 'workspace_tools'); - } - - return $this->{$method}($parameters, $tool_def); - } - - /** - * Build a standard workspace tool error response. - * - * @param string $message Error message. - * @param string $tool_name Tool name. - * @return array - */ - protected function buildErrorResponse( string $message, string $tool_name ): array - { - return array( - 'success' => false, - 'error' => $message, - 'tool_name' => $tool_name, - ); - } - - /** - * Handle workspace_path tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handlePath( array $parameters ): array - { - $ability = wp_get_ability('datamachine-code/workspace-path'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace path ability not available.', 'workspace_path'); - } - - $result = $ability->execute( - array( - 'ensure' => ! empty($parameters['ensure']), - ) - ); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_path'); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_path', - ); - } - - /** - * Handle workspace_capabilities tool call. - * - * @return array - */ - public function handleCapabilities(): array - { - $ability = wp_get_ability('datamachine-code/workspace-capabilities'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace capabilities ability not available.', 'workspace_capabilities'); - } - - $result = $ability->execute(array()); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_capabilities'); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_capabilities', - ); - } - - /** - * Handle workspace_list tool call. - * - * @return array - */ - public function handleList( array $parameters = array() ): array - { - $ability = wp_get_ability('datamachine-code/workspace-list'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace list ability not available.', 'workspace_list'); - } - - $input = array(); - if (isset($parameters['repo']) ) { - $input['repo'] = (string) $parameters['repo']; - } - if (isset($parameters['type']) ) { - $input['type'] = (string) $parameters['type']; - } - foreach ( array( 'limit', 'cursor', 'all', 'include_status' ) as $key ) { - if (isset($parameters[$key]) ) { - if ( 'limit' === $key ) { - $limit = Workspace::normalize_workspace_list_limit($parameters[$key]); - if (is_wp_error($limit) ) { - return $this->buildErrorResponse($limit->get_error_message(), 'workspace_list'); - } - $input[$key] = $limit; - continue; - } - $input[$key] = $parameters[$key]; - } - } - - $result = $ability->execute($input); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_list'); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_list', - ); - } - - /** - * Handle workspace_show tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleShow( array $parameters ): array - { - $ability = wp_get_ability('datamachine-code/workspace-show'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace show ability not available.', 'workspace_show'); - } - - $input = array( - 'name' => $parameters['name'] ?? '', - 'refresh' => ! empty($parameters['refresh']), - ); - $input = $this->resolveWorkspaceInputAliases($input, array( 'name' )); - $result = $ability->execute($input); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_show'); - } - - return array( - 'success' => true, - 'data' => $this->sanitizeWorkspaceResult($result, $input), - 'tool_name' => 'workspace_show', - ); - } - - /** - * Handle workspace_ls tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleLs( array $parameters ): array - { - $ability = wp_get_ability('datamachine-code/workspace-ls'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace ls ability not available.', 'workspace_ls'); - } - - $input = $this->resolveWorkspaceInputAliases( - array( - 'repo' => $parameters['repo'] ?? '', - 'path' => $parameters['path'] ?? '', - ), - array( 'repo' ) - ); - if (isset($input['_workspace_alias_error']) ) { - return $this->buildErrorResponse((string) $input['_workspace_alias_error'], 'workspace_ls'); - } - if (array_key_exists('allow_stale_primary', $parameters) ) { - $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; - } - $result = $ability->execute($input); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_ls'); - } - - return array( - 'success' => true, - 'data' => $this->sanitizeWorkspaceResult($result, $input), - 'tool_name' => 'workspace_ls', - ); - } - - /** - * Handle workspace_read tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleRead( array $parameters ): array - { - $ability = wp_get_ability('datamachine-code/workspace-read'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace read ability not available.', 'workspace_read'); - } - - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'path' => $parameters['path'] ?? '', - ); - $input = $this->resolveWorkspaceInputAliases($input, array( 'repo' )); - if (isset($input['_workspace_alias_error']) ) { - return $this->buildErrorResponse((string) $input['_workspace_alias_error'], 'workspace_read'); - } - - if (isset($parameters['max_size']) ) { - $input['max_size'] = (int) $parameters['max_size']; - } - - if (isset($parameters['offset']) ) { - $input['offset'] = (int) $parameters['offset']; - } - - if (isset($parameters['limit']) ) { - $input['limit'] = (int) $parameters['limit']; - } - - if (array_key_exists('allow_stale_primary', $parameters) ) { - $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; - } - - $result = $ability->execute($input); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_read'); - } - - return array( - 'success' => true, - 'data' => $this->sanitizeWorkspaceResult($result, $input), - 'tool_name' => 'workspace_read', - ); - } - - /** - * Handle workspace_grep tool call. - * - * @param array $parameters Tool parameters. - * @return array - */ - public function handleGrep( array $parameters ): array - { - $ability = wp_get_ability('datamachine-code/workspace-grep'); - - if (! $ability ) { - return $this->buildErrorResponse('Workspace grep ability not available.', 'workspace_grep'); - } - - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'pattern' => $parameters['pattern'] ?? '', - ); - - foreach ( array( 'path', 'include' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - $input = $this->resolveWorkspaceInputAliases($input, array( 'repo' )); - if (isset($input['_workspace_alias_error']) ) { - return $this->buildErrorResponse((string) $input['_workspace_alias_error'], 'workspace_grep'); - } - - foreach ( array( 'max_results', 'context_lines' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = (int) $parameters[ $key ]; - } - } - if (array_key_exists('allow_stale_primary', $parameters) ) { - $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; - } - - $result = $ability->execute($input); - - if (is_wp_error($result) ) { - return $this->buildErrorResponse($result->get_error_message(), 'workspace_grep'); - } - - return array( - 'success' => true, - 'data' => $this->sanitizeWorkspaceResult($result, $input), - 'tool_name' => 'workspace_grep', - ); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleWrite( array $parameters ): array - { - return $this->executeAbility( - 'datamachine-code/workspace-write', 'workspace_write', array( - 'repo' => $parameters['repo'] ?? '', - 'path' => $parameters['path'] ?? '', - 'content' => $parameters['content'] ?? '', - ), array( 'repo' ) - ); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleEdit( array $parameters ): array - { - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'path' => $parameters['path'] ?? '', - 'old_string' => $parameters['old_string'] ?? $parameters['search'] ?? $parameters['old'] ?? '', - 'new_string' => $parameters['new_string'] ?? $parameters['replace'] ?? $parameters['new'] ?? '', - ); - - if (array_key_exists('replace_all', $parameters) ) { - $input['replace_all'] = (bool) $parameters['replace_all']; - } - - return $this->executeAbility('datamachine-code/workspace-edit', 'workspace_edit', $input, array( 'repo' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleApplyPatch( array $parameters ): array - { - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'patch' => $parameters['patch'] ?? '', - ); - - if (array_key_exists('allow_primary_mutation', $parameters) ) { - $input['allow_primary_mutation'] = (bool) $parameters['allow_primary_mutation']; - } - - return $this->executeAbility('datamachine-code/workspace-apply-patch', 'workspace_apply_patch', $input, array( 'repo' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleDelete( array $parameters ): array - { - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'path' => $parameters['path'] ?? '', - ); - - foreach ( array( 'recursive', 'allow_primary_mutation' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-delete', 'workspace_delete', $input, array( 'repo' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitStatus( array $parameters ): array - { - return $this->executeAbility('datamachine-code/workspace-git-status', 'workspace_git_status', array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ), array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitLog( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - if (isset($parameters['limit']) ) { - $input['limit'] = (int) $parameters['limit']; - } - - return $this->executeAbility('datamachine-code/workspace-git-log', 'workspace_git_log', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitDiff( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'from', 'to', 'path' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - if (array_key_exists('staged', $parameters) ) { - $input['staged'] = (bool) $parameters['staged']; - } - - return $this->executeAbility('datamachine-code/workspace-git-diff', 'workspace_git_diff', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitPull( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'allow_dirty', 'allow_primary_refresh', 'allow_primary_mutation' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-git-pull', 'workspace_git_pull', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleWorktreeAdd( array $parameters ): array - { - $input = array( - 'repo' => $parameters['repo'] ?? '', - 'branch' => $parameters['branch'] ?? '', - 'require_task_tracker' => true, - ); - - foreach ( array( 'from', 'task_url', 'task_ref', 'purpose', 'owner_run_ref', 'cleanup_policy', 'reuse_policy' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - - foreach ( array( 'inject_context', 'bootstrap', 'allow_stale', 'allow_unverified_freshness', 'rebase_base', 'force' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-worktree-add', 'workspace_worktree_add', $input, array( 'repo' )); - } - - /** @param array $parameters @return array */ - public function handleWorktreePlan( array $parameters ): array - { - $input = array( 'repo' => $parameters['repo'] ?? '', 'branch' => $parameters['branch'] ?? '', 'require_task_tracker' => true ); - foreach ( array( 'from', 'task_url', 'task_ref', 'purpose', 'owner_run_ref', 'cleanup_policy', 'reuse_policy' ) as $key ) { - if ( isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - foreach ( array( 'inject_context', 'bootstrap', 'allow_stale', 'allow_unverified_freshness', 'rebase_base', 'force' ) as $key ) { - if ( array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - return $this->executeAbility('datamachine-code/workspace-worktree-plan', 'workspace_worktree_plan', $input, array( 'repo', 'branch' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitAdd( array $parameters ): array - { - $input = array( - 'name' => $parameters['name'] ?? $parameters['repo'] ?? '', - 'paths' => isset($parameters['paths']) && is_array($parameters['paths']) ? $parameters['paths'] : array(), - ); - if (array_key_exists('allow_primary_mutation', $parameters) ) { - $input['allow_primary_mutation'] = (bool) $parameters['allow_primary_mutation']; - } - - return $this->executeAbility('datamachine-code/workspace-git-add', 'workspace_git_add', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitCommit( array $parameters ): array - { - $input = array( - 'name' => $parameters['name'] ?? $parameters['repo'] ?? '', - 'message' => $parameters['message'] ?? '', - ); - if (array_key_exists('allow_dangerous_primary_mutation', $parameters) ) { - $input['allow_dangerous_primary_mutation'] = (bool) $parameters['allow_dangerous_primary_mutation']; - } - - return $this->executeAbility('datamachine-code/workspace-git-commit', 'workspace_git_commit', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitPush( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'remote', 'branch', 'expected_sha' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - foreach ( array( 'allow_dangerous_primary_mutation', 'force_with_lease' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-git-push', 'workspace_git_push', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handlePublishRunner( array $parameters ): array - { - $input = $parameters; - if (isset($parameters['name']) && ! isset($input['workspace_handle']) ) { - $input['workspace_handle'] = $parameters['name']; - } - if (isset($parameters['repo']) && ! isset($input['workspace_handle']) ) { - $input['workspace_handle'] = $parameters['repo']; - } - - return $this->executeAbility('datamachine-code/publish-runner-workspace', 'workspace_publish_runner', $input, array( 'workspace_handle' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleRunRunnerCommand( array $parameters ): array - { - $input = $parameters; - if (isset($parameters['name']) && ! isset($input['workspace_handle']) ) { - $input['workspace_handle'] = $parameters['name']; - } - if (isset($parameters['repo']) && ! isset($input['workspace_handle']) ) { - $input['workspace_handle'] = $parameters['repo']; - } - - return $this->executeAbility('datamachine-code/run-runner-workspace-command', 'workspace_run_runner_command', $input, array( 'workspace_handle', 'command' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitRebase( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'onto', 'strategy_option' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - foreach ( array( 'continue', 'allow_dangerous_primary_mutation' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-git-rebase', 'workspace_git_rebase', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handleGitReset( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'mode', 'target' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - foreach ( array( 'allow_destructive', 'allow_dangerous_primary_mutation' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-git-reset', 'workspace_git_reset', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handlePrStatus( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'pr', 'branch' ) as $key ) { - if (isset($parameters[ $key ]) ) { - $input[ $key ] = $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-pr-status', 'workspace_pr_status', $input, array( 'name' )); - } - - /** - * @param array $parameters Tool parameters. @return array - */ - public function handlePrRebase( array $parameters ): array - { - $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - if (isset($parameters['pr']) ) { - $input['pr'] = $parameters['pr']; - } - if (isset($parameters['drop_paths']) && is_array($parameters['drop_paths']) ) { - $input['drop_paths'] = $parameters['drop_paths']; - } - foreach ( array( 'squash', 'allow_dangerous_primary_mutation' ) as $key ) { - if (array_key_exists($key, $parameters) ) { - $input[ $key ] = (bool) $parameters[ $key ]; - } - } - - return $this->executeAbility('datamachine-code/workspace-pr-rebase', 'workspace_pr_rebase', $input, array( 'name' )); - } - - /** - * @param array $input Ability input. @return array - */ - private function executeAbility( string $ability_name, string $tool_name, array $input, array $handle_keys = array() ): array - { - $ability = wp_get_ability($ability_name); - if (! $ability ) { - return $this->buildErrorResponse("{$tool_name} ability not available.", $tool_name); - } - - $input = $this->resolveWorkspaceInputAliases($input, $handle_keys); - if (isset($input['_workspace_alias_error']) ) { - return $this->buildErrorResponse((string) $input['_workspace_alias_error'], $tool_name); - } - - $result = $ability->execute($input); - if (is_wp_error($result) ) { - $response = $this->buildErrorResponse($result->get_error_message(), $tool_name); - $response['error_code'] = $result->get_error_code(); - $error_data = (array) $result->get_error_data(); - $response['error_data'] = ! empty($error_data['mutation_committed']) ? $error_data : $this->sanitizeWorkspaceResult($error_data, $input); - return $response; - } - - return array( - 'success' => true, - 'data' => $this->sanitizeWorkspaceResult($result, $input), - 'tool_name' => $tool_name, - ); - } - - /** - * @param array $input Tool input. @param string[] $handle_keys Keys that hold workspace handles. @return array - */ - private function resolveWorkspaceInputAliases( array $input, array $handle_keys ): array - { - $input = $this->normalizeWorkspaceRootPathInput($input, $handle_keys); - if (isset($input['_workspace_alias_error']) ) { - return $input; - } - - foreach ( $handle_keys as $key ) { - if (! isset($input[ $key ]) || ! is_string($input[ $key ]) ) { - continue; - } - - $alias = $input[ $key ]; - $spec = WorkspaceAliasResolver::spec($alias); - if (null === $spec ) { - continue; - } - - $real = $spec['target']; - $root = $spec['root']; - $input[ $key ] = $real; - $input['_workspace_alias'] = $alias; - $input['_workspace_handle'] = $real; - $input['_workspace_root'] = $root; - - $scoped = $this->scopeWorkspacePaths($input, $root); - if (is_string($scoped) ) { - $input['_workspace_alias_error'] = $scoped; - } else { - $input = $scoped; - } - } - - return $input; - } - - /** - * Normalize mounted workspace absolute paths into repo handles and relative paths. - * - * Runners can expose repositories under DATAMACHINE_WORKSPACE_PATH. Models then - * naturally use paths like /workspace/example/README.md even though the - * canonical workspace tools accept repo=example and path=README.md. - * - * This is the model-facing compatibility boundary for workspace file tools: - * DMC accepts mounted absolute paths under DATAMACHINE_WORKSPACE_PATH and - * the documented workspace_edit aliases search/replace and old/new. It does - * not infer unrelated required inputs from ambient context. - * - * @param array $input Tool input. - * @param string[] $handle_keys Keys that hold workspace handles. - * @return array - */ - private function normalizeWorkspaceRootPathInput( array $input, array $handle_keys ): array - { - $workspace_root = defined('DATAMACHINE_WORKSPACE_PATH') ? (string) DATAMACHINE_WORKSPACE_PATH : ''; - $workspace_root = $this->normalizeWorkspaceRoot($workspace_root); - if ('' === $workspace_root ) { - return $input; - } - - foreach ( $handle_keys as $key ) { - if (isset($input[ $key ]) && is_string($input[ $key ]) && $this->isAbsolutePath($input[ $key ]) ) { - $parts = $this->splitWorkspaceRootPath($input[ $key ], $workspace_root); - if (null === $parts ) { - $input['_workspace_alias_error'] = 'Workspace path is outside the configured workspace root.'; - return $input; - } - - $input[ $key ] = $parts['repo']; - if ('' !== $parts['path'] ) { - $existing_path = isset($input['path']) && is_string($input['path']) ? trim($input['path'], '/') : ''; - $input['path'] = '' === $existing_path ? $parts['path'] : $parts['path'] . '/' . $existing_path; - } - } - } - - if (isset($input['path']) && is_string($input['path']) && $this->isAbsolutePath($input['path']) ) { - $parts = $this->splitWorkspaceRootPath($input['path'], $workspace_root); - if (null === $parts ) { - $input['_workspace_alias_error'] = 'Workspace path is outside the configured workspace root.'; - return $input; - } - - $current_handle = ''; - foreach ( $handle_keys as $key ) { - if (isset($input[ $key ]) && is_string($input[ $key ]) && '' !== trim($input[ $key ]) ) { - $current_handle = trim($input[ $key ]); - break; - } - } - - if ('' !== $current_handle && $current_handle !== $parts['repo'] ) { - $input['_workspace_alias_error'] = 'Workspace path belongs to a different workspace repository.'; - return $input; - } - - if ('' === $current_handle && ! empty($handle_keys) ) { - $input[ $handle_keys[0] ] = $parts['repo']; - } - $input['path'] = $parts['path']; - } - - return $input; - } - - private function normalizeWorkspaceRoot( string $root ): string - { - $root = str_replace('\\', '/', trim($root)); - $root = trim($root, '/'); - return '' === $root ? '' : '/' . $root; - } - - private function isAbsolutePath( string $path ): bool - { - $path = str_replace('\\', '/', trim($path)); - return str_starts_with($path, '/') || preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $path); - } - - /** - * @return array{repo:string,path:string}|null - */ - private function splitWorkspaceRootPath( string $path, string $workspace_root ): ?array - { - $path = str_replace('\\', '/', trim($path)); - if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $path) ) { - return null; - } - - $root = rtrim($workspace_root, '/'); - if ($path !== $root && ! str_starts_with($path, $root . '/') ) { - return null; - } - - $relative = ltrim(substr($path, strlen($root)), '/'); - if ('' === $relative ) { - return null; - } - - $segments = array_values(array_filter(explode('/', $relative), static fn( string $segment ): bool => '' !== $segment && '.' !== $segment)); - if (empty($segments) || in_array('..', $segments, true) ) { - return null; - } - - $repo = array_shift($segments); - return array( - 'repo' => $repo, - 'path' => implode('/', $segments), - ); - } - - /** - * @param mixed $result Ability result. @param array $input Resolved ability input. @return mixed - */ - private function sanitizeWorkspaceResult( mixed $result, array $input ): mixed - { - $alias = isset($input['_workspace_alias']) ? (string) $input['_workspace_alias'] : ''; - $handle = isset($input['_workspace_handle']) ? (string) $input['_workspace_handle'] : ''; - $root = isset($input['_workspace_root']) ? (string) $input['_workspace_root'] : ''; - if ('' === $alias || '' === $handle ) { - return $result; - } - - return WorkspaceAliasResolver::sanitize_result($result, $alias, $handle, $root); - } - - /** - * @param array $input Resolved ability input. @return array|string - */ - private function scopeWorkspacePaths( array $input, string $root ): array|string - { - if ('' === $root ) { - return $input; - } - - if (array_key_exists('pattern', $input) && ! array_key_exists('path', $input) ) { - $input['path'] = ''; - } - - foreach ( array( 'path' ) as $key ) { - if (array_key_exists($key, $input) ) { - $scoped = WorkspaceAliasResolver::scope_path((string) $input[ $key ], $root); - if (false === $scoped ) { - return 'Path is outside the scoped workspace.'; - } - $input[ $key ] = $scoped; - } - } - - if (array_key_exists('paths', $input) && is_array($input['paths']) ) { - $paths = array(); - foreach ( $input['paths'] as $path ) { - $scoped = WorkspaceAliasResolver::scope_path((string) $path, $root); - if (false === $scoped ) { - return 'Path is outside the scoped workspace.'; - } - $paths[] = $scoped; - } - $input['paths'] = $paths; - } - - if (isset($input['patch']) && is_string($input['patch']) ) { - $patch = $this->scopeWorkspacePatch($input['patch'], $root); - if (false === $patch ) { - return 'Patch contains a path outside the scoped workspace.'; - } - $input['patch'] = $patch; - } - - return $input; - } - - private function scopeWorkspacePatch( string $patch, string $root ): string|false - { - $lines = explode("\n", $patch); - foreach ( $lines as &$line ) { - if (preg_match('/^(diff --git) a\/(.+) b\/(.+)$/', $line, $matches) ) { - $from = WorkspaceAliasResolver::scope_path($matches[2], $root); - $to = WorkspaceAliasResolver::scope_path($matches[3], $root); - if (false === $from || false === $to ) { - return false; - } - $line = $matches[1] . ' a/' . $from . ' b/' . $to; - continue; - } - - if (preg_match('/^(---|\+\+\+) ([ab])\/(.+)$/', $line, $matches) ) { - $path = WorkspaceAliasResolver::scope_path($matches[3], $root); - if (false === $path ) { - return false; - } - $line = $matches[1] . ' ' . $matches[2] . '/' . $path; - } - } - unset($line); - - return implode("\n", $lines); - } - - /** - * Primary tool definition for convention compatibility. - * - * @return array - */ - public function getToolDefinition(): array - { - return $this->getPathDefinition(); - } - - /** - * Tool definition for workspace_path. - * - * @return array - */ - public function getPathDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handlePath', - 'description' => 'Get the Data Machine workspace path. Optionally ensure it exists.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'name' => array( - 'type' => 'string', - 'description' => 'Optional primary or worktree handle, such as or @.', - ), - 'ensure' => array( - 'type' => 'boolean', - 'description' => 'Create the workspace directory if it does not exist (default false).', - ), - ), - 'required' => array(), - ), - ) - ); - } - - /** - * Get workspace_capabilities tool definition. - * - * @return array Tool definition. - */ - public function getCapabilitiesDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => self::class, - 'method' => 'handleCapabilities', - 'description' => 'Inspect whether the current Data Machine Code workspace backend can run local git operations in this runtime.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'include_diagnostics' => array( - 'type' => 'boolean', - 'description' => 'Include workspace backend diagnostics. Defaults to true.', - ), - ), - 'required' => array(), - ), - ) - ); - } - - /** - * Tool definition for workspace_list. - * - * @return array - */ - public function getListDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleList', - 'description' => 'Return a bounded, lightweight workspace inventory page. Request all rows or Git status probes explicitly.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Optional primary repository name to filter by. Includes the primary checkout and its worktrees.', - ), - 'type' => array( - 'type' => 'string', - 'enum' => array( 'primary', 'worktree', 'context' ), - 'description' => 'Optional checkout type filter. Use "primary" for base checkouts, "worktree" for branch worktrees, or "context" for read-only context repositories.', - ), - 'limit' => array( - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => 200, - 'default' => 50, - 'description' => 'Maximum lightweight rows to return. Defaults to 50.', - ), - 'cursor' => array( - 'type' => 'string', - 'description' => 'Cursor returned by a previous response with the same filters.', - ), - 'all' => array( - 'type' => 'boolean', - 'description' => 'Return every matching row.', - ), - 'include_status' => array( - 'type' => 'boolean', - 'description' => 'Include per-row Git remote, branch, and freshness probes.', - ), - ), - 'required' => array(), - ), - ) - ); - } - - /** - * Tool definition for workspace_show. - * - * @return array - */ - public function getShowDefinition(): array - { - return array( - 'class' => __CLASS__, - 'method' => 'handleShow', - 'description' => 'Show detailed information about a workspace repository (branch, remote, latest commit, dirty count).', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'name' => array( - 'type' => 'string', - 'description' => 'Workspace repository directory name.', - ), - 'refresh' => array( - 'type' => 'boolean', - 'description' => 'Fetch the tracked remote under a bounded timeout before classifying primary freshness.', - ), - ), - 'required' => array( 'name' ), - ), - ); - } - - /** - * Tool definition for workspace_ls. - * - * @return array - */ - public function getLsDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleLs', - 'description' => 'List directory contents within a workspace repository.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Workspace repository directory name.', - ), - 'path' => array( - 'type' => 'string', - 'description' => 'Optional relative directory path inside the repo.', - ), - 'allow_stale_primary' => array( - 'type' => 'boolean', - 'description' => 'Explicitly list a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', - ), - ), - 'required' => array(), - ), - ) - ); - } - - /** - * Tool definition for workspace_read. - * - * @return array - */ - public function getReadDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleRead', - 'description' => 'Read a text file from a workspace repository. Supports optional max_size, offset, and limit for large files.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Workspace repository directory name.', - ), - 'path' => array( - 'type' => 'string', - 'description' => 'Relative file path inside the repository.', - ), - 'max_size' => array( - 'type' => 'integer', - 'description' => 'Maximum readable size in bytes (default 1MB).', - ), - 'offset' => array( - 'type' => 'integer', - 'description' => 'Line offset to start reading from (1-indexed).', - ), - 'limit' => array( - 'type' => 'integer', - 'description' => 'Maximum number of lines to return.', - ), - 'allow_stale_primary' => array( - 'type' => 'boolean', - 'description' => 'Explicitly read from a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', - ), - ), - 'required' => array( 'path' ), - ), - ) - ); - } - - /** - * Tool definition for workspace_grep. - * - * @return array - */ - public function getGrepDefinition(): array - { - return $this->repeatableDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleGrep', - 'description' => 'Search text files in a workspace repository using a regular expression pattern.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( - 'type' => 'string', - 'description' => 'Workspace repository directory name or worktree handle.', - ), - 'pattern' => array( - 'type' => 'string', - 'description' => 'Regular expression pattern to search for.', - ), - 'path' => array( - 'type' => 'string', - 'description' => 'Optional relative file or directory path inside the repository.', - ), - 'include' => array( - 'type' => 'string', - 'description' => 'Optional glob pattern to limit matching file paths.', - ), - 'max_results' => array( - 'type' => 'integer', - 'description' => 'Maximum number of matches to return (default 100, max 500).', - ), - 'context_lines' => array( - 'type' => 'integer', - 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', - ), - 'allow_stale_primary' => array( - 'type' => 'boolean', - 'description' => 'Explicitly grep a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', - ), - ), - 'required' => array( 'pattern' ), - ), - ) - ); - } - - /** - * @return array - */ - public function getWriteDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleWrite', - 'description' => 'Create or overwrite a file in a workspace repository. Policy-gated for pipeline use.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: or @.' ), - 'path' => array( 'type' => 'string', 'description' => 'Relative file path within the repo.' ), - 'content' => array( 'type' => 'string', 'description' => 'File content to write.' ), - ), - 'required' => array( 'path', 'content' ), - ), - ) - ); - } - - /** - * @return array - */ - public function getEditDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleEdit', - 'description' => 'Find-and-replace exact text in a workspace repository file. Policy-gated for pipeline use.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: or @.' ), - 'path' => array( 'type' => 'string', 'description' => 'Relative file path within the repo.' ), - 'old_string' => array( 'type' => 'string', 'description' => 'Exact text to find.' ), - 'new_string' => array( 'type' => 'string', 'description' => 'Replacement text.' ), - 'search' => array( 'type' => 'string', 'description' => 'Alias for old_string.' ), - 'replace' => array( 'type' => 'string', 'description' => 'Alias for new_string.' ), - 'old' => array( 'type' => 'string', 'description' => 'Alias for old_string.' ), - 'new' => array( 'type' => 'string', 'description' => 'Alias for new_string.' ), - 'replace_all' => array( 'type' => 'boolean', 'description' => 'Replace all occurrences. Default false.' ), - ), - 'required' => array( 'path' ), - ), - ) - ); - } - - /** - * @return array - */ - public function getApplyPatchDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleApplyPatch', - 'description' => 'Apply a unified diff to a workspace repository using git apply checks. Policy-gated for pipeline use.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: or @.' ), - 'patch' => array( 'type' => 'string', 'description' => 'Unified diff content to apply.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), - ), - 'required' => array( 'repo', 'patch' ), - ), - ) - ); - } - - /** - * @return array - */ - public function getDeleteDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleDelete', - 'description' => 'Delete a tracked or untracked path from a workspace repository. Policy-gated for pipeline use.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: or @.' ), - 'path' => array( 'type' => 'string', 'description' => 'Relative path within the repo.' ), - 'recursive' => array( 'type' => 'boolean', 'description' => 'Required when target is a directory. Default false.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), - ), - 'required' => array( 'path' ), - ), - ) - ); - } - - /** - * @return array - */ - public function getGitStatusDefinition(): array - { - return $this->simpleGitDefinition('handleGitStatus', 'Get git status information for a workspace handle. Use once to inspect cleanliness, then move to a concrete edit, commit, or completion outcome.', array()); - } - - /** - * @return array - */ - public function getGitLogDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitLog', 'Read git log entries for a workspace handle.', array( - 'limit' => array( 'type' => 'integer', 'description' => 'Maximum log entries to return.' ), - ), array( 'name' ), array( 'duplicate_policy' => 'repeatable' ) - ); - } - - /** - * @return array - */ - public function getGitDiffDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitDiff', 'Read git diff output for a workspace handle.', array( - 'from' => array( 'type' => 'string', 'description' => 'Optional from git ref.' ), - 'to' => array( 'type' => 'string', 'description' => 'Optional to git ref.' ), - 'staged' => array( 'type' => 'boolean', 'description' => 'Read staged diff instead of working tree diff.' ), - 'path' => array( 'type' => 'string', 'description' => 'Optional relative path filter.' ), - ), array(), array( 'duplicate_policy' => 'repeatable' ) - ); - } - - /** - * @return array - */ - public function getGitPullDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitPull', 'Run git pull --ff-only for a workspace handle. Policy-gated for pipeline use.', array( - 'allow_dirty' => array( 'type' => 'boolean', 'description' => 'Allow pull when working tree is dirty. Default false.' ), - 'allow_primary_refresh' => array( 'type' => 'boolean', 'description' => 'Permit safe primary refresh with git pull --ff-only. Default false.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Legacy alias for allow_primary_refresh on git pull only.' ), - ), array( 'name' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getWorktreeAddDefinition(): array - { - return $this->progressDefinition( - array( - 'class' => __CLASS__, - 'method' => 'handleWorktreeAdd', - 'description' => 'Create a git worktree for a workspace repository branch. Policy-gated for pipeline use.', - 'parameters' => array( - 'type' => 'object', - 'properties' => array( - 'repo' => array( 'type' => 'string', 'description' => 'Primary workspace repo name, without an @ worktree suffix.' ), - 'branch' => array( 'type' => 'string', 'description' => 'Branch to check out in the worktree, for example fix/foo-bar.' ), - 'from' => array( 'type' => 'string', 'description' => 'Base ref when creating the branch. Defaults to origin/HEAD.' ), - 'inject_context' => array( 'type' => 'boolean', 'description' => 'Inject originating agent context into the worktree. Default true.' ), - 'bootstrap' => array( 'type' => 'boolean', 'description' => 'Run detected bootstrap steps after creation. Default true.' ), - 'allow_stale' => array( 'type' => 'boolean', 'description' => 'Bypass the verified staleness gate. Default false.' ), - 'allow_unverified_freshness' => array( 'type' => 'boolean', 'description' => 'Bypass fetch-failure freshness verification for intentional offline work. Default false.' ), - 'rebase_base' => array( 'type' => 'boolean', 'description' => 'Rebase the worktree onto the upstream tip after creation. Default false.' ), - 'force' => array( 'type' => 'boolean', 'description' => 'Bypass disk-budget refusal threshold. Default false.' ), - 'task_url' => array( 'type' => 'string', 'description' => 'Optional task or issue URL to record on the worktree.' ), - 'task_ref' => array( 'type' => 'string', 'description' => 'Optional short task or issue reference to record on the worktree.' ), - ...\DataMachineCode\Workspace\WorktreeContextInjector::worktree_add_policy_schema_properties(), - ), - 'required' => array( 'repo', 'branch' ), - ), - ) - ); - } - - /** @return array */ - public function getWorktreePlanDefinition(): array - { - $definition = $this->getWorktreeAddDefinition(); - $definition['method'] = 'handleWorktreePlan'; - $definition['description'] = 'Plan a git worktree allocation without mutation. Returns a digest-addressed apply intent for a generic command provider.'; - return $definition; - } - - /** - * @return array - */ - public function getGitAddDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitAdd', 'Stage repository paths with git add. Policy-gated for pipeline use.', array( - 'paths' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Relative paths to stage.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), - ), array( 'name', 'paths' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getGitCommitDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitCommit', 'Commit staged changes in a workspace handle. Policy-gated for pipeline use.', array( - 'message' => array( 'type' => 'string', 'description' => 'Commit message.' ), - 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit committing on a primary checkout. Use only for an explicitly approved primary mutation.' ), - ), array( 'name', 'message' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getGitPushDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitPush', 'Push commits for a workspace handle. Policy-gated for pipeline use.', array( - 'remote' => array( 'type' => 'string', 'description' => 'Remote name. Default origin.' ), - 'branch' => array( 'type' => 'string', 'description' => 'Branch override.' ), - 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit pushing from a primary checkout. Use only for an explicitly approved primary mutation.' ), - 'force_with_lease' => array( 'type' => 'boolean', 'description' => 'Use --force-with-lease. Refuses protected base/fixed branches.' ), - 'expected_sha' => array( 'type' => 'string', 'description' => 'Optional expected remote branch SHA.' ), - ), array( 'name' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getPublishRunnerDefinition(): array - { - return $this->withRuntime( - array( - 'class' => __CLASS__, - 'method' => 'handlePublishRunner', - 'description' => 'Publish runner-owned workspace changes through the canonical DMC API: stage, commit, push, then open or reuse a pull request.', - 'parameters' => array( - 'type' => 'object', - 'required' => array( 'workspace_handle', 'target_repo', 'commit_message', 'pr_title' ), - 'properties' => array( - 'workspace_handle' => array( - 'type' => 'string', - 'description' => 'Workspace handle: or @.', - ), - 'target_repo' => array( - 'type' => 'string', - 'description' => 'GitHub owner/repo for the PR.', - ), - 'base' => array( - 'type' => 'string', - 'description' => 'Base branch/ref.', - ), - 'head' => array( - 'type' => 'string', - 'description' => 'PR head branch or owner:branch.', - ), - 'head_branch' => array( - 'type' => 'string', - 'description' => 'Branch to push/publish.', - ), - 'branch' => array( - 'type' => 'string', - 'description' => 'Alias for head_branch.', - ), - 'commit_message' => array( - 'type' => 'string', - 'description' => 'Commit message.', - ), - 'pr_title' => array( - 'type' => 'string', - 'description' => 'Pull request title.', - ), - 'pr_body' => array( - 'type' => 'string', - 'description' => 'Pull request body.', - ), - 'labels' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Optional PR labels.', - ), - 'draft' => array( - 'type' => 'boolean', - 'description' => 'Open as draft.', - ), - 'maintainer_can_modify' => array( - 'type' => 'boolean', - 'description' => 'Allow maintainers to modify.', - ), - 'evidence_context' => array( - 'type' => 'object', - 'description' => 'Runner evidence/artifact context.', - ), - 'artifact_context' => array( - 'type' => 'object', - 'description' => 'Alias for evidence_context.', - ), - 'run_artifacts' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - 'description' => 'Run artifacts forwarded to PR artifact handling.', - ), - 'run_artifact_policy' => array( - 'type' => 'object', - 'description' => 'Artifact egress policy.', - ), - 'paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Paths to stage. Defaults to all.', - ), - 'remote' => array( - 'type' => 'string', - 'description' => 'Git remote for local workspaces.', - ), - 'allow_primary_mutation' => array( - 'type' => 'boolean', - 'description' => 'Permit primary checkout publication.', - ), - 'force_with_lease' => array( - 'type' => 'boolean', - 'description' => 'Use --force-with-lease.', - ), - 'expected_sha' => array( - 'type' => 'string', - 'description' => 'Expected remote branch SHA.', - ), - ), - ), - ), array( 'completion_signal' => 'complete' ) - ); - } - - /** - * @return array - */ - public function getRunRunnerCommandDefinition(): array - { - return $this->withRuntime( - array( - 'class' => __CLASS__, - 'method' => 'handleRunRunnerCommand', - 'description' => 'Run a bounded verification or drift command against a runner-owned workspace handle through the canonical DMC backend API.', - 'parameters' => array( - 'type' => 'object', - 'required' => array( 'workspace_handle', 'command' ), - 'properties' => array( - 'workspace_handle' => array( 'type' => 'string', 'description' => 'Workspace handle: , @, or runner alias.' ), - 'name' => array( 'type' => 'string', 'description' => 'Alias for workspace_handle.' ), - 'repo' => array( 'type' => 'string', 'description' => 'Alias for workspace_handle.' ), - 'command' => array( 'type' => 'string', 'description' => 'Shell command to run inside the workspace.' ), - 'description' => array( 'type' => 'string', 'description' => 'Human-readable reason for the command.' ), - 'timeout' => array( 'type' => 'integer', 'description' => 'Timeout in seconds. Defaults to 300 and is capped at 1800.' ), - 'timeout_seconds' => array( 'type' => 'integer', 'description' => 'Alias for timeout.' ), - 'cwd' => array( 'type' => 'string', 'description' => 'Optional relative working directory inside the workspace.' ), - 'env' => array( 'type' => 'object', 'description' => 'Optional string environment variables.' ), - 'context' => array( 'type' => 'object', 'description' => 'Optional caller context carried for observability.' ), - ), - ), - ), array( 'completion_signal' => 'complete' ) - ); - } - - /** - * @return array - */ - public function getGitRebaseDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitRebase', 'Fetch and rebase a workspace handle, returning structured conflicts without auto-resolving.', array( - 'onto' => array( 'type' => 'string', 'description' => 'Base ref to rebase onto.' ), - 'strategy_option' => array( 'type' => 'string', 'description' => 'Optional strategy option, such as theirs or ours.' ), - 'continue' => array( 'type' => 'boolean', 'description' => 'Continue an in-progress rebase.' ), - 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit rebasing a primary checkout. Use only for an explicitly approved primary mutation.' ), - ), array( 'name' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getGitResetDefinition(): array - { - return $this->simpleGitDefinition( - 'handleGitReset', 'Reset a workspace handle. Hard reset requires allow_destructive=true.', array( - 'mode' => array( 'type' => 'string', 'enum' => array( 'soft', 'mixed', 'hard' ), 'description' => 'Reset mode.' ), - 'target' => array( 'type' => 'string', 'description' => 'Target ref or commit.' ), - 'allow_destructive' => array( 'type' => 'boolean', 'description' => 'Required for hard reset.' ), - 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit resetting a primary checkout. Use only for an explicitly approved primary mutation.' ), - ), array( 'name' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @return array - */ - public function getPrStatusDefinition(): array - { - return $this->simpleGitDefinition( - 'handlePrStatus', 'Return GitHub pull request mergeability/freshness state for a workspace handle.', array( - 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL.' ), - 'branch' => array( 'type' => 'string', 'description' => 'Branch to resolve when PR is omitted.' ), - ), array( 'name' ), array( 'duplicate_policy' => 'repeatable' ) - ); - } - - /** - * @return array - */ - public function getPrRebaseDefinition(): array - { - return $this->simpleGitDefinition( - 'handlePrRebase', 'Bring a pull request branch up to date, optionally dropping path conflicts, squashing, and force-with-lease pushing.', array( - 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL.' ), - 'squash' => array( 'type' => 'boolean', 'description' => 'Squash rebased commits into one PR-title commit.' ), - 'drop_paths' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Conflict path globs to resolve by taking the base version.' ), - 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit rebasing and force-with-lease pushing from a primary checkout. Use only for an explicitly approved primary mutation.' ), - ), array( 'name' ), array( 'completion_signal' => 'progress' ) - ); - } - - /** - * @param array $extra_properties Extra parameters. @param string[] $required Required properties. @return array - */ - private function simpleGitDefinition( string $method, string $description, array $extra_properties, array $required = array( 'name' ), array $runtime = array() ): array - { - return $this->withRuntime( - array( - 'class' => __CLASS__, - 'method' => $method, - 'description' => $description, - 'parameters' => array( - 'type' => 'object', - 'properties' => array_merge( - array( - 'name' => array( 'type' => 'string', 'description' => 'Workspace handle: or @.' ), - 'repo' => array( 'type' => 'string', 'description' => 'Alias for name.' ), - ), - $extra_properties - ), - 'required' => $required, - ), - ), $runtime - ); - } - - /** - * @param array $definition Tool definition. @return array - */ - private function repeatableDefinition( array $definition ): array - { - return $this->withRuntime($definition, array( 'duplicate_policy' => 'repeatable' )); - } - - /** - * @param array $definition Tool definition. @return array - */ - private function progressDefinition( array $definition ): array - { - return $this->withRuntime($definition, array( 'completion_signal' => 'progress' )); - } - - /** - * @param array $definition Tool definition. @param array $runtime Runtime metadata. @return array - */ - private function withRuntime( array $definition, array $runtime ): array - { - if (empty($runtime) ) { - return $definition; - } - - $definition['runtime'] = array_merge(is_array($definition['runtime'] ?? null) ? $definition['runtime'] : array(), $runtime); - return $definition; - } -} diff --git a/tests/ability-tool-projections.php b/tests/ability-tool-projections.php new file mode 100644 index 00000000..e63e0ed1 --- /dev/null +++ b/tests/ability-tool-projections.php @@ -0,0 +1,114 @@ + $contexts, + 'options' => $options, + ); + } + + protected function progressDefinition( array $definition ): array { + return $definition; + } + } +} + +namespace { + if ( ! defined('ABSPATH') ) { + define('ABSPATH', __DIR__ . '/fixtures/'); + } + + $GLOBALS['dmc_projected_tools'] = array(); + $GLOBALS['dmc_bespoke_tools'] = array(); + + function datamachine_register_ability_tool( string $tool_name, array $declaration ): void { + $GLOBALS['dmc_projected_tools'][ $tool_name ] = $declaration; + } + + $GLOBALS['dmc_projection_abilities'] = array(); + + final class ProjectionTestAbility { + /** @var array> */ + public array $calls = array(); + + public function execute( array $input ): array { + $this->calls[] = $input; + return array( 'success' => true ); + } + } + + function wp_get_ability( string $slug ): ?ProjectionTestAbility { + return $GLOBALS['dmc_projection_abilities'][ $slug ] ?? null; + } + + function is_wp_error( mixed $value ): bool { + return $value instanceof \WP_Error; + } + + function projection_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException($message); + } + } + + require_once dirname(__DIR__) . '/inc/Tools/AbilityToolProjections.php'; + require_once dirname(__DIR__) . '/inc/Tools/GitHubTools.php'; + + use DataMachineCode\Tools\AbilityToolProjections; + use DataMachineCode\Tools\GitHubTools; + + projection_assert(AbilityToolProjections::register(), 'Ability projection registration should require the Data Machine helper.'); + + $expected = array( + 'workspace_path' => 'datamachine-code/workspace-path', + 'workspace_worktree_plan' => 'datamachine-code/workspace-worktree-plan', + 'workspace_worktree_add' => 'datamachine-code/workspace-worktree-add', + 'workspace_publish_runner' => 'datamachine-code/publish-runner-workspace', + 'list_github_issues' => 'datamachine-code/list-github-issues', + 'remove_label_from_issue' => 'datamachine-code/remove-github-label', + 'comment_github_pull_request' => 'datamachine-code/comment-github-pull-request', + 'upsert_github_pull_review_comment' => 'datamachine-code/upsert-github-pull-review-comment', + 'merge_github_pull_request' => 'datamachine-code/merge-github-pull-request', + 'cleanup_github_pull_request' => 'datamachine-code/cleanup-github-pull-request', + 'create_or_update_github_file' => 'datamachine-code/create-or-update-github-file', + ); + + foreach ( $expected as $tool_name => $ability_slug ) { + $declaration = $GLOBALS['dmc_projected_tools'][ $tool_name ] ?? null; + projection_assert(is_array($declaration), sprintf('Projected model tool is missing: %s', $tool_name)); + projection_assert($ability_slug === ( $declaration['ability'] ?? null ), sprintf('Projected model tool does not target its canonical ability: %s', $tool_name)); + + } + + $github_tools = new GitHubTools(); + projection_assert( + array( 'manage_github_issue', 'add_label_to_issue' ) === array_keys($GLOBALS['dmc_bespoke_tools']), + 'Only composed or input-adapting GitHub wrappers should remain bespoke.' + ); + + $comment_ability = new ProjectionTestAbility(); + $label_ability = new ProjectionTestAbility(); + $GLOBALS['dmc_projection_abilities']['datamachine-code/comment-github-issue'] = $comment_ability; + $GLOBALS['dmc_projection_abilities']['datamachine-code/add-github-labels'] = $label_ability; + + $github_tools->handleManageIssue(array( + 'repo' => 'Extra-Chill/data-machine-code', + 'issue_number' => 547, + 'action' => 'comment', + 'body' => 'Projection contract', + )); + projection_assert('Projection contract' === ( $comment_ability->calls[0]['body'] ?? null ), 'Composed issue management should route comments through the canonical comment ability.'); + + $github_tools->handleAddLabelToIssue(array( + 'repo' => 'Extra-Chill/data-machine-code', + 'issue_number' => 547, + 'label' => 'status:review', + )); + projection_assert(array( 'status:review' ) === ( $label_ability->calls[0]['labels'] ?? null ), 'The bespoke label wrapper should adapt one label to the canonical collection input.'); + + echo "ability-tool-projections ok\n"; +} diff --git a/tests/workspace-list-cli-format-contract.php b/tests/workspace-list-cli-format-contract.php index c6fb1e13..e1be72df 100644 --- a/tests/workspace-list-cli-format-contract.php +++ b/tests/workspace-list-cli-format-contract.php @@ -14,12 +14,6 @@ protected function format_items( array $items, array $fields, array $assoc_args, } } -namespace DataMachine\Engine\AI\Tools { - class BaseTool { - protected function buildErrorResponse( string $message, string $tool ): array { return array( 'success' => false, 'message' => $message, 'tool_name' => $tool ); } - } -} - namespace DataMachineCode\Workspace { class Workspace { public static array $limit_inputs = array(); @@ -29,7 +23,6 @@ public static function normalize_workspace_list_limit( mixed $limit ): int|\WP_E return (int) $limit; } } - class WorkspaceAliasResolver {} class WorktreeDiskBudget { public static function format_advisory( array $capacity ): string { return (string) ( $capacity['advisory'] ?? '' ); } public static function format_summary( array $capacity ): string { return (string) ( $capacity['summary'] ?? '' ); } @@ -66,17 +59,13 @@ function wp_get_ability( string $name ): ?WorkspaceListAbility { return $GLOBALS require_once dirname(__DIR__) . '/inc/Cli/CliResponseRenderer.php'; require_once dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php'; - require_once dirname(__DIR__) . '/inc/Tools/WorkspaceTools.php'; require_once dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'; use DataMachine\Cli\BaseCommand; use DataMachineCode\Cli\Commands\WorkspaceCommand; - use DataMachineCode\Tools\WorkspaceTools; use DataMachineCode\Abilities\WorkspaceAbilities; use DataMachineCode\Workspace\Workspace; - final class WorkspaceListToolContract extends WorkspaceTools { public function __construct() {} } - function cli_format_assert( bool $condition, string $message ): void { if ( ! $condition ) { throw new RuntimeException($message); } } @@ -109,7 +98,6 @@ function cli_format_reset(): void { ); $GLOBALS['dmc_workspace_list_ability'] = new WorkspaceListAbility($result); $command = new WorkspaceCommand(); - $tool = new WorkspaceListToolContract(); foreach ( array( '1.5', 'junk', array( 1 ), true ) as $invalid_limit ) { Workspace::$limit_inputs = array(); @@ -120,9 +108,6 @@ function cli_format_reset(): void { cli_format_assert($invalid_limit === (Workspace::$limit_inputs[0] ?? null), 'CLI must validate the raw limit before coercion.'); } Workspace::$limit_inputs = array(); - $tool_result = $tool->handleList(array( 'limit' => $invalid_limit )); - cli_format_assert(false === ($tool_result['success'] ?? true) && $invalid_limit === (Workspace::$limit_inputs[0] ?? null), 'Tool must validate the raw limit before coercion.'); - Workspace::$limit_inputs = array(); $ability_result = WorkspaceAbilities::listRepos(array( 'limit' => $invalid_limit )); cli_format_assert(is_wp_error($ability_result) && $invalid_limit === (Workspace::$limit_inputs[0] ?? null), 'Ability must validate the raw limit before coercion.'); Workspace::$limit_inputs = array(); diff --git a/tests/workspace-tool-error-envelope.php b/tests/workspace-tool-error-envelope.php deleted file mode 100644 index 1598c546..00000000 --- a/tests/workspace-tool-error-envelope.php +++ /dev/null @@ -1,58 +0,0 @@ -code; } - public function get_error_message(): string { return $this->message; } - public function get_error_data(): array { return $this->data; } - } - - final class Workspace_Tool_Error_Ability { - public function execute( array $input ): WP_Error { - unset($input); - return new WP_Error('worktree_handoff_freshness_unverified', 'handoff pending', array( - 'partial_success' => true, - 'mutation_committed' => true, - 'continuation' => array( 'ability' => 'datamachine-code/workspace-worktree-handoff-resume' ), - )); - } - } - - function wp_get_ability( string $name ): Workspace_Tool_Error_Ability { - unset($name); - return new Workspace_Tool_Error_Ability(); - } - - function is_wp_error( mixed $value ): bool { return $value instanceof WP_Error; } - - require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceAliasResolver.php'; - require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php'; - require_once dirname(__DIR__) . '/inc/Tools/WorkspaceTools.php'; - - final class Workspace_Tool_Error_Envelope_Harness extends \DataMachineCode\Tools\WorkspaceTools { - public function __construct() {} - } - - $result = ( new Workspace_Tool_Error_Envelope_Harness() )->handleWorktreeAdd(array( - 'repo' => 'example', - 'branch' => 'fix/1205', - 'task_ref' => 'Extra-Chill/data-machine-code#1205', - )); - if ( 'worktree_handoff_freshness_unverified' !== ( $result['error_code'] ?? null ) - || true !== ( $result['error_data']['mutation_committed'] ?? false ) - || 'datamachine-code/workspace-worktree-handoff-resume' !== ( $result['error_data']['continuation']['ability'] ?? null ) - ) { - throw new RuntimeException('Workspace tool discarded the committed mutation boundary or exact continuation.'); - } - - fwrite(STDOUT, "workspace-tool-error-envelope: ok\n"); -} diff --git a/tests/worktree-add-tool-tracker-contract.php b/tests/worktree-add-tool-tracker-contract.php deleted file mode 100644 index 1f7d4b8c..00000000 --- a/tests/worktree-add-tool-tracker-contract.php +++ /dev/null @@ -1,89 +0,0 @@ -input = $input; - return array( 'success' => true ); - } - } - - $worktree_add_tool_tracker_ability = new Worktree_Add_Tool_Tracker_Ability(); - function wp_get_ability( string $name ): Worktree_Add_Tool_Tracker_Ability { - global $worktree_add_tool_tracker_ability; - return $worktree_add_tool_tracker_ability; - } - - function is_wp_error( mixed $value ): bool { - return false; - } - - require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceAliasResolver.php'; - require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php'; - require_once dirname(__DIR__) . '/inc/Tools/WorkspaceTools.php'; - - final class Worktree_Add_Tool_Tracker_Contract extends \DataMachineCode\Tools\WorkspaceTools { - public function __construct() { - } - } - - function worktree_add_tool_tracker_assert( bool $condition, string $message ): void { - if ( ! $condition ) { - throw new \RuntimeException($message); - } - } - - try { - $tool = new Worktree_Add_Tool_Tracker_Contract(); - $tool->handleWorktreeAdd( - array( - 'repo' => 'example', - 'branch' => 'feature/tracked', - 'require_task_tracker' => false, - 'reuse_policy' => 'isolated', - ) - ); - - worktree_add_tool_tracker_assert(true === ( $worktree_add_tool_tracker_ability->input['require_task_tracker'] ?? null ), 'agent tool allowed tracker enforcement to be disabled'); - worktree_add_tool_tracker_assert('isolated' === ( $worktree_add_tool_tracker_ability->input['reuse_policy'] ?? null ), 'agent tool did not forward reuse policy to the worktree ability'); - $definition = $tool->getWorktreeAddDefinition(); - $properties = (array) ( $definition['parameters']['properties'] ?? array() ); - worktree_add_tool_tracker_assert(! array_key_exists('require_task_tracker', $properties), 'agent tool schema exposes a tracker-enforcement override'); - worktree_add_tool_tracker_assert(array( 'reuse_compatible', 'isolated', 'recycle_terminal', 'claim_expired' ) === ( $properties['reuse_policy']['enum'] ?? null ), 'agent tool schema did not expose the supported reuse policies'); - worktree_add_tool_tracker_assert(array( 'manual', 'remove_on_success', 'preserve_on_failure' ) === ( $properties['cleanup_policy']['enum'] ?? null ), 'agent tool schema did not expose the supported cleanup policies'); - worktree_add_tool_tracker_assert(str_contains((string) ( $properties['reuse_policy']['description'] ?? '' ), '--purpose, --owner-run-ref, and --cleanup-policy=remove_on_success'), 'agent tool schema did not describe the canonical isolated same-task CLI contract'); - $ability_source = file_get_contents(dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'); - worktree_add_tool_tracker_assert(false !== $ability_source && str_contains($ability_source, '...WorktreeContextInjector::worktree_add_policy_schema_properties()'), 'worktree ability schema did not project the shared worktree policy contract'); - worktree_add_tool_tracker_assert(false !== $ability_source && str_contains($ability_source, "'reuse_candidates'"), 'worktree ability result schema did not expose optional reuse candidates'); - $cli_source = file_get_contents(dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php'); - worktree_add_tool_tracker_assert(false !== $cli_source && str_contains($cli_source, "'reuse-policy'"), 'worktree CLI did not map --reuse-policy to ability input'); - $plan_definition = $tool->getWorktreePlanDefinition(); - worktree_add_tool_tracker_assert('handleWorktreePlan' === ( $plan_definition['method'] ?? null ), 'generic command providers cannot configure worktree planning directly'); - worktree_add_tool_tracker_assert('string' === ( $plan_definition['parameters']['properties']['repo']['type'] ?? '' ) && 'string' === ( $plan_definition['parameters']['properties']['branch']['type'] ?? '' ), 'plan definition was not projected from the typed worktree intent'); - worktree_add_tool_tracker_assert(false !== $ability_source && str_contains($ability_source, "'datamachine-code/workspace-worktree-plan'"), 'worktree plan ability was not registered'); - fwrite(STDOUT, "worktree-add-tool-tracker-contract ok\n"); - } catch (\Throwable $e) { - fwrite(STDERR, $e->getMessage() . "\n"); - exit(1); - } -} From 59e478f73c1775893f61c2241759a9a7ee810bce Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:38:33 -0400 Subject: [PATCH 06/10] test: add shared standalone runner --- bin/dmc-test | 40 ++++++++++++++++++++++ tests/TESTING.md | 16 ++++----- tests/ability-tool-projections.php | 4 +-- tests/support/bootstrap.php | 27 +++++++++++++++ tests/workspace-operation-architecture.php | 23 +++++++++++++ tests/worktree-allocation-request.php | 4 +-- 6 files changed, 100 insertions(+), 14 deletions(-) create mode 100755 bin/dmc-test create mode 100644 tests/support/bootstrap.php create mode 100644 tests/workspace-operation-architecture.php diff --git a/bin/dmc-test b/bin/dmc-test new file mode 100755 index 00000000..12e75fd8 --- /dev/null +++ b/bin/dmc-test @@ -0,0 +1,40 @@ +#!/usr/bin/env php + Date: Wed, 26 Aug 2026 02:48:09 -0400 Subject: [PATCH 07/10] fix(worktree): stabilize plan capacity identity --- inc/Workspace/WorkspaceWorktreeLifecycle.php | 21 ++++++++++++++++++- tests/worktree-add-lifecycle.php | 2 +- ...ktree-percentage-byte-floor-plan-apply.php | 7 ++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 2387378d..8497db42 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -282,6 +282,7 @@ public function worktree_apply_plan( array $plan ): array|\WP_Error { 'expected_digest' => $expected, 'actual_digest' => $current['digest'] ?? null, 'disposition' => $current['disposition'] ?? null, + 'changed_sections' => $this->worktree_plan_changed_sections($plan, $current), )); } $result = $this->worktree_add_request(WorktreeAllocationRequest::from_input($input + array( @@ -1051,7 +1052,6 @@ private function worktree_plan_capacity_identity( array $capacity ): array { 'filesystem_total_bytes' => $capacity['filesystem_total_bytes'] ?? null, 'filesystem_free_bytes' => $this->worktree_plan_capacity_measurement($capacity['filesystem_free_bytes'] ?? null, 64 * 1024 * 1024), 'projected_free_bytes' => $this->worktree_plan_capacity_measurement($capacity['projected_free_bytes'] ?? null, 64 * 1024 * 1024), - 'filesystem_total_inodes' => $this->worktree_plan_capacity_measurement($capacity['filesystem_total_inodes'] ?? null, 1000000), 'filesystem_free_inodes' => $this->worktree_plan_capacity_measurement($capacity['filesystem_free_inodes'] ?? null, 1000000), 'projected_free_inodes' => $this->worktree_plan_capacity_measurement($capacity['projected_free_inodes'] ?? null, 1000000), 'refuse_free_bytes' => $capacity['refuse_free_bytes'] ?? null, @@ -1077,6 +1077,25 @@ private function worktree_plan_capacity_measurement( mixed $value, int $quantum return abs($value) < $quantum ? $value : intdiv($value, $quantum); } + /** Identify normalized evidence sections that invalidated a reviewed plan. */ + private function worktree_plan_changed_sections( array $expected, array $actual ): array { + $sections = array( + 'apply_intent' => array( $expected['apply_intent'] ?? null, $actual['apply_intent'] ?? null ), + 'freshness' => array( + array_intersect_key((array) ( $expected['freshness'] ?? array() ), array_flip(array( 'verified', 'identity', 'target_ref', 'target_head' ))), + array_intersect_key((array) ( $actual['freshness'] ?? array() ), array_flip(array( 'verified', 'identity', 'target_ref', 'target_head' ))), + ), + 'capacity' => array( $this->worktree_plan_capacity_identity((array) ( $expected['capacity'] ?? array() )), $this->worktree_plan_capacity_identity((array) ( $actual['capacity'] ?? array() )) ), + 'bootstrap_demand' => array( $expected['bootstrap_demand'] ?? null, $actual['bootstrap_demand'] ?? null ), + 'destination' => array( $expected['destination'] ?? null, $actual['destination'] ?? null ), + 'ownership' => array( $expected['ownership'] ?? null, $actual['ownership'] ?? null ), + 'reuse_candidates' => array( $expected['reuse_candidates'] ?? null, $actual['reuse_candidates'] ?? null ), + 'legacy_handoff' => array( $expected['legacy_handoff'] ?? null, $actual['legacy_handoff'] ?? null ), + ); + + return array_keys(array_filter($sections, static fn( array $pair ): bool => $pair[0] !== $pair[1])); + } + private function worktree_plan_sort( mixed $value ): mixed { if ( ! is_array($value) ) { return $value; diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index f4feb585..51022212 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -365,7 +365,7 @@ protected function inspect_worktree_capacity( string $repo, string $branch, bool assert_true($plan_inventory === $wpdb->rows, 'worktree plan changed inventory rows'); assert_true($plan_filesystem === plan_filesystem_snapshot($workspace_root), 'worktree plan changed the workspace filesystem'); $applied_plan = $stable_plan_workspace->worktree_apply_plan($create_plan); - assert_true(! is_wp_error($applied_plan) && is_dir($workspace_root . '/homeboy@planned-create'), is_wp_error($applied_plan) ? $applied_plan->get_error_message() : 'unchanged create plan did not apply'); + assert_true(! is_wp_error($applied_plan) && is_dir($workspace_root . '/homeboy@planned-create'), is_wp_error($applied_plan) ? $applied_plan->get_error_message() . ' ' . wp_json_encode($applied_plan->get_error_data()) : 'unchanged create plan did not apply'); $capacity_planner = new class extends Workspace { protected function inspect_worktree_capacity( string $repo, string $branch, bool $force, array $demand_plan ): array { return array( 'status' => 'refused', 'demand_plan' => $demand_plan ); diff --git a/tests/worktree-percentage-byte-floor-plan-apply.php b/tests/worktree-percentage-byte-floor-plan-apply.php index f17a2d7a..f6379b78 100644 --- a/tests/worktree-percentage-byte-floor-plan-apply.php +++ b/tests/worktree-percentage-byte-floor-plan-apply.php @@ -32,7 +32,7 @@ function percentage_plan_apply_assert( bool $condition, string $message ): void ); $capacity = array( 'status' => 'warning', 'creation_allowed' => true, 'filesystem_free_bytes' => 40, 'projected_free_bytes' => 30, - 'filesystem_free_inodes' => 500, 'projected_free_inodes' => 400, 'refuse_free_bytes' => 20, 'effective_refuse_bytes' => 100, + 'filesystem_total_inodes' => 1000000, 'filesystem_free_inodes' => 500, 'projected_free_inodes' => 400, 'refuse_free_bytes' => 20, 'effective_refuse_bytes' => 100, 'refuse_percent_bytes_floor' => 100, 'refuse_free_inodes' => 100, 'effective_refuse_inodes' => 100, 'trigger_reasons' => array( 'projected_free_bytes_percentage_refusal_floor' ), 'typed_trigger_reasons' => array( array( 'code' => 'projected_free_bytes_percentage_refusal_floor', 'severity' => 'blocking' ) ), @@ -55,6 +55,11 @@ function percentage_plan_apply_assert( bool $condition, string $message ): void percentage_plan_apply_assert(($planned['digest'] ?? '') !== ($changed['digest'] ?? ''), sprintf('Changed %s must produce a stale plan digest before apply.', $name)); } +$dynamic_inode_supply = $result->invoke($workspace, $intent, 'repo@small-demand', '/tmp/repo@small-demand', 'small-demand', 'create', array_replace($evidence, array( + 'capacity' => array_replace($capacity, array( 'filesystem_total_inodes' => 2000000 )), +))); +percentage_plan_apply_assert(($planned['digest'] ?? '') === ($dynamic_inode_supply['digest'] ?? ''), 'Dynamic filesystem total inode supply must not stale an unchanged capacity decision.'); + $changed_provenance = $result->invoke($workspace, $intent, 'repo@small-demand', '/tmp/repo@small-demand', 'small-demand', 'create', array_replace($evidence, array( 'bootstrap_demand' => array_replace($evidence['bootstrap_demand'], array( 'source' => 'not_provided' ) ) ))); percentage_plan_apply_assert(($planned['digest'] ?? '') !== ($changed_provenance['digest'] ?? ''), 'Changed trusted demand provenance must produce a stale plan digest before apply.'); From 523b623b47f270f6d478a737a87b655c801e28d0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 02:57:45 -0400 Subject: [PATCH 08/10] style(cleanup): satisfy operation lint --- inc/Cleanup/CleanupRunControlOperation.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/inc/Cleanup/CleanupRunControlOperation.php b/inc/Cleanup/CleanupRunControlOperation.php index 85d21c13..61fb2e67 100644 --- a/inc/Cleanup/CleanupRunControlOperation.php +++ b/inc/Cleanup/CleanupRunControlOperation.php @@ -56,7 +56,7 @@ public function drain( array $result, bool $verbose = false ): array { } $children = (array) ( $status['evidence']['children'] ?? array() ); - $undrainable_child_ids = self::job_ids((array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); + $undrainable_child_ids = self::job_ids( (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); if ( array() !== $undrainable_child_ids ) { $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_child_ids); $pass_repaired_child_ids = array_values(array_diff($undrainable_child_ids, (array) $repair['unrepairable'])); @@ -137,7 +137,10 @@ public function control( string $operation, int $job_id, bool $force = false ): $results[] = $result; } - $output = $results[0] ?? array( 'success' => true, 'job_id' => $job_id ); + $output = $results[0] ?? array( + 'success' => true, + 'job_id' => $job_id, + ); $output['run_id'] = self::run_id($job_id); $output['state'] = 'resume' === $operation ? 'running' : 'cancelled'; $output['controlled_job_ids'] = $target_job_ids; @@ -153,10 +156,10 @@ private function control_job_ids( string $operation, int $job_id ): array { } $children = (array) ( $output['evidence']['children'] ?? array() ); - $processing_ids = self::job_ids((array) ( $children['processing_job_ids'] ?? array() )); - $failed_ids = self::job_ids((array) ( $children['failed_job_ids'] ?? array() )); - $pending_ids = self::job_ids((array) ( $children['pending_job_ids'] ?? array() )); - $undrainable_ids = self::job_ids((array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); + $processing_ids = self::job_ids( (array) ( $children['processing_job_ids'] ?? array() )); + $failed_ids = self::job_ids( (array) ( $children['failed_job_ids'] ?? array() )); + $pending_ids = self::job_ids( (array) ( $children['pending_job_ids'] ?? array() )); + $undrainable_ids = self::job_ids( (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); if ( 'resume' === $operation ) { $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_ids); From c16c460326dab3d460e7d559edafbe4f8907ddab Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 10:14:01 -0400 Subject: [PATCH 09/10] fix(worktree): preserve stable plan outcomes --- inc/Workspace/WorkspaceWorktreeLifecycle.php | 45 +++++++++++++++---- inc/Workspace/WorktreeStalenessProbe.php | 16 ++++++- tests/worktree-add-lifecycle.php | 10 ++--- ...ktree-percentage-byte-floor-plan-apply.php | 9 ++++ tests/worktree-staleness-fetch-retry.php | 10 +++++ 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 8497db42..107ee53f 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1039,21 +1039,20 @@ private function worktree_plan_result( array $input, string $handle, string $pat private function worktree_plan_capacity_identity( array $capacity ): array { $exception = (array) ( $capacity['admission_exception'] ?? array() ); $projected_exception = (array) ( $exception['projected_post_create_capacity'] ?? array() ); - if ( array() !== $projected_exception ) { + $bind_measurements = ! empty($exception['operator_intent']); + if ( $bind_measurements && array() !== $projected_exception ) { $exception['projected_post_create_capacity'] = array( 'free_bytes' => $this->worktree_plan_capacity_measurement($projected_exception['free_bytes'] ?? null, 64 * 1024 * 1024), 'free_inodes' => $this->worktree_plan_capacity_measurement($projected_exception['free_inodes'] ?? null, 1000000), ); + } else { + unset($exception['projected_post_create_capacity']); } - return array( + $identity = array( 'status' => $capacity['status'] ?? null, 'creation_allowed' => $capacity['creation_allowed'] ?? null, 'filesystem_total_bytes' => $capacity['filesystem_total_bytes'] ?? null, - 'filesystem_free_bytes' => $this->worktree_plan_capacity_measurement($capacity['filesystem_free_bytes'] ?? null, 64 * 1024 * 1024), - 'projected_free_bytes' => $this->worktree_plan_capacity_measurement($capacity['projected_free_bytes'] ?? null, 64 * 1024 * 1024), - 'filesystem_free_inodes' => $this->worktree_plan_capacity_measurement($capacity['filesystem_free_inodes'] ?? null, 1000000), - 'projected_free_inodes' => $this->worktree_plan_capacity_measurement($capacity['projected_free_inodes'] ?? null, 1000000), 'refuse_free_bytes' => $capacity['refuse_free_bytes'] ?? null, 'refuse_percent_bytes_floor' => $capacity['refuse_percent_bytes_floor'] ?? null, 'effective_refuse_bytes' => $capacity['effective_refuse_bytes'] ?? null, @@ -1067,6 +1066,13 @@ private function worktree_plan_capacity_identity( array $capacity ): array { 'force_override_applied' => $capacity['force_override_applied'] ?? null, 'worktree_count' => $capacity['worktree_count'] ?? null, ); + if ( $bind_measurements ) { + $identity['filesystem_free_bytes'] = $this->worktree_plan_capacity_measurement($capacity['filesystem_free_bytes'] ?? null, 64 * 1024 * 1024); + $identity['projected_free_bytes'] = $this->worktree_plan_capacity_measurement($capacity['projected_free_bytes'] ?? null, 64 * 1024 * 1024); + $identity['filesystem_free_inodes'] = $this->worktree_plan_capacity_measurement($capacity['filesystem_free_inodes'] ?? null, 1000000); + $identity['projected_free_inodes'] = $this->worktree_plan_capacity_measurement($capacity['projected_free_inodes'] ?? null, 1000000); + } + return $identity; } private function worktree_plan_capacity_measurement( mixed $value, int $quantum ): mixed { @@ -1449,6 +1455,19 @@ private function worktree_capacity_preflight( string $primary_path, string $repo $this->worktree_add_progress($progress_callback, 'freshness_fetch'); $fetch = WorktreeStalenessProbe::fetch($primary_path, null, $operation_deadline, null, $from); if ( ! $fetch['ok'] ) { + if ( ! empty($fetch['missing_remote_ref']) ) { + $exists_local = GitRunner::ref_exists($primary_path, 'refs/heads/' . $branch); + $target_ref = $exists_local ? 'refs/heads/' . $branch : (string) $from; + $demand_plan = WorktreeBootstrapper::demand_plan_for_target($primary_path, $target_ref, $bootstrap); + if ( $demand_plan instanceof \WP_Error ) { + return array( + 'fetch' => $fetch, + 'exists_local' => $exists_local, + 'target_ref' => $target_ref, + 'demand_plan' => $demand_plan, + ); + } + } return array( 'fetch' => $fetch ); } @@ -1701,6 +1720,16 @@ private function worktree_add_with_capacity_lock( ); } } + $exists_local = array_key_exists('exists_local', $preflight) ? (bool) $preflight['exists_local'] : GitRunner::ref_exists($primary_path, 'refs/heads/' . $branch); + $target_ref = (string) ( $preflight['target_ref'] ?? ( $exists_local ? 'refs/heads/' . $branch : ( $from && '' !== trim($from) ? trim($from) : $this->resolve_default_base($primary_path) ) ) ); + $demand_plan = $preflight['demand_plan'] ?? null; + if ( $demand_plan instanceof \WP_Error ) { + if ( 'worktree_target_ref_invalid' === $demand_plan->get_error_code() && ! $exists_local && null !== $from && '' !== trim($from) ) { + return $this->worktree_missing_explicit_base_error($demand_plan, $primary_path, $repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force, $task, $intent); + } + return $demand_plan; + } + $fetch = (array) ( $preflight['fetch'] ?? WorktreeStalenessProbe::fetch($primary_path, null, $operation_deadline) ); $fetch_failed = ! $fetch['ok']; $fetch_error = $fetch['error'] ?? null; @@ -1737,8 +1766,6 @@ private function worktree_add_with_capacity_lock( ); } - $exists_local = array_key_exists('exists_local', $preflight) ? (bool) $preflight['exists_local'] : GitRunner::ref_exists($primary_path, 'refs/heads/' . $branch); - $target_ref = (string) ( $preflight['target_ref'] ?? ( $exists_local ? 'refs/heads/' . $branch : ( $from && '' !== trim($from) ? trim($from) : $this->resolve_default_base($primary_path) ) ) ); if ( array() !== $expected_freshness_identity ) { $actual_freshness_identity = $this->primary_freshness_identity($primary_path, $target_ref); if ( $expected_freshness_identity !== $actual_freshness_identity ) { @@ -1753,7 +1780,7 @@ private function worktree_add_with_capacity_lock( ); } } - $demand_plan = $preflight['demand_plan'] ?? WorktreeBootstrapper::demand_plan_for_target($primary_path, $target_ref, $bootstrap); + $demand_plan ??= WorktreeBootstrapper::demand_plan_for_target($primary_path, $target_ref, $bootstrap); if ( $demand_plan instanceof \WP_Error ) { if ( 'worktree_target_ref_invalid' === $demand_plan->get_error_code() && ! $exists_local && null !== $from && '' !== trim($from) ) { return $this->worktree_missing_explicit_base_error( diff --git a/inc/Workspace/WorktreeStalenessProbe.php b/inc/Workspace/WorktreeStalenessProbe.php index 79ea6e51..dd2e3ef3 100644 --- a/inc/Workspace/WorktreeStalenessProbe.php +++ b/inc/Workspace/WorktreeStalenessProbe.php @@ -61,7 +61,7 @@ final class WorktreeStalenessProbe { * @param callable|null $runner Optional git runner, used by deterministic tests. * @param callable|null $fallback_resolver Optional equivalent-transport resolver, used by deterministic tests. * @param string|null $remote_ref Requested `origin/*` base, used to avoid repository-wide wildcard fetches. - * @return array{ok: bool, attempts: int, attempted_transports: string[], successful_transport?: string, transport_fallback_used?: bool, fallback_preflight_code?: string, error?: string, timed_out?: bool, timeout_seconds?: int} + * @return array{ok: bool, attempts: int, attempted_transports: string[], successful_transport?: string, transport_fallback_used?: bool, fallback_preflight_code?: string, error?: string, timed_out?: bool, timeout_seconds?: int, missing_remote_ref?: bool, remote_ref?: string} */ public static function fetch( string $repo_path, ?callable $runner = null, ?float $deadline = null, ?callable $fallback_resolver = null, ?string $remote_ref = null ): array { $runner = $runner ?? static fn( string $path, string $args, int $timeout ): array|\WP_Error => GitRunner::run($path, $args, $timeout); @@ -136,11 +136,17 @@ public static function fetch( string $repo_path, ?callable $runner = null, ?floa ), $transport_evidence); } - return array_merge(array( + $missing_remote_ref = null !== $remote_ref && self::remote_ref_missing($error); + $response = array_merge(array( 'ok' => false, 'attempts' => $attempt, 'error' => $error, ), $transport_evidence); + if ( $missing_remote_ref ) { + $response['missing_remote_ref'] = true; + $response['remote_ref'] = $remote_ref; + } + return $response; } } @@ -151,6 +157,12 @@ public static function fetch( string $repo_path, ?callable $runner = null, ?floa ); } + /** Classify Git's stable missing-ref diagnostic separately from transport failure. */ + private static function remote_ref_missing( string $error ): bool { + $error = strtolower($error); + return str_contains($error, "couldn't find remote ref") || str_contains($error, 'could not find remote ref'); + } + /** Resolve an SSH retry for the same authorized GitHub repository. */ private static function equivalent_transport_fallback( string $repo_path, ?string $remote_ref = null ): ?array { $remote = GitRunner::remote_url($repo_path); diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index 51022212..3e326139 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -1088,7 +1088,7 @@ protected function worktree_behind_count( string $repo_path, string $ref, string assert_true(1 === ( $finalizer_timeout->get_error_data()['timeout_seconds'] ?? null ), 'stalled finalizer dirty probe did not preserve its configured budget'); assert_true($elapsed < 3.0, sprintf('stalled finalizer dirty probe exceeded its configured bound: %.3fs', $elapsed)); assert_true('active' === ( $wpdb->rows[$handle]['lifecycle_state'] ?? '' ), 'timed-out finalizer dirty probe mutated lifecycle metadata'); - $GLOBALS['datamachine_code_test_filters']['datamachine_code_workspace_target_lookup_timeout_seconds'] = static fn() => 7; + $GLOBALS['datamachine_code_test_filters']['datamachine_code_workspace_target_lookup_timeout_seconds'] = static fn() => 10; putenv('DMC_FINALIZER_STATUS_DELAY=6'); $started = microtime(true); $clean_finalization = $workspace->worktree_finalize($handle, 'merged'); @@ -1098,7 +1098,7 @@ protected function worktree_behind_count( string $repo_path, string $ref, string unset($GLOBALS['datamachine_code_test_filters']['datamachine_code_workspace_target_lookup_timeout_seconds']); assert_true(! is_wp_error($clean_finalization), is_wp_error($clean_finalization) ? 'clean terminal worktree finalization failed: ' . $clean_finalization->get_error_code() . ' ' . $clean_finalization->get_error_message() : 'clean terminal worktree finalization failed'); assert_true('cleanup_eligible' === ( $clean_finalization['lifecycle_state'] ?? '' ), 'clean terminal finalization did not expose cleanup eligibility'); - assert_true($elapsed >= 5.5 && $elapsed < 8.5, sprintf('large clean-worktree finalization did not honor its deterministic process budget: %.3fs', $elapsed)); + assert_true($elapsed >= 5.5 && $elapsed < 11.5, sprintf('large clean-worktree finalization did not honor its deterministic process budget: %.3fs', $elapsed)); $show = $workspace->show_repo('homeboy@audit-primitives-20260616'); assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo'); @@ -1217,7 +1217,7 @@ protected function worktree_behind_count( string $repo_path, string $ref, string // default ref and an exact corrected command for main, trunk, and custom heads. $missing_main = $workspace->worktree_add('homeboy', 'missing-main-base', 'origin/not-a-ref', false, false, false, false, true); assert_true(is_wp_error($missing_main), 'missing explicit main base reported success'); - assert_true('worktree_target_ref_invalid' === $missing_main->get_error_code(), 'missing explicit main base changed the existing error code'); + assert_true('worktree_target_ref_invalid' === $missing_main->get_error_code(), 'missing explicit main base changed the existing error code: ' . $missing_main->get_error_code() . ' ' . wp_json_encode($missing_main->get_error_data())); $missing_main_data = (array) $missing_main->get_error_data(); assert_true('origin/main' === ( $missing_main_data['detected_default_ref'] ?? null ), 'missing explicit main base did not detect origin/main'); assert_true('remote_head' === ( $missing_main_data['default_ref_source'] ?? null ), 'missing explicit main base did not report remote-head evidence'); @@ -1317,7 +1317,7 @@ static function ( array $event ) use ( &$progress, &$bootstrap_outcome_at_comple return; } $bootstrap_outcome_at_complete = WorktreeContextInjector::get_metadata_fresh('homeboy@handoff-partial-success')['provisioning']['bootstrap']['outcome'] ?? null; - $script = "#!/bin/sh\ncount=0\n[ -f " . escapeshellarg($handoff_call_count) . " ] && count=\$(cat " . escapeshellarg($handoff_call_count) . ")\ncount=\$((count + 1))\nprintf '%s\\n' \"\$count\" > " . escapeshellarg($handoff_call_count) . "\n[ \"\$count\" -eq 1 ] && exec git upload-pack \"\$@\"\nsleep 10\n"; + $script = "#!/bin/sh\ncount=0\n[ -f " . escapeshellarg($handoff_call_count) . " ] && count=\$(cat " . escapeshellarg($handoff_call_count) . ")\ncount=\$((count + 1))\nprintf '%s\\n' \"\$count\" > " . escapeshellarg($handoff_call_count) . "\n[ \"\$count\" -eq 1 ] && exec git upload-pack \"\$@\"\nsleep 60\n"; file_put_contents($handoff_upload_pack, $script); chmod($handoff_upload_pack, 0700); run_command('git config remote.origin.uploadpack ' . escapeshellarg($handoff_upload_pack), $primary_path); @@ -1326,7 +1326,7 @@ static function ( array $event ) use ( &$progress, &$bootstrap_outcome_at_comple $partial = is_wp_error($timed_out_add) ? (array) $timed_out_add->get_error_data() : array(); assert_true(is_wp_error($timed_out_add) && 'worktree_handoff_freshness_unverified' === $timed_out_add->get_error_code(), 'post-commit handoff timeout did not remain a fail-closed add result'); assert_true(true === ( $partial['partial_success'] ?? false ) && true === ( $partial['mutation_committed'] ?? false ) && 'worktree_allocation_committed' === ( $partial['mutation_boundary'] ?? null ), 'post-commit handoff timeout omitted its explicit mutation boundary'); - assert_true(in_array((string) ( $partial['handoff_freshness']['reason'] ?? '' ), array( 'worktree_handoff_revalidation_timeout', 'remote_default_unresolved' ), true) && true === ( $partial['allocation']['success'] ?? false ), 'post-commit handoff failure lost its typed freshness cause or committed allocation'); + assert_true(in_array((string) ( $partial['handoff_freshness']['reason'] ?? '' ), array( 'worktree_handoff_revalidation_timeout', 'remote_default_unresolved' ), true) && true === ( $partial['allocation']['success'] ?? false ), 'post-commit handoff failure lost its typed freshness cause or committed allocation: ' . wp_json_encode($partial)); assert_true(false === ( $partial['retry']['repeat_allocation'] ?? true ) && false === ( $partial['retry']['repeat_bootstrap'] ?? true ), 'post-commit handoff timeout did not prohibit allocation/bootstrap replay'); assert_true('succeeded' === $bootstrap_outcome_at_complete, 'no-op bootstrap completion became observable before terminal metadata was persisted'); $continuation = (array) ( $partial['continuation'] ?? array() ); diff --git a/tests/worktree-percentage-byte-floor-plan-apply.php b/tests/worktree-percentage-byte-floor-plan-apply.php index f6379b78..df9fa655 100644 --- a/tests/worktree-percentage-byte-floor-plan-apply.php +++ b/tests/worktree-percentage-byte-floor-plan-apply.php @@ -60,6 +60,15 @@ function percentage_plan_apply_assert( bool $condition, string $message ): void ))); percentage_plan_apply_assert(($planned['digest'] ?? '') === ($dynamic_inode_supply['digest'] ?? ''), 'Dynamic filesystem total inode supply must not stale an unchanged capacity decision.'); +$ordinary_capacity = array_replace($capacity, array( + 'admission_exception' => array( 'operator_intent' => false, 'status' => 'not_requested' ), +)); +$ordinary_plan = $result->invoke($workspace, $intent, 'repo@small-demand', '/tmp/repo@small-demand', 'small-demand', 'create', array_replace($evidence, array( 'capacity' => $ordinary_capacity ))); +$ordinary_drift = $result->invoke($workspace, $intent, 'repo@small-demand', '/tmp/repo@small-demand', 'small-demand', 'create', array_replace($evidence, array( + 'capacity' => array_replace($ordinary_capacity, array( 'filesystem_free_bytes' => 39, 'projected_free_bytes' => 29 )), +))); +percentage_plan_apply_assert(($ordinary_plan['digest'] ?? '') === ($ordinary_drift['digest'] ?? ''), 'Ambient free-space drift must not stale an unchanged ordinary admission decision.'); + $changed_provenance = $result->invoke($workspace, $intent, 'repo@small-demand', '/tmp/repo@small-demand', 'small-demand', 'create', array_replace($evidence, array( 'bootstrap_demand' => array_replace($evidence['bootstrap_demand'], array( 'source' => 'not_provided' ) ) ))); percentage_plan_apply_assert(($planned['digest'] ?? '') !== ($changed_provenance['digest'] ?? ''), 'Changed trusted demand provenance must produce a stale plan digest before apply.'); diff --git a/tests/worktree-staleness-fetch-retry.php b/tests/worktree-staleness-fetch-retry.php index b8265aaf..e2538c49 100644 --- a/tests/worktree-staleness-fetch-retry.php +++ b/tests/worktree-staleness-fetch-retry.php @@ -53,4 +53,14 @@ static function () use ( &$calls ): WP_Error { staleness_retry_assert_same(2, $failed['attempts'], 'Persistent fetch failures must report the retry count.'); staleness_retry_assert_same('fatal: unable to access origin', $failed['error'], 'Persistent fetch failures must expose Git stderr.'); +$missing = WorktreeStalenessProbe::fetch( + '/repo', + static fn(): WP_Error => new WP_Error('git_command_failed', 'fetch failed', array( 'output' => "fatal: couldn't find remote ref refs/heads/not-a-ref" )), + null, + null, + 'origin/not-a-ref' +); +staleness_retry_assert_same(true, $missing['missing_remote_ref'] ?? false, 'A targeted missing remote ref must not be classified as a transport failure.'); +staleness_retry_assert_same('origin/not-a-ref', $missing['remote_ref'] ?? null, 'Missing-ref evidence must retain the requested ref.'); + fwrite(STDOUT, "worktree-staleness-fetch-retry: ok\n"); From 3adc4243af3848183256a267ae037e8edc06403c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 27 Aug 2026 19:21:29 -0400 Subject: [PATCH 10/10] fix(cleanup): host run-control operation with workspace operations The audit ratchet treated inc/Cleanup as a Store convention, so the new operation was a naming outlier. Moving it next to WorktreeAllocationOperation keeps Cleanup/ as evidence stores and makes drain orchestration explicit steps instead of a page-loop lookalike. --- inc/Cli/Commands/WorkspaceCommand.php | 2 +- .../CleanupRunControlOperation.php | 169 +++++++++++------- .../workspace-cleanup-drainability-repair.php | 4 +- 3 files changed, 105 insertions(+), 70 deletions(-) rename inc/{Cleanup => Workspace}/CleanupRunControlOperation.php (58%) diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index c498eb0d..a473c05b 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -24,7 +24,7 @@ use DataMachineCode\Cli\WorkspaceCompactOutput; use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; -use DataMachineCode\Cleanup\CleanupRunControlOperation; +use DataMachineCode\Workspace\CleanupRunControlOperation; use DataMachineCode\Workspace\Workspace; use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Workspace\WorkspaceMutationLock; diff --git a/inc/Cleanup/CleanupRunControlOperation.php b/inc/Workspace/CleanupRunControlOperation.php similarity index 58% rename from inc/Cleanup/CleanupRunControlOperation.php rename to inc/Workspace/CleanupRunControlOperation.php index 61fb2e67..939f94d9 100644 --- a/inc/Cleanup/CleanupRunControlOperation.php +++ b/inc/Workspace/CleanupRunControlOperation.php @@ -2,17 +2,20 @@ /** * Job-backed cleanup run drain and control orchestration. * - * @package DataMachineCode\Cleanup + * @package DataMachineCode\Workspace */ -namespace DataMachineCode\Cleanup; +namespace DataMachineCode\Workspace; +use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; use DataMachineCode\Support\SystemTaskDrainability; defined('ABSPATH') || exit; final class CleanupRunControlOperation { + private const CHILD_DRAIN_PASS_LIMIT = 10; + private \Closure $command_runner; private \Closure $ability_resolver; @@ -37,71 +40,9 @@ public function drain( array $result, bool $verbose = false ): array { return $result; } - $commands = array(); - $errors = array(); - $drainability_repairs = array(); - $repaired_child_ids = array(); - $parent_command = sprintf('datamachine drain --job-id=%d', $job_id); - $commands[] = 'studio wp ' . $parent_command; - $error = (string) ( $this->command_runner )($parent_command); - if ( '' !== $error ) { - $errors[] = $error; - } - - for ( $pass = 0; $pass < 10; ++$pass ) { - $status = $this->evidence_store->read($run_id, true, true); - if ( $status instanceof \WP_Error ) { - $errors[] = $status->get_error_message(); - break; - } - - $children = (array) ( $status['evidence']['children'] ?? array() ); - $undrainable_child_ids = self::job_ids( (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); - if ( array() !== $undrainable_child_ids ) { - $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_child_ids); - $pass_repaired_child_ids = array_values(array_diff($undrainable_child_ids, (array) $repair['unrepairable'])); - $repaired_child_ids = array_values(array_unique(array_merge($repaired_child_ids, $pass_repaired_child_ids))); - $drainability_repairs[] = array( - 'pass' => $pass + 1, - 'detected_child_job_ids' => $undrainable_child_ids, - 'repaired_child_job_ids' => $pass_repaired_child_ids, - 'unrepairable_child_job_ids' => (array) $repair['unrepairable'], - ); - } - - $active_child_ids = self::job_ids(array_merge( - (array) ( $children['pending_job_ids'] ?? array() ), - (array) ( $children['processing_job_ids'] ?? array() ) - )); - if ( array() === $active_child_ids ) { - break; - } - - $child_command = sprintf('datamachine drain --job-id=%s', implode(',', $active_child_ids)); - $commands[] = 'studio wp ' . $child_command; - $error = (string) ( $this->command_runner )($child_command); - if ( '' !== $error ) { - $errors[] = $error; - break; - } - } - - $final = $this->evidence_store->read($run_id, false, $verbose); - $output = $final instanceof \WP_Error ? $result : $final; - $output['initial_run'] = $result; - $output['drain'] = array( - 'success' => array() === $errors, - 'commands' => $commands, - 'errors' => $errors, - 'verify_command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), - 'bytes_reclaimed' => (int) ( $output['cleanup_items']['bytes_reclaimed'] ?? 0 ), - 'freed_human' => (string) ( $output['cleanup_items']['freed_human'] ?? '0 B' ), - 'completion_state' => (string) ( $output['state'] ?? 'unknown' ), - 'drainability_repairs' => $drainability_repairs, - 'repaired_child_job_ids' => $repaired_child_ids, - ); - - return $output; + $session = $this->run_parent_drain($job_id); + $session = $this->drain_remaining_children($run_id, $session); + return $this->assemble_drain_result($result, $run_id, $verbose, $session); } /** Resume or cancel the parent and active child jobs for a cleanup run. */ @@ -148,6 +89,100 @@ public function control( string $operation, int $job_id, bool $force = false ): return $output; } + /** @return array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} */ + private function run_parent_drain( int $job_id ): array { + $parent_command = sprintf('datamachine drain --job-id=%d', $job_id); + $error = (string) ( $this->command_runner )($parent_command); + return array( + 'commands' => array( 'studio wp ' . $parent_command ), + 'errors' => '' === $error ? array() : array( $error ), + 'drainability_repairs' => array(), + 'repaired_child_ids' => array(), + ); + } + + /** + * @param array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} $session + * @return array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} + */ + private function drain_remaining_children( string $run_id, array $session ): array { + $pass = 0; + while ( $pass < self::CHILD_DRAIN_PASS_LIMIT ) { + ++$pass; + $status = $this->evidence_store->read($run_id, true, true); + if ( $status instanceof \WP_Error ) { + $session['errors'][] = $status->get_error_message(); + return $session; + } + + $children = (array) ( $status['evidence']['children'] ?? array() ); + $session = $this->repair_undrainable_children($children, $pass, $session); + + $active_child_ids = self::job_ids(array_merge( + (array) ( $children['pending_job_ids'] ?? array() ), + (array) ( $children['processing_job_ids'] ?? array() ) + )); + if ( array() === $active_child_ids ) { + return $session; + } + + $child_command = sprintf('datamachine drain --job-id=%s', implode(',', $active_child_ids)); + $session['commands'][] = 'studio wp ' . $child_command; + $error = (string) ( $this->command_runner )($child_command); + if ( '' !== $error ) { + $session['errors'][] = $error; + return $session; + } + } + + return $session; + } + + /** + * @param array $children + * @param array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} $session + * @return array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} + */ + private function repair_undrainable_children( array $children, int $pass, array $session ): array { + $undrainable_child_ids = self::job_ids( (array) ( $children['pending_without_drainable_action_job_ids'] ?? array() )); + if ( array() === $undrainable_child_ids ) { + return $session; + } + + $repair = SystemTaskDrainability::ensure_jobs_have_execute_step_actions($undrainable_child_ids); + $pass_repaired_child_ids = array_values(array_diff($undrainable_child_ids, (array) $repair['unrepairable'])); + $session['repaired_child_ids'] = array_values(array_unique(array_merge($session['repaired_child_ids'], $pass_repaired_child_ids))); + $session['drainability_repairs'][] = array( + 'pass' => $pass, + 'detected_child_job_ids' => $undrainable_child_ids, + 'repaired_child_job_ids' => $pass_repaired_child_ids, + 'unrepairable_child_job_ids' => (array) $repair['unrepairable'], + ); + return $session; + } + + /** + * @param array{commands:array,errors:array,drainability_repairs:array>,repaired_child_ids:array} $session + */ + private function assemble_drain_result( array $result, string $run_id, bool $verbose, array $session ): array { + $final = $this->evidence_store->read($run_id, false, $verbose); + $output = $final instanceof \WP_Error ? $result : $final; + $output['initial_run'] = $result; + $output['drain'] = array( + 'success' => array() === $session['errors'], + 'commands' => $session['commands'], + 'errors' => $session['errors'], + 'verify_command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), + 'bytes_reclaimed' => (int) ( $output['cleanup_items']['bytes_reclaimed'] ?? 0 ), + 'freed_human' => (string) ( $output['cleanup_items']['freed_human'] ?? '0 B' ), + 'completion_state' => (string) ( $output['state'] ?? 'unknown' ), + 'drainability_repairs' => $session['drainability_repairs'], + 'repaired_child_job_ids' => $session['repaired_child_ids'], + ); + + return $output; + } + /** Resolve which Data Machine jobs should be controlled for the cleanup run. */ private function control_job_ids( string $operation, int $job_id ): array { $output = $this->evidence_store->read(self::run_id($job_id), true, true); diff --git a/tests/workspace-cleanup-drainability-repair.php b/tests/workspace-cleanup-drainability-repair.php index d8d853f3..2aa063a2 100644 --- a/tests/workspace-cleanup-drainability-repair.php +++ b/tests/workspace-cleanup-drainability-repair.php @@ -36,11 +36,11 @@ public static function runcommand( string $command, array $args ): string { require_once dirname(__DIR__) . '/inc/Cleanup/CleanupRunEvidenceStoreInterface.php'; require_once dirname(__DIR__) . '/inc/Support/SystemTaskDrainability.php'; - require_once dirname(__DIR__) . '/inc/Cleanup/CleanupRunControlOperation.php'; + require_once dirname(__DIR__) . '/inc/Workspace/CleanupRunControlOperation.php'; require_once dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php'; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; - use DataMachineCode\Cleanup\CleanupRunControlOperation; + use DataMachineCode\Workspace\CleanupRunControlOperation; use DataMachineCode\Cli\Commands\WorkspaceCommand; $GLOBALS['workspace_cleanup_drainability_jobs'] = array(