Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions data-machine-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@
define( 'DATAMACHINE_CODE_PATH', plugin_dir_path( __FILE__ ) );
define( 'DATAMACHINE_CODE_URL', plugin_dir_url( __FILE__ ) );

/**
* Classify DMC CLI requests which must not initialize runtime services.
*
* WP-CLI loads WordPress before it dispatches nested help, so command methods
* cannot protect help from plugin bootstrap side effects. Inspect only DMC's
* own argv namespace here and leave every unrelated WP-CLI request unchanged.
*
* @param array<int,mixed>|null $argv Raw process arguments.
*/
function datamachine_code_is_side_effect_free_cli_request( ?array $argv = null ): bool {
if ( ! defined('WP_CLI') || ! WP_CLI ) {
return false;
}

$tokens = array_values(array_map('strval', $argv ?? ( is_array($GLOBALS['argv'] ?? null) ? $GLOBALS['argv'] : array() )));
if ( ! in_array('datamachine-code', $tokens, true) ) {
return false;
}

if ( in_array('--help', $tokens, true) || in_array('-h', $tokens, true) ) {
return true;
}

$help_index = array_search('help', $tokens, true);
$dmc_index = array_search('datamachine-code', $tokens, true);
return false !== $help_index && false !== $dmc_index && $help_index < $dmc_index;
}

/**
* Whether this request is a targeted, read-only workspace command.
*/
function datamachine_code_is_targeted_workspace_read_cli_request( ?array $argv = null ): bool {
if ( ! defined('WP_CLI') || ! WP_CLI ) {
return false;
}

$tokens = array_values(array_map('strval', $argv ?? ( is_array($GLOBALS['argv'] ?? null) ? $GLOBALS['argv'] : array() )));
$workspace_index = array_search('workspace', $tokens, true);
return false !== $workspace_index && 'show' === ( $tokens[ $workspace_index + 1 ] ?? '' ) && in_array('datamachine-code', array_slice($tokens, 0, $workspace_index), true);
}

// PSR-4 Autoloading.
require_once __DIR__ . '/vendor/autoload.php';

