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
129 changes: 129 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,20 @@ private function registerAbilities(): void {
)
);

AbilityRegistry::register(
'datamachine-code/run-runner-workspace-command',
array(
'label' => 'Run Runner Workspace Command',
'description' => 'Run a bounded verification or drift command against a runner-owned workspace handle without exposing DMC local paths to callers.',
'category' => 'datamachine-code-workspace',
'input_schema' => self::runnerWorkspaceCommandInputSchema(),
'output_schema' => self::runnerWorkspaceCommandOutputSchema(),
'execute_callback' => array( self::class, 'runRunnerWorkspaceCommand' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => false ),
)
);

AbilityRegistry::register(
'datamachine-code/workspace-git-rebase',
array(
Expand Down Expand Up @@ -3049,6 +3063,121 @@ public static function publishRunnerWorkspace( array $input ): array|\WP_Error {
return ( new RunnerWorkspacePublisher() )->publish($input);
}

/**
* Run a bounded command against a runner-owned workspace.
*
* @param array<string,mixed> $input Command input.
* @return array<string,mixed>|\WP_Error
*/
public static function runRunnerWorkspaceCommand( array $input ): array|\WP_Error {
$handle = trim( (string) ( $input['workspace_handle'] ?? $input['name'] ?? $input['repo'] ?? '' ) );
$command = trim( (string) ( $input['command'] ?? '' ) );
$timeout = isset($input['timeout']) ? (int) $input['timeout'] : (int) ( $input['timeout_seconds'] ?? 300 );
$env = isset($input['env']) && is_array($input['env']) ? $input['env'] : array();

if ( RemoteWorkspaceBackend::should_handle() ) {
$result = ( new RemoteWorkspaceBackend() )->run_command(
$handle,
$command,
(string) ( $input['description'] ?? '' ),
$timeout,
$env,
isset($input['cwd']) ? (string) $input['cwd'] : null
);
return self::decorate_remote_workspace_result('run_runner_workspace_command', $result);
}

$workspace = new Workspace();
return $workspace->run_runner_workspace_command(
$handle,
$command,
(string) ( $input['description'] ?? '' ),
$timeout,
$env,
isset($input['cwd']) ? (string) $input['cwd'] : null
);
}

/**
* @return array<string,mixed>
*/
private static function runnerWorkspaceCommandInputSchema(): array {
return array(
'type' => 'object',
'required' => array( 'workspace_handle', 'command' ),
'properties' => array(
'workspace_handle' => array(
'type' => 'string',
'description' => 'Workspace handle: <repo>, <repo>@<branch-slug>, or a runner-provided 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 for the command.',
'additionalProperties' => array( 'type' => 'string' ),
),
'context' => array(
'type' => 'object',
'description' => 'Optional caller context carried for observability.',
),
),
);
}

/**
* @return array<string,mixed>
*/
private static function runnerWorkspaceCommandOutputSchema(): array {
return array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'kind' => array( 'type' => 'string' ),
'backend' => array( 'type' => 'string' ),
'failure_type' => array( 'type' => array( 'string', 'null' ) ),
'name' => array( 'type' => 'string' ),
'repo' => array( 'type' => 'string' ),
'path' => array( 'type' => array( 'string', 'null' ) ),
'command' => array( 'type' => 'string' ),
'description' => array( 'type' => 'string' ),
'exit_code' => array( 'type' => array( 'integer', 'null' ) ),
'stdout' => array( 'type' => 'string' ),
'stderr' => array( 'type' => 'string' ),
'elapsed_ms' => array( 'type' => 'integer' ),
'timed_out' => array( 'type' => 'boolean' ),
'workspace' => array( 'type' => 'object' ),
'message' => array( 'type' => 'string' ),
),
);
}

