Skip to content

Commit 3a97cd3

Browse files
authored
Merge pull request #953 from Extra-Chill/fix/951-workspace-cli-hang
Keep workspace help and targeted reads bounded
2 parents 358cbb4 + 81c16f4 commit 3a97cd3

8 files changed

Lines changed: 480 additions & 40 deletions

data-machine-code.php

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,47 @@
2121
define( 'DATAMACHINE_CODE_PATH', plugin_dir_path( __FILE__ ) );
2222
define( 'DATAMACHINE_CODE_URL', plugin_dir_url( __FILE__ ) );
2323

24+
/**
25+
* Classify DMC CLI requests which must not initialize runtime services.
26+
*
27+
* WP-CLI loads WordPress before it dispatches nested help, so command methods
28+
* cannot protect help from plugin bootstrap side effects. Inspect only DMC's
29+
* own argv namespace here and leave every unrelated WP-CLI request unchanged.
30+
*
31+
* @param array<int,mixed>|null $argv Raw process arguments.
32+
*/
33+
function datamachine_code_is_side_effect_free_cli_request( ?array $argv = null ): bool {
34+
if ( ! defined('WP_CLI') || ! WP_CLI ) {
35+
return false;
36+
}
37+
38+
$tokens = array_values(array_map('strval', $argv ?? ( is_array($GLOBALS['argv'] ?? null) ? $GLOBALS['argv'] : array() )));
39+
if ( ! in_array('datamachine-code', $tokens, true) ) {
40+
return false;
41+
}
42+
43+
if ( in_array('--help', $tokens, true) || in_array('-h', $tokens, true) ) {
44+
return true;
45+
}
46+
47+
$help_index = array_search('help', $tokens, true);
48+
$dmc_index = array_search('datamachine-code', $tokens, true);
49+
return false !== $help_index && false !== $dmc_index && $help_index < $dmc_index;
50+
}
51+
52+
/**
53+
* Whether this request is a targeted, read-only workspace command.
54+
*/
55+
function datamachine_code_is_targeted_workspace_read_cli_request( ?array $argv = null ): bool {
56+
if ( ! defined('WP_CLI') || ! WP_CLI ) {
57+
return false;
58+
}
59+
60+
$tokens = array_values(array_map('strval', $argv ?? ( is_array($GLOBALS['argv'] ?? null) ? $GLOBALS['argv'] : array() )));
61+
$workspace_index = array_search('workspace', $tokens, true);
62+
return false !== $workspace_index && 'show' === ( $tokens[ $workspace_index + 1 ] ?? '' ) && in_array('datamachine-code', array_slice($tokens, 0, $workspace_index), true);
63+
}
64+
2465
// PSR-4 Autoloading.
2566
require_once __DIR__ . '/vendor/autoload.php';
2667