Expand All @@ -41,7 +82,7 @@ function datamachine_code_install_schema(): void {
* Keep schema current for already-active installs after deploy/update.
*/
function datamachine_code_maybe_upgrade_schema(): void {
if ( function_exists('wp_installing') && wp_installing() ) {
if ( datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() || ( function_exists('wp_installing') && wp_installing() ) ) {
return;
}

Expand Down Expand Up @@ -75,7 +116,7 @@ function datamachine_code_has_datamachine_integration(): bool {
function datamachine_code_register_bundle_artifacts(): void {
static $registered = false;

if ( $registered ) {
if ( $registered || datamachine_code_is_side_effect_free_cli_request() ) {
return;
}

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

if ( $registered || ! datamachine_code_has_datamachine_integration() ) {
if ( $registered || datamachine_code_is_side_effect_free_cli_request() || ! datamachine_code_has_datamachine_integration() ) {
return;
}

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

if ( $bootstrapped ) {
if ( $bootstrapped || datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() ) {
return;
}

Expand Down Expand Up @@ -321,7 +362,7 @@ function datamachine_code_register_cli_commands() {
* Only load when Data Machine core's AI engine is available.
*/
function datamachine_code_load_chat_tools() {
if ( ! class_exists('DataMachine\Engine\AI\Tools\BaseTool') ) {
if ( datamachine_code_is_side_effect_free_cli_request() || datamachine_code_is_targeted_workspace_read_cli_request() || ! class_exists('DataMachine\Engine\AI\Tools\BaseTool') ) {
return;
}

Expand Down
15 changes: 8 additions & 7 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2888,21 +2888,22 @@ public static function getCapabilities( array $input ): array { // phpcs:ignor
*/
public static function showRepo( array $input ): array|\WP_Error {
$workspace = new Workspace();
if ( RemoteWorkspaceBackend::should_handle() ) {
$local_result = self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['name'] ?? '' ));
if ( null !== $local_result ) {
return $local_result;
}
$handle = (string) ( $input['name'] ?? '' );
$local = $workspace->show_repo($handle);
if ( ! is_wp_error($local) && empty($local['is_context']) ) {
return $local;
}

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

return $workspace->show_repo($input['name'] ?? '');
return $local;
}

/**
Expand Down
11 changes: 4 additions & 7 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

use WP_CLI;
use DataMachine\Cli\BaseCommand;
use DataMachineCode\Abilities\WorkspaceAbilities;
use DataMachineCode\Cli\CliResponseRenderer;
use DataMachineCode\Cli\CliRepeatableOptionParser;
use DataMachineCode\Cli\WorkspaceCompactOutput;
Expand Down Expand Up @@ -2510,13 +2511,9 @@ public function show( array $args, array $assoc_args ): void {
return;
}

$ability = wp_get_ability('datamachine-code/workspace-show');
if ( ! $ability ) {
WP_CLI::error('Workspace show ability not available.');
return;
}

$result = $ability->execute(array( 'name' => $args[0] ));
// This targeted read is also the lightweight startup path used before the
// Abilities API runtime has been bootstrapped.
$result = WorkspaceAbilities::showRepo(array( 'name' => $args[0] ));

if ( is_wp_error($result) ) {
WP_CLI::error($result->get_error_message());
Expand Down
12 changes: 11 additions & 1 deletion inc/Workspace/WorkspaceCoreUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,17 @@ private function build_primary_freshness_report( string $repo_path, string $hand
);
}

$header = strtok( (string) ( $status_result['output'] ?? '' ), "\n");
return $this->build_primary_freshness_report_from_status_output((string) ( $status_result['output'] ?? '' ), $handle);
}

/**
* Build primary freshness metadata from an already-bounded local status probe.
*
* @return array<string,mixed>
*/
private function build_primary_freshness_report_from_status_output( string $status_output, string $handle ): array {

$header = strtok($status_output, "\n");
$header = false === $header ? '' : trim($header);
$branch = null;
$detached = false;
Expand Down
57 changes: 37 additions & 20 deletions inc/Workspace/WorkspaceRepositoryLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -712,13 +712,28 @@ public function remove_repo( string $handle ): array|\WP_Error {
* @return array{success: bool, name?: string, path?: string, branch?: string, remote?: string, commit?: string, dirty?: int}|\WP_Error
*/
public function show_repo( string $handle ): array|\WP_Error {
$context_policy = WorkspaceAliasResolver::context_policy_for($handle);
$requested_handle = $handle;
$context_policy = null;
$parsed = $this->parse_handle($handle);
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
$inspection = WorkspaceTargetInspector::inspect($repo_path, $parsed['dir_name']);
if ( is_wp_error($inspection) ) {
return $inspection;
}

if ( empty($inspection['exists']) ) {
$context_policy = WorkspaceAliasResolver::context_policy_for($handle);
}
if ( null !== $context_policy ) {
$target = (string) ( $context_policy['target'] ?? $handle );
$parsed = $this->parse_handle($target);
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
$ref = (string) ( $context_policy['ref'] ?? '' );
if ( ! is_dir($repo_path) ) {
$inspection = WorkspaceTargetInspector::inspect($repo_path, $handle);
if ( is_wp_error($inspection) ) {
return $inspection;
}
if ( empty($inspection['exists']) ) {
return array(
'success' => true,
'name' => (string) $context_policy['alias'],
Expand All @@ -736,35 +751,37 @@ public function show_repo( string $handle ): array|\WP_Error {
$handle = $target;
}

$resolved_handle = $this->resolve_primary_repo_name($handle);
if ( ! is_wp_error($resolved_handle) ) {
$handle = $resolved_handle;
if ( empty($inspection['exists']) && null === $context_policy ) {
$resolved_handle = $this->resolve_primary_repo_name($handle);
if ( ! is_wp_error($resolved_handle) && $resolved_handle !== $handle ) {
$handle = $resolved_handle;
$parsed = $this->parse_handle($handle);
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];
$inspection = WorkspaceTargetInspector::inspect($repo_path, $parsed['dir_name']);
if ( is_wp_error($inspection) ) {
return $inspection;
}
}
}

$parsed = $this->parse_handle($handle);
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];

if ( ! is_dir($repo_path) ) {
return new \WP_Error('repo_not_found', sprintf('Workspace handle "%s" not found.', $parsed['dir_name']), array( 'status' => 404 ));
if ( empty($inspection['exists']) ) {
return new \WP_Error('repo_not_found', sprintf('Workspace handle "%s" not found.', $requested_handle), array( 'status' => 404 ));
}

$branch = GitRunner::current_branch($repo_path);
$remote = GitRunner::remote_url($repo_path);
$commit = GitRunner::latest_commit_summary($repo_path);
$status = GitRunner::dirty_count($repo_path);

$result = array(
'success' => true,
'name' => null !== $context_policy ? (string) $context_policy['alias'] : $parsed['dir_name'],
'repo' => $parsed['repo'],
'is_worktree' => $parsed['is_worktree'],
'is_context' => null !== $context_policy,
'path' => $repo_path,
'branch' => $branch,
'remote' => $remote,
'commit' => $commit,
'dirty' => $status,
'primary_freshness' => ! $parsed['is_worktree'] ? $this->build_primary_freshness_report($repo_path, $parsed['dir_name']) : null,
'branch' => $inspection['branch'] ?? null,
'remote' => $inspection['remote'] ?? null,
'commit' => $inspection['commit'] ?? null,
'dirty' => (int) ( $inspection['dirty'] ?? 0 ),
'primary_freshness' => ! $parsed['is_worktree'] && is_string($inspection['branch_status'] ?? null)
? $this->build_primary_freshness_report_from_status_output((string) $inspection['branch_status'], $parsed['dir_name'])
: null,
);
if ( null !== $context_policy ) {
$result['workspace_policy'] = WorkspaceAliasResolver::policy_attestation($handle);
Expand Down
95 changes: 95 additions & 0 deletions inc/Workspace/WorkspaceTargetInspector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php
/**
* Bounded, read-only inspection of one workspace target.
*
* @package DataMachineCode\Workspace
*/

namespace DataMachineCode\Workspace;

use DataMachineCode\Support\CommandSpec;
use DataMachineCode\Support\ProcessRunner;

defined('ABSPATH') || exit;

final class WorkspaceTargetInspector {

private const DEFAULT_TIMEOUT_SECONDS = 5;

/**
* Inspect exactly one path in a killable child process.
*
* @return array<string,mixed>|\WP_Error
*/
public static function inspect( string $path, string $handle ): array|\WP_Error {
$timeout = function_exists('apply_filters')
? (int) apply_filters('datamachine_code_workspace_target_lookup_timeout_seconds', self::DEFAULT_TIMEOUT_SECONDS, $handle)
: self::DEFAULT_TIMEOUT_SECONDS;
$timeout = max(1, $timeout);

$diagnostic = array(
'phase' => 'filesystem',
'resource' => $path,
'probe_owner' => 'workspace-show',
'timeout_seconds' => $timeout,
);
if ( function_exists('do_action') ) {
do_action('datamachine_code_workspace_lookup_waiting', $diagnostic);
}

$probe_path = __DIR__ . '/workspace-target-probe.php';
$filesystem_probe = '';
$git_command = 'git';
if ( function_exists('apply_filters') ) {
$filesystem_probe = (string) apply_filters('datamachine_code_workspace_target_filesystem_probe_command', '', $handle, $path);
$git_command = (string) apply_filters('datamachine_code_workspace_target_git_command', 'git', $handle, $path);
}
$command = CommandSpec::from_argv(array( PHP_BINARY, $probe_path, $path, $filesystem_probe, $git_command ));
if ( is_wp_error($command) ) {
return $command;
}

$result = ProcessRunner::run(
$command,
array(
'timeout_seconds' => $timeout,
'separate_streams' => true,
'output_cap_bytes' => 32768,
'error_code' => 'workspace_target_lookup_failed',
)
);
if ( is_wp_error($result) ) {
$data = is_array($result->get_error_data()) ? $result->get_error_data() : array();
$stderr = (string) ( $data['stderr'] ?? '' );
if ( preg_match_all('/^DMC_BOUNDARY:([a-z_]+):([^\r\n]+)/m', $stderr, $matches) && ! empty($matches[1]) ) {
$diagnostic['phase'] = (string) end($matches[1]);
$diagnostic['operation'] = (string) end($matches[2]);
}

if ( isset($data['timeout']) ) {
return new \WP_Error(
'workspace_target_lookup_timeout',
sprintf('Workspace lookup for "%s" timed out during the %s probe after %d second(s).', $handle, $diagnostic['phase'], $timeout),
array_merge($data, $diagnostic, array( 'status' => 504 ))
);
}

return new \WP_Error(
'workspace_target_lookup_failed',
sprintf('Workspace lookup for "%s" failed during the %s probe.', $handle, $diagnostic['phase']),
array_merge($data, $diagnostic, array( 'status' => 500 ))
);
}

$decoded = json_decode((string) ( $result['stdout'] ?? '' ), true);
if ( ! is_array($decoded) ) {
return new \WP_Error(
'workspace_target_lookup_invalid_response',
sprintf('Workspace lookup for "%s" returned an invalid probe response.', $handle),
array_merge($diagnostic, array( 'status' => 500 ))
);
}

return $decoded;
}
}
Loading