Skip to content

Commit d0f25eb

Browse files
authored
Add runner workspace command ability (#629)
* Add runner workspace command ability * Fix runner command lint
1 parent e061c59 commit d0f25eb

8 files changed

Lines changed: 620 additions & 22 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,20 @@ private function registerAbilities(): void {
10641064
)
10651065
);
10661066

1067+
AbilityRegistry::register(
1068+
'datamachine-code/run-runner-workspace-command',
1069+
array(
1070+
'label' => 'Run Runner Workspace Command',
1071+
'description' => 'Run a bounded verification or drift command against a runner-owned workspace handle without exposing DMC local paths to callers.',
1072+
'category' => 'datamachine-code-workspace',
1073+
'input_schema' => self::runnerWorkspaceCommandInputSchema(),
1074+
'output_schema' => self::runnerWorkspaceCommandOutputSchema(),
1075+
'execute_callback' => array( self::class, 'runRunnerWorkspaceCommand' ),
1076+
'permission_callback' => fn() => PermissionHelper::can_manage(),
1077+
'meta' => array( 'show_in_rest' => false ),
1078+
)
1079+
);
1080+
10671081
AbilityRegistry::register(
10681082
'datamachine-code/workspace-git-rebase',
10691083
array(
@@ -3049,6 +3063,121 @@ public static function publishRunnerWorkspace( array $input ): array|\WP_Error {
30493063
return ( new RunnerWorkspacePublisher() )->publish($input);
30503064
}
30513065

3066+
/**
3067+
* Run a bounded command against a runner-owned workspace.
3068+
*
3069+
* @param array<string,mixed> $input Command input.
3070+
* @return array<string,mixed>|\WP_Error
3071+
*/
3072+
public static function runRunnerWorkspaceCommand( array $input ): array|\WP_Error {
3073+
$handle = trim( (string) ( $input['workspace_handle'] ?? $input['name'] ?? $input['repo'] ?? '' ) );
3074+
$command = trim( (string) ( $input['command'] ?? '' ) );
3075+
$timeout = isset($input['timeout']) ? (int) $input['timeout'] : (int) ( $input['timeout_seconds'] ?? 300 );
3076+
$env = isset($input['env']) && is_array($input['env']) ? $input['env'] : array();
3077+
3078+
if ( RemoteWorkspaceBackend::should_handle() ) {
3079+
$result = ( new RemoteWorkspaceBackend() )->run_command(
3080+
$handle,
3081+
$command,
3082+
(string) ( $input['description'] ?? '' ),
3083+
$timeout,
3084+
$env,
3085+
isset($input['cwd']) ? (string) $input['cwd'] : null
3086+
);
3087+
return self::decorate_remote_workspace_result('run_runner_workspace_command', $result);
3088+
}
3089+
3090+
$workspace = new Workspace();
3091+
return $workspace->run_runner_workspace_command(
3092+
$handle,
3093+
$command,
3094+
(string) ( $input['description'] ?? '' ),
3095+
$timeout,
3096+
$env,
3097+
isset($input['cwd']) ? (string) $input['cwd'] : null
3098+
);
3099+
}
3100+
3101+
/**
3102+
* @return array<string,mixed>
3103+
*/
3104+
private static function runnerWorkspaceCommandInputSchema(): array {
3105+
return array(
3106+
'type' => 'object',
3107+
'required' => array( 'workspace_handle', 'command' ),
3108+
'properties' => array(
3109+
'workspace_handle' => array(
3110+
'type' => 'string',
3111+
'description' => 'Workspace handle: <repo>, <repo>@<branch-slug>, or a runner-provided alias.',
3112+
),
3113+
'name' => array(
3114+
'type' => 'string',
3115+
'description' => 'Alias for workspace_handle.',
3116+
),
3117+
'repo' => array(
3118+
'type' => 'string',
3119+
'description' => 'Alias for workspace_handle.',
3120+
),
3121+
'command' => array(
3122+
'type' => 'string',
3123+
'description' => 'Shell command to run inside the workspace.',
3124+
),
3125+
'description' => array(
3126+
'type' => 'string',
3127+
'description' => 'Human-readable reason for the command.',
3128+
),
3129+
'timeout' => array(
3130+
'type' => 'integer',
3131+
'description' => 'Timeout in seconds. Defaults to 300 and is capped at 1800.',
3132+
),
3133+
'timeout_seconds' => array(
3134+
'type' => 'integer',
3135+
'description' => 'Alias for timeout.',
3136+
),
3137+
'cwd' => array(
3138+
'type' => 'string',
3139+
'description' => 'Optional relative working directory inside the workspace.',
3140+
),
3141+
'env' => array(
3142+
'type' => 'object',
3143+
'description' => 'Optional string environment variables for the command.',
3144+
'additionalProperties' => array( 'type' => 'string' ),
3145+
),
3146+
'context' => array(
3147+
'type' => 'object',
3148+
'description' => 'Optional caller context carried for observability.',
3149+
),
3150+
),
3151+
);
3152+
}
3153+
3154+
/**
3155+
* @return array<string,mixed>
3156+
*/
3157+
private static function runnerWorkspaceCommandOutputSchema(): array {
3158+
return array(
3159+
'type' => 'object',
3160+
'properties' => array(
3161+
'success' => array( 'type' => 'boolean' ),
3162+
'kind' => array( 'type' => 'string' ),
3163+
'backend' => array( 'type' => 'string' ),
3164+
'failure_type' => array( 'type' => array( 'string', 'null' ) ),
3165+
'name' => array( 'type' => 'string' ),
3166+
'repo' => array( 'type' => 'string' ),
3167+
'path' => array( 'type' => array( 'string', 'null' ) ),
3168+
'command' => array( 'type' => 'string' ),
3169+
'description' => array( 'type' => 'string' ),
3170+
'exit_code' => array( 'type' => array( 'integer', 'null' ) ),
3171+
'stdout' => array( 'type' => 'string' ),
3172+
'stderr' => array( 'type' => 'string' ),
3173+
'elapsed_ms' => array( 'type' => 'integer' ),
3174+
'timed_out' => array( 'type' => 'boolean' ),
3175+
'workspace' => array( 'type' => 'object' ),
3176+
'message' => array( 'type' => 'string' ),
3177+
),
3178+
);
3179+
}
3180+
30523181
/**
30533182
* @return array<string,mixed>
30543183
*/

inc/Support/ProcessRunner.php

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ final class ProcessRunner {
2626
*
2727
* @param string $command Shell command to execute.
2828
* @param array<string,mixed> $options Execution options.
29-
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
29+
* @return array<string,mixed>|\WP_Error
3030
*/
3131
public static function run( string $command, array $options = array() ): array|\WP_Error {
3232
$timeout_seconds = max(0, (int) ( $options['timeout_seconds'] ?? 0 ));
@@ -53,7 +53,7 @@ public static function run( string $command, array $options = array() ): array|\
5353

5454
/**
5555
* @param array<string,mixed> $options
56-
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
56+
* @return array<string,mixed>|\WP_Error
5757
*/
5858
private static function run_via_exec( string $command, array $options, int $output_cap ): array|\WP_Error {
5959
$shell = RuntimeCapabilities::shell_diagnostic();
@@ -87,7 +87,7 @@ private static function run_via_exec( string $command, array $options, int $outp
8787
* @param array<string,mixed> $options
8888
* @param callable|null $on_output
8989
* @param array<string,mixed>|null $env
90-
* @return array{success: bool, output: string, exit_code: int}|\WP_Error
90+
* @return array<string,mixed>|\WP_Error
9191
*/
9292
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 {
9393
$descriptor_spec = array(
@@ -104,13 +104,20 @@ private static function run_via_proc_open( string $command, array $options, int
104104
stream_set_blocking($pipes[1], false);
105105
stream_set_blocking($pipes[2], false);
106106

107-
$output = '';
108-
$deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null;
109-
$exit_code = null;
107+
$separate_streams = ! empty($options['separate_streams']);
108+
$stdout = '';
109+
$stderr = '';
110+
$output = '';
111+
$deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null;
112+
$exit_code = 0;
110113

111114
while ( true ) {
112-
$chunk = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
115+
$stdout_chunk = (string) stream_get_contents($pipes[1]);
116+
$stderr_chunk = (string) stream_get_contents($pipes[2]);
117+
$chunk = $stdout_chunk . $stderr_chunk;
113118
if ( '' !== $chunk ) {
119+
$stdout .= $stdout_chunk;
120+
$stderr .= $stderr_chunk;
114121
$output .= $chunk;
115122
if ( null !== $on_output ) {
116123
$on_output($chunk);
@@ -119,75 +126,103 @@ private static function run_via_proc_open( string $command, array $options, int
119126

120127
$status = proc_get_status($process);
121128
if ( empty($status['running']) ) {
122-
$exit_code = isset($status['exitcode']) ? (int) $status['exitcode'] : null;
129+
$exit_code = (int) $status['exitcode'];
123130
break;
124131
}
125132

126133
if ( null !== $deadline && microtime(true) >= $deadline ) {
127-
$output = self::terminate_timed_out_process($process, $pipes, $output);
134+
$remaining = self::terminate_timed_out_process($process, $pipes, $output, $stdout, $stderr);
128135
return self::error(
129136
$options,
130137
sprintf('Process command timed out after %d second(s).', $timeout_seconds),
131138
array(
132139
'timeout' => $timeout_seconds,
133-
'output' => self::cap_output(trim($output), $output_cap),
140+
'output' => self::cap_output(trim($remaining['output']), $output_cap),
141+
'stdout' => self::cap_output(trim($remaining['stdout']), $output_cap),
142+
'stderr' => self::cap_output(trim($remaining['stderr']), $output_cap),
134143
)
135144
);
136145
}
137146

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

141-
$output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
150+
$stdout_tail = (string) stream_get_contents($pipes[1]);
151+
$stderr_tail = (string) stream_get_contents($pipes[2]);
152+
$stdout .= $stdout_tail;
153+
$stderr .= $stderr_tail;
154+
$output .= $stdout_tail . $stderr_tail;
142155
foreach ( $pipes as $pipe ) {
143156
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
144157
fclose($pipe);
145158
}
146159

147160
$close_code = proc_close($process);
148-
if ( null === $exit_code ) {
161+
if ( -1 === $exit_code ) {
149162
$exit_code = $close_code;
150163
}
151164

152165
$output = self::cap_output(trim(str_replace("\r", "\n", $output)), $output_cap);
166+
$stdout = self::cap_output(trim(str_replace("\r", "\n", $stdout)), $output_cap);
167+
$stderr = self::cap_output(trim(str_replace("\r", "\n", $stderr)), $output_cap);
153168
if ( 0 !== $exit_code ) {
169+
$data = array(
170+
'exit_code' => $exit_code,
171+
'output' => $output,
172+
);
173+
if ( $separate_streams ) {
174+
$data['stdout'] = $stdout;
175+
$data['stderr'] = $stderr;
176+
}
177+
154178
return self::error(
155179
$options,
156180
sprintf('Process command failed (exit %d): %s', $exit_code, $output),
157-
array(
158-
'exit_code' => $exit_code,
159-
'output' => $output,
160-
)
181+
$data
161182
);
162183
}
163184

164-
return array(
185+
$result = array(
165186
'success' => true,
166187
'output' => $output,
167188
'exit_code' => 0,
168189
);
190+
if ( $separate_streams ) {
191+
$result['stdout'] = $stdout;
192+
$result['stderr'] = $stderr;
193+
}
194+
195+
return $result;
169196
}
170197

171198
/**
172199
* @param resource $process
173200
* @param array<int,resource> $pipes
174201
*/
175-
private static function terminate_timed_out_process( $process, array $pipes, string $output ): string {
202+
private static function terminate_timed_out_process( $process, array $pipes, string $output, string $stdout = '', string $stderr = '' ): array {
176203
proc_terminate($process);
177204
usleep(100000);
178205
$status = proc_get_status($process);
179206
if ( ! empty($status['running']) ) {
180207
proc_terminate($process, 9);
181208
}
182209

183-
$output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
210+
$stdout_tail = (string) stream_get_contents($pipes[1]);
211+
$stderr_tail = (string) stream_get_contents($pipes[2]);
212+
$stdout .= $stdout_tail;
213+
$stderr .= $stderr_tail;
214+
$output .= $stdout_tail . $stderr_tail;
184215
foreach ( $pipes as $pipe ) {
185216
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
186217
fclose($pipe);
187218
}
188219
proc_close($process);
189220

190-
return $output;
221+
return array(
222+
'output' => $output,
223+
'stdout' => $stdout,
224+
'stderr' => $stderr,
225+
);
191226
}
192227

193228
private static function cap_output( string $output, int $output_cap ): string {
@@ -201,14 +236,23 @@ private static function cap_output( string $output, int $output_cap ): string {
201236
/**
202237
* @param array<string,mixed> $options
203238
* @param array<string,mixed> $data
239+
* @return array<string,mixed>|\WP_Error
204240
*/
205241
private static function error( array $options, string $message, array $data = array() ): array|\WP_Error {
206242
if ( ! empty($options['error_as_result']) ) {
207-
return array(
243+
$result = array(
208244
'success' => false,
209245
'output' => (string) ( $data['output'] ?? $message ),
210246
'exit_code' => (int) ( $data['exit_code'] ?? 1 ),
211247
);
248+
if ( array_key_exists('stdout', $data) ) {
249+
$result['stdout'] = (string) $data['stdout'];
250+
}
251+
if ( array_key_exists('stderr', $data) ) {
252+
$result['stderr'] = (string) $data['stderr'];
253+
}
254+
255+
return $result;
212256
}
213257

214258
$code = isset($options['error_code']) && is_string($options['error_code']) && '' !== $options['error_code'] ? $options['error_code'] : 'process_command_failed';

inc/Tools/AbilityToolProjections.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public static function projected_tools(): array {
6262
'workspace_git_add' => self::workspace_write('datamachine-code/workspace-git-add'),
6363
'workspace_git_commit' => self::workspace_write('datamachine-code/workspace-git-commit'),
6464
'workspace_git_push' => self::workspace_write('datamachine-code/workspace-git-push'),
65+
'workspace_run_runner_command' => self::workspace_write('datamachine-code/run-runner-workspace-command'),
6566
'workspace_git_rebase' => self::workspace_write('datamachine-code/workspace-git-rebase'),
6667
'workspace_git_reset' => self::workspace_write('datamachine-code/workspace-git-reset'),
6768
'workspace_worktree_add' => self::workspace_write('datamachine-code/workspace-worktree-add'),

0 commit comments

Comments
 (0)