@@ -41,7 +82,7 @@ function datamachine_code_install_schema(): void {
4182
* Keep schema current for already-active installs after deploy/update.
4283
*/
4384
function datamachine_code_maybe_upgrade_schema(): void {
44-
if ( function_exists('wp_installing') && wp_installing() ) {
85+
if ( datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() || ( function_exists('wp_installing') && wp_installing() ) ) {
4586
return;
4687
}
4788

@@ -75,7 +116,7 @@ function datamachine_code_has_datamachine_integration(): bool {
75116
function datamachine_code_register_bundle_artifacts(): void {
76117
static $registered = false;
77118

78-
if ( $registered ) {
119+
if ( $registered || datamachine_code_is_side_effect_free_cli_request() ) {
79120
return;
80121
}
81122

@@ -90,7 +131,7 @@ function datamachine_code_register_bundle_artifacts(): void {
90131
function datamachine_code_register_datamachine_integrations(): void {
91132
static $registered = false;
92133

93-
if ( $registered || ! datamachine_code_has_datamachine_integration() ) {
134+
if ( $registered || datamachine_code_is_side_effect_free_cli_request() || ! datamachine_code_has_datamachine_integration() ) {
94135
return;
95136
}
96137

@@ -117,7 +158,7 @@ function datamachine_code_register_datamachine_integrations(): void {
117158
function datamachine_code_bootstrap() {
118159
static $bootstrapped = false;
119160

120-
if ( $bootstrapped ) {
161+
if ( $bootstrapped || datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() ) {
121162
return;
122163
}
123164

@@ -321,7 +362,7 @@ function datamachine_code_register_cli_commands() {
321362
* Only load when Data Machine core's AI engine is available.
322363
*/
323364
function datamachine_code_load_chat_tools() {
324-
if ( ! class_exists('DataMachine\Engine\AI\Tools\BaseTool') ) {
365+
if ( datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() || ! class_exists('DataMachine\Engine\AI\Tools\BaseTool') ) {
325366
return;
326367
}
327368

inc/Abilities/WorkspaceAbilities.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2888,21 +2888,22 @@ public static function getCapabilities( array $input ): array { // phpcs:ignor
28882888
*/
28892889
public static function showRepo( array $input ): array|\WP_Error {
28902890
$workspace = new Workspace();
2891-
if ( RemoteWorkspaceBackend::should_handle() ) {
2892-
$local_result = self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['name'] ?? '' ));
2893-
if ( null !== $local_result ) {
2894-
return $local_result;
2895-
}
2891+
$handle = (string) ( $input['name'] ?? '' );
2892+
$local = $workspace->show_repo($handle);
2893+
if ( ! is_wp_error($local) && empty($local['is_context']) ) {
2894+
return $local;
28962895
}
28972896

2897+
// Registered remote state remains authoritative for local misses, bounded
2898+
// local failures, and context aliases whose checkout is not mounted.
28982899
if ( RemoteWorkspaceBackend::should_handle() ) {
2899-
$result = ( new RemoteWorkspaceBackend() )->show($input['name'] ?? '');
2900+
$result = ( new RemoteWorkspaceBackend() )->show($handle);
29002901
if ( ! self::shouldFallbackToLocalWorkspace($result) ) {
29012902
return $result;
29022903
}
29032904
}
29042905

2905-
return $workspace->show_repo($input['name'] ?? '');
2906+
return $local;
29062907
}
29072908

29082909
/**

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
use WP_CLI;
1919
use DataMachine\Cli\BaseCommand;
20+
use DataMachineCode\Abilities\WorkspaceAbilities;
2021
use DataMachineCode\Cli\CliResponseRenderer;
2122
use DataMachineCode\Cli\CliRepeatableOptionParser;
2223
use DataMachineCode\Cli\WorkspaceCompactOutput;
@@ -2510,13 +2511,9 @@ public function show( array $args, array $assoc_args ): void {
25102511
return;
25112512
}
25122513

2513-
$ability = wp_get_ability('datamachine-code/workspace-show');
2514-
if ( ! $ability ) {
2515-
WP_CLI::error('Workspace show ability not available.');
2516-
return;
2517-
}
2518-
2519-
$result = $ability->execute(array( 'name' => $args[0] ));
2514+
// This targeted read is also the lightweight startup path used before the
2515+
// Abilities API runtime has been bootstrapped.
2516+
$result = WorkspaceAbilities::showRepo(array( 'name' => $args[0] ));
25202517

25212518
if ( is_wp_error($result) ) {
25222519
WP_CLI::error($result->get_error_message());

inc/Workspace/WorkspaceCoreUtilities.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,17 @@ private function build_primary_freshness_report( string $repo_path, string $hand
692692
);
693693
}
694694

695-
$header = strtok( (string) ( $status_result['output'] ?? '' ), "\n");
695+
return $this->build_primary_freshness_report_from_status_output((string) ( $status_result['output'] ?? '' ), $handle);
696+
}
697+
698+
/**
699+
* Build primary freshness metadata from an already-bounded local status probe.
700+
*
701+
* @return array<string,mixed>
702+
*/
703+
private function build_primary_freshness_report_from_status_output( string $status_output, string $handle ): array {
704+
705+
$header = strtok($status_output, "\n");
696706
$header = false === $header ? '' : trim($header);
697707
$branch = null;
698708
$detached = false;

inc/Workspace/WorkspaceRepositoryLifecycle.php

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -712,13 +712,28 @@ public function remove_repo( string $handle ): array|\WP_Error {
712712
* @return array{success: bool, name?: string, path?: string, branch?: string, remote?: string, commit?: string, dirty?: int}|\WP_Error
713713
*/
714714
public function show_repo( string $handle ): array|\WP_Error {
715-
$context_policy = WorkspaceAliasResolver::context_policy_for($handle);
715+
$requested_handle = $handle;
716+
$context_policy = null;
717+
$parsed = $this->parse_handle($handle);
718+
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
719+
$inspection = WorkspaceTargetInspector::inspect($repo_path, $parsed['dir_name']);
720+
if ( is_wp_error($inspection) ) {
721+
return $inspection;
722+
}
723+
724+
if ( empty($inspection['exists']) ) {
725+
$context_policy = WorkspaceAliasResolver::context_policy_for($handle);
726+
}
716727
if ( null !== $context_policy ) {
717728
$target = (string) ( $context_policy['target'] ?? $handle );
718729
$parsed = $this->parse_handle($target);
719730
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
720731
$ref = (string) ( $context_policy['ref'] ?? '' );
721-
if ( ! is_dir($repo_path) ) {
732+
$inspection = WorkspaceTargetInspector::inspect($repo_path, $handle);
733+
if ( is_wp_error($inspection) ) {
734+
return $inspection;
735+
}
736+
if ( empty($inspection['exists']) ) {
722737
return array(
723738
'success' => true,
724739
'name' => (string) $context_policy['alias'],
@@ -736,35 +751,37 @@ public function show_repo( string $handle ): array|\WP_Error {
736751
$handle = $target;
737752
}
738753

739-
$resolved_handle = $this->resolve_primary_repo_name($handle);
740-
if ( ! is_wp_error($resolved_handle) ) {
741-
$handle = $resolved_handle;
754+
if ( empty($inspection['exists']) && null === $context_policy ) {
755+
$resolved_handle = $this->resolve_primary_repo_name($handle);
756+
if ( ! is_wp_error($resolved_handle) && $resolved_handle !== $handle ) {
757+
$handle = $resolved_handle;
758+
$parsed = $this->parse_handle($handle);
759+
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
760+
$inspection = WorkspaceTargetInspector::inspect($repo_path, $parsed['dir_name']);
761+
if ( is_wp_error($inspection) ) {
762+
return $inspection;
763+
}
764+
}
742765
}
743766

744-
$parsed = $this->parse_handle($handle);
745-
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
746-
747-
if ( ! is_dir($repo_path) ) {
748-
return new \WP_Error('repo_not_found', sprintf('Workspace handle "%s" not found.', $parsed['dir_name']), array( 'status' => 404 ));
767+
if ( empty($inspection['exists']) ) {
768+
return new \WP_Error('repo_not_found', sprintf('Workspace handle "%s" not found.', $requested_handle), array( 'status' => 404 ));
749769
}
750770

751-
$branch = GitRunner::current_branch($repo_path);
752-
$remote = GitRunner::remote_url($repo_path);
753-
$commit = GitRunner::latest_commit_summary($repo_path);
754-
$status = GitRunner::dirty_count($repo_path);
755-
756771
$result = array(
757772
'success' => true,
758773
'name' => null !== $context_policy ? (string) $context_policy['alias'] : $parsed['dir_name'],
759774
'repo' => $parsed['repo'],
760775
'is_worktree' => $parsed['is_worktree'],
761776
'is_context' => null !== $context_policy,
762777
'path' => $repo_path,
763-
'branch' => $branch,
764-
'remote' => $remote,
765-
'commit' => $commit,
766-
'dirty' => $status,
767-
'primary_freshness' => ! $parsed['is_worktree'] ? $this->build_primary_freshness_report($repo_path, $parsed['dir_name']) : null,
778+
'branch' => $inspection['branch'] ?? null,
779+
'remote' => $inspection['remote'] ?? null,
780+
'commit' => $inspection['commit'] ?? null,
781+
'dirty' => (int) ( $inspection['dirty'] ?? 0 ),
782+
'primary_freshness' => ! $parsed['is_worktree'] && is_string($inspection['branch_status'] ?? null)
783+
? $this->build_primary_freshness_report_from_status_output((string) $inspection['branch_status'], $parsed['dir_name'])
784+
: null,
768785
);
769786
if ( null !== $context_policy ) {
770787
$result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
/**
3+
* Bounded, read-only inspection of one workspace target.
4+
*
5+
* @package DataMachineCode\Workspace
6+
*/
7+
8+
namespace DataMachineCode\Workspace;
9+
10+
use DataMachineCode\Support\CommandSpec;
11+
use DataMachineCode\Support\ProcessRunner;
12+
13+
defined('ABSPATH') || exit;
14+
15+
final class WorkspaceTargetInspector {
16+
17+
private const DEFAULT_TIMEOUT_SECONDS = 5;
18+
19+
/**
20+
* Inspect exactly one path in a killable child process.
21+
*
22+
* @return array<string,mixed>|\WP_Error
23+
*/
24+
public static function inspect( string $path, string $handle ): array|\WP_Error {
25+
$timeout = function_exists('apply_filters')
26+
? (int) apply_filters('datamachine_code_workspace_target_lookup_timeout_seconds', self::DEFAULT_TIMEOUT_SECONDS, $handle)
27+
: self::DEFAULT_TIMEOUT_SECONDS;
28+
$timeout = max(1, $timeout);
29+
30+
$diagnostic = array(
31+
'phase' => 'filesystem',
32+
'resource' => $path,
33+
'probe_owner' => 'workspace-show',
34+
'timeout_seconds' => $timeout,
35+
);
36+
if ( function_exists('do_action') ) {
37+
do_action('datamachine_code_workspace_lookup_waiting', $diagnostic);
38+
}
39+
40+
$probe_path = __DIR__ . '/workspace-target-probe.php';
41+
$filesystem_probe = '';
42+
$git_command = 'git';
43+
if ( function_exists('apply_filters') ) {
44+
$filesystem_probe = (string) apply_filters('datamachine_code_workspace_target_filesystem_probe_command', '', $handle, $path);
45+
$git_command = (string) apply_filters('datamachine_code_workspace_target_git_command', 'git', $handle, $path);
46+
}
47+
$command = CommandSpec::from_argv(array( PHP_BINARY, $probe_path, $path, $filesystem_probe, $git_command ));
48+
if ( is_wp_error($command) ) {
49+
return $command;
50+
}
51+
52+
$result = ProcessRunner::run(
53+
$command,
54+
array(
55+
'timeout_seconds' => $timeout,
56+
'separate_streams' => true,
57+
'output_cap_bytes' => 32768,
58+
'error_code' => 'workspace_target_lookup_failed',
59+
)
60+
);
61+
if ( is_wp_error($result) ) {
62+
$data = is_array($result->get_error_data()) ? $result->get_error_data() : array();
63+
$stderr = (string) ( $data['stderr'] ?? '' );
64+
if ( preg_match_all('/^DMC_BOUNDARY:([a-z_]+):([^\r\n]+)/m', $stderr, $matches) && ! empty($matches[1]) ) {
65+
$diagnostic['phase'] = (string) end($matches[1]);
66+
$diagnostic['operation'] = (string) end($matches[2]);
67+
}
68+
69+
if ( isset($data['timeout']) ) {
70+
return new \WP_Error(
71+
'workspace_target_lookup_timeout',
72+
sprintf('Workspace lookup for "%s" timed out during the %s probe after %d second(s).', $handle, $diagnostic['phase'], $timeout),
73+
array_merge($data, $diagnostic, array( 'status' => 504 ))
74+
);
75+
}
76+
77+
return new \WP_Error(
78+
'workspace_target_lookup_failed',
79+
sprintf('Workspace lookup for "%s" failed during the %s probe.', $handle, $diagnostic['phase']),
80+
array_merge($data, $diagnostic, array( 'status' => 500 ))
81+
);
82+
}
83+
84+
$decoded = json_decode((string) ( $result['stdout'] ?? '' ), true);
85+
if ( ! is_array($decoded) ) {
86+
return new \WP_Error(
87+
'workspace_target_lookup_invalid_response',
88+
sprintf('Workspace lookup for "%s" returned an invalid probe response.', $handle),
89+
array_merge($diagnostic, array( 'status' => 500 ))
90+
);
91+
}
92+
93+
return $decoded;
94+
}
95+
}

0 commit comments

Comments
 (0)