/**
* @return array<string,mixed>
*/
Expand Down
86 changes: 65 additions & 21 deletions inc/Support/ProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ final class ProcessRunner {
*
* @param string $command Shell command to execute.
* @param array<string,mixed> $options Execution options.
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
* @return array<string,mixed>|\WP_Error
*/
public static function run( string $command, array $options = array() ): array|\WP_Error {
$timeout_seconds = max(0, (int) ( $options['timeout_seconds'] ?? 0 ));
Expand All @@ -53,7 +53,7 @@ public static function run( string $command, array $options = array() ): array|\

/**
* @param array<string,mixed> $options
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
* @return array<string,mixed>|\WP_Error
*/
private static function run_via_exec( string $command, array $options, int $output_cap ): array|\WP_Error {
$shell = RuntimeCapabilities::shell_diagnostic();
Expand Down Expand Up @@ -87,7 +87,7 @@ private static function run_via_exec( string $command, array $options, int $outp
* @param array<string,mixed> $options
* @param callable|null $on_output
* @param array<string,mixed>|null $env
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
* @return array<string,mixed>|\WP_Error
*/
private static function run_via_proc_open( string $command, array $options, int $timeout_seconds, int $output_cap, ?callable $on_output, ?string $cwd, ?array $env ): array|\WP_Error {
$descriptor_spec = array(
Expand All @@ -104,13 +104,20 @@ private static function run_via_proc_open( string $command, array $options, int
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);

$output = '';
$deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null;
$exit_code = null;
$separate_streams = ! empty($options['separate_streams']);
$stdout = '';
$stderr = '';
$output = '';
$deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null;
$exit_code = 0;

while ( true ) {
$chunk = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
$stdout_chunk = (string) stream_get_contents($pipes[1]);
$stderr_chunk = (string) stream_get_contents($pipes[2]);
$chunk = $stdout_chunk . $stderr_chunk;
if ( '' !== $chunk ) {
$stdout .= $stdout_chunk;
$stderr .= $stderr_chunk;
$output .= $chunk;
if ( null !== $on_output ) {
$on_output($chunk);
Expand All @@ -119,75 +126,103 @@ private static function run_via_proc_open( string $command, array $options, int

$status = proc_get_status($process);
if ( empty($status['running']) ) {
$exit_code = isset($status['exitcode']) ? (int) $status['exitcode'] : null;
$exit_code = (int) $status['exitcode'];
break;
}

if ( null !== $deadline && microtime(true) >= $deadline ) {
$output = self::terminate_timed_out_process($process, $pipes, $output);
$remaining = self::terminate_timed_out_process($process, $pipes, $output, $stdout, $stderr);
return self::error(
$options,
sprintf('Process command timed out after %d second(s).', $timeout_seconds),
array(
'timeout' => $timeout_seconds,
'output' => self::cap_output(trim($output), $output_cap),
'output' => self::cap_output(trim($remaining['output']), $output_cap),
'stdout' => self::cap_output(trim($remaining['stdout']), $output_cap),
'stderr' => self::cap_output(trim($remaining['stderr']), $output_cap),
)
);
}

usleep( (int) ( $options['poll_interval_microseconds'] ?? 50000 ) );
}

$output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
$stdout_tail = (string) stream_get_contents($pipes[1]);
$stderr_tail = (string) stream_get_contents($pipes[2]);
$stdout .= $stdout_tail;
$stderr .= $stderr_tail;
$output .= $stdout_tail . $stderr_tail;
foreach ( $pipes as $pipe ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
fclose($pipe);
}

$close_code = proc_close($process);
if ( null === $exit_code ) {
if ( -1 === $exit_code ) {
$exit_code = $close_code;
}

$output = self::cap_output(trim(str_replace("\r", "\n", $output)), $output_cap);
$stdout = self::cap_output(trim(str_replace("\r", "\n", $stdout)), $output_cap);
$stderr = self::cap_output(trim(str_replace("\r", "\n", $stderr)), $output_cap);
if ( 0 !== $exit_code ) {
$data = array(
'exit_code' => $exit_code,
'output' => $output,
);
if ( $separate_streams ) {
$data['stdout'] = $stdout;
$data['stderr'] = $stderr;
}

return self::error(
$options,
sprintf('Process command failed (exit %d): %s', $exit_code, $output),
array(
'exit_code' => $exit_code,
'output' => $output,
)
$data
);
}

return array(
$result = array(
'success' => true,
'output' => $output,
'exit_code' => 0,
);
if ( $separate_streams ) {
$result['stdout'] = $stdout;
$result['stderr'] = $stderr;
}

return $result;
}

/**
* @param resource $process
* @param array<int,resource> $pipes
*/
private static function terminate_timed_out_process( $process, array $pipes, string $output ): string {
private static function terminate_timed_out_process( $process, array $pipes, string $output, string $stdout = '', string $stderr = '' ): array {
proc_terminate($process);
usleep(100000);
$status = proc_get_status($process);
if ( ! empty($status['running']) ) {
proc_terminate($process, 9);
}

$output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
$stdout_tail = (string) stream_get_contents($pipes[1]);
$stderr_tail = (string) stream_get_contents($pipes[2]);
$stdout .= $stdout_tail;
$stderr .= $stderr_tail;
$output .= $stdout_tail . $stderr_tail;
foreach ( $pipes as $pipe ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
fclose($pipe);
}
proc_close($process);

return $output;
return array(
'output' => $output,
'stdout' => $stdout,
'stderr' => $stderr,
);
}

private static function cap_output( string $output, int $output_cap ): string {
Expand All @@ -201,14 +236,23 @@ private static function cap_output( string $output, int $output_cap ): string {
/**
* @param array<string,mixed> $options
* @param array<string,mixed> $data
* @return array<string,mixed>|\WP_Error
*/
private static function error( array $options, string $message, array $data = array() ): array|\WP_Error {
if ( ! empty($options['error_as_result']) ) {
return array(
$result = array(
'success' => false,
'output' => (string) ( $data['output'] ?? $message ),
'exit_code' => (int) ( $data['exit_code'] ?? 1 ),
);
if ( array_key_exists('stdout', $data) ) {
$result['stdout'] = (string) $data['stdout'];
}
if ( array_key_exists('stderr', $data) ) {
$result['stderr'] = (string) $data['stderr'];
}

return $result;
}

$code = isset($options['error_code']) && is_string($options['error_code']) && '' !== $options['error_code'] ? $options['error_code'] : 'process_command_failed';
Expand Down
1 change: 1 addition & 0 deletions inc/Tools/AbilityToolProjections.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static function projected_tools(): array {
'workspace_git_add' => self::workspace_write('datamachine-code/workspace-git-add'),
'workspace_git_commit' => self::workspace_write('datamachine-code/workspace-git-commit'),
'workspace_git_push' => self::workspace_write('datamachine-code/workspace-git-push'),
'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_add' => self::workspace_write('datamachine-code/workspace-worktree-add'),
Expand Down
Loading
Loading