Skip to content

Commit f3d8c5b

Browse files
committed
Add runner workspace command ability
1 parent e061c59 commit f3d8c5b

8 files changed

Lines changed: 612 additions & 15 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: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = '';
107+
$separate_streams = ! empty($options['separate_streams']);
108+
$stdout = '';
109+
$stderr = '';
110+
$output = '';
108111
$deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null;
109112
$exit_code = null;
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);
@@ -124,21 +131,27 @@ private static function run_via_proc_open( string $command, array $options, int
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);
@@ -150,44 +163,66 @@ private static function run_via_proc_open( string $command, array $options, int
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 {
@@ -204,11 +239,19 @@ private static function cap_output( string $output, int $output_cap ): string {
204239
*/
205240
private static function error( array $options, string $message, array $data = array() ): array|\WP_Error {
206241
if ( ! empty($options['error_as_result']) ) {
207-
return array(
242+
$result = array(
208243
'success' => false,
209244
'output' => (string) ( $data['output'] ?? $message ),
210245
'exit_code' => (int) ( $data['exit_code'] ?? 1 ),
211246
);
247+
if ( array_key_exists('stdout', $data) ) {
248+
$result['stdout'] = (string) $data['stdout'];
249+
}
250+
if ( array_key_exists('stderr', $data) ) {
251+
$result['stderr'] = (string) $data['stderr'];
252+
}
253+
254+
return $result;
212255
}
213256

214257
$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'),

inc/Tools/WorkspaceTools.php

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public function check_configuration( $configured, $tool_id )
5858
'workspace_git_add',
5959
'workspace_git_commit',
6060
'workspace_git_push',
61+
'workspace_run_runner_command',
6162
'workspace_git_rebase',
6263
'workspace_git_reset',
6364
'workspace_pr_status',
@@ -99,6 +100,7 @@ public function __construct()
99100
$this->registerTool('workspace_git_commit', array( $this, 'getGitCommitDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-commit' ));
100101
$this->registerTool('workspace_git_push', array( $this, 'getGitPushDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-push' ));
101102
$this->registerTool('workspace_publish_runner', array( $this, 'getPublishRunnerDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/publish-runner-workspace' ));
103+
$this->registerTool('workspace_run_runner_command', array( $this, 'getRunRunnerCommandDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/run-runner-workspace-command' ));
102104
$this->registerTool('workspace_git_rebase', array( $this, 'getGitRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-rebase' ));
103105
$this->registerTool('workspace_git_reset', array( $this, 'getGitResetDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-git-reset' ));
104106
$this->registerTool('workspace_pr_status', array( $this, 'getPrStatusDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-pr-status' ));
@@ -625,6 +627,22 @@ public function handlePublishRunner( array $parameters ): array
625627
return $this->executeAbility('datamachine-code/publish-runner-workspace', 'workspace_publish_runner', $input, array( 'workspace_handle' ));
626628
}
627629

630+
/**
631+
* @param array<string,mixed> $parameters Tool parameters. @return array<string,mixed>
632+
*/
633+
public function handleRunRunnerCommand( array $parameters ): array
634+
{
635+
$input = $parameters;
636+
if (isset($parameters['name']) && ! isset($input['workspace_handle']) ) {
637+
$input['workspace_handle'] = $parameters['name'];
638+
}
639+
if (isset($parameters['repo']) && ! isset($input['workspace_handle']) ) {
640+
$input['workspace_handle'] = $parameters['repo'];
641+
}
642+
643+
return $this->executeAbility('datamachine-code/run-runner-workspace-command', 'workspace_run_runner_command', $input, array( 'workspace_handle', 'command' ));
644+
}
645+
628646
/**
629647
* @param array<string,mixed> $parameters Tool parameters. @return array<string,mixed>
630648
*/
@@ -1531,7 +1549,37 @@ public function getPublishRunnerDefinition(): array
15311549
}
15321550

15331551
/**
1534-
* @return array<string,mixed>
1552+
* @return array<string,mixed>
1553+
*/
1554+
public function getRunRunnerCommandDefinition(): array
1555+
{
1556+
return $this->withRuntime(
1557+
array(
1558+
'class' => __CLASS__,
1559+
'method' => 'handleRunRunnerCommand',
1560+
'description' => 'Run a bounded verification or drift command against a runner-owned workspace handle through the canonical DMC backend API.',
1561+
'parameters' => array(
1562+
'type' => 'object',
1563+
'required' => array( 'workspace_handle', 'command' ),
1564+
'properties' => array(
1565+
'workspace_handle' => array( 'type' => 'string', 'description' => 'Workspace handle: <repo>, <repo>@<branch-slug>, or runner alias.' ),
1566+
'name' => array( 'type' => 'string', 'description' => 'Alias for workspace_handle.' ),
1567+
'repo' => array( 'type' => 'string', 'description' => 'Alias for workspace_handle.' ),
1568+
'command' => array( 'type' => 'string', 'description' => 'Shell command to run inside the workspace.' ),
1569+
'description' => array( 'type' => 'string', 'description' => 'Human-readable reason for the command.' ),
1570+
'timeout' => array( 'type' => 'integer', 'description' => 'Timeout in seconds. Defaults to 300 and is capped at 1800.' ),
1571+
'timeout_seconds' => array( 'type' => 'integer', 'description' => 'Alias for timeout.' ),
1572+
'cwd' => array( 'type' => 'string', 'description' => 'Optional relative working directory inside the workspace.' ),
1573+
'env' => array( 'type' => 'object', 'description' => 'Optional string environment variables.' ),
1574+
'context' => array( 'type' => 'object', 'description' => 'Optional caller context carried for observability.' ),
1575+
),
1576+
),
1577+
), array( 'completion_signal' => 'complete' )
1578+
);
1579+
}
1580+
1581+
/**
1582+
* @return array<string,mixed>
15351583
*/
15361584
public function getGitRebaseDefinition(): array
15371585
{

0 commit comments

Comments
 (0)