PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / agents / jobs.php

jobs.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/agents/jobs.php

329 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Agents: durable async invocations and owner-only status reads.
4 *
5 * Non-autoloaded options survive cache eviction. WP-Cron and the optional
6 * post-response FPM worker compete for an atomic, permanent execution claim:
7 * a terminated worker must never replay abilities that may have written data.
8 *
9 * @package OpenStation
10 */
11
12 defined( 'ABSPATH' ) || exit;
13
14 /** Maximum elapsed time before an abandoned job is reported as interrupted. */
15 const OPENSTATION_AGENT_JOB_LIFETIME = 2 * HOUR_IN_SECONDS;
16
17 /**
18 * Register the lightweight status route.
19 *
20 * @access private
21 */
22 function openstation_agents_register_job_routes() {
23 if ( ! openstation_agents_enabled() ) {
24 return;
25 }
26 register_rest_route(
27 'desktop-mode/v1',
28 '/agents/(?P<id>\d+)/jobs/(?P<job>[a-f0-9-]{36})',
29 array(
30 'methods' => WP_REST_Server::READABLE,
31 'permission_callback' => 'openstation_agents_rest_invoke_permission',
32 'callback' => 'openstation_agents_rest_job',
33 )
34 );
35 }
36 add_action( 'rest_api_init', 'openstation_agents_register_job_routes' );
37
38 /**
39 * Read a job, returning null for missing or malformed identifiers.
40 *
41 * @access private
42 * @param string $id Job UUID.
43 * @return array|null
44 */
45 function openstation_agent_job_get( $id ) {
46 if ( ! is_string( $id ) || ! wp_is_uuid( $id ) ) {
47 return null;
48 }
49 $job = get_option( 'openstation_agent_job_' . $id );
50 return is_array( $job ) ? $job : null;
51 }
52
53 /**
54 * Insert without overwriting a concurrent winner (add_option uses an upsert).
55 *
56 * @access private
57 * @param string $key Option name.
58 * @param mixed $value Option value.
59 * @return bool Whether this request inserted the row.
60 */
61 function openstation_agent_job_insert( $key, $value ) {
62 global $wpdb;
63 $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $key, maybe_serialize( $value ) ) );
64 wp_cache_delete( $key, 'options' );
65 wp_cache_delete( 'notoptions', 'options' );
66 return 1 === $inserted;
67 }
68
69 /**
70 * Build the public status without exposing input, history or ownership data.
71 *
72 * @access private
73 * @param array $job Stored job.
74 * @return array
75 */
76 function openstation_agent_job_status( array $job ) {
77 $status = $job['status'];
78 $error = isset( $job['error'] ) ? $job['error'] : null;
79 if ( in_array( $status, array( 'queued', 'running' ), true ) && time() >= $job['deadline'] ) {
80 $status = 'failed';
81 $error = array(
82 'code' => 'openstation_agent_job_interrupted',
83 'message' => __( 'The background worker did not finish. Some work may already have been applied. Check the site before submitting the task again.', 'desktop-mode' ),
84 );
85 }
86 return array(
87 'jobId' => $job['id'],
88 'status' => $status,
89 'createdAt' => $job['created'],
90 'pollAfter' => 3,
91 'result' => 'completed' === $status ? $job['result'] : null,
92 'error' => $error,
93 );
94 }
95
96 /**
97 * Return an uncached job snapshot. Polling never starts or advances a worker.
98 *
99 * @access private
100 * @param WP_REST_Request $request REST request.
101 * @return WP_REST_Response|WP_Error
102 */
103 function openstation_agents_rest_job( WP_REST_Request $request ) {
104 $job = openstation_agent_job_get( (string) $request['job'] );
105 if ( ! $job || get_current_user_id() !== $job['owner'] || (int) $request['id'] !== $job['agent'] ) {
106 return new WP_Error( 'openstation_agent_job_not_found', __( 'Agent job not found.', 'desktop-mode' ), array( 'status' => 404 ) );
107 }
108 $response = rest_ensure_response( openstation_agent_job_status( $job ) );
109 $response->header( 'Cache-Control', 'no-store, private' );
110 return $response;
111 }
112
113 /**
114 * Release only this job's admission slot, atomically.
115 *
116 * @access private
117 * @param array $job Stored job.
118 */
119 function openstation_agent_job_release( array $job ) {
120 global $wpdb;
121 $key = 'openstation_agent_job_active_' . $job['owner'] . '_' . $job['agent'];
122 // A compare-and-delete protects a replacement slot from a late worker.
123 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, $job['id'] . '|' . $job['deadline'] ) );
124 wp_cache_delete( $key, 'options' );
125 }
126
127 /**
128 * Queue a validated invocation; a request UUID makes submission retry-safe.
129 *
130 * @access private
131 * @param WP_REST_Request $request REST request, after the per-agent gate.
132 * @return WP_REST_Response|WP_Error
133 */
134 function openstation_agents_rest_enqueue_job( WP_REST_Request $request ) {
135 $id = strtolower( (string) $request['requestId'] );
136 if ( ! wp_is_uuid( $id ) ) {
137 return new WP_Error( 'openstation_agent_job_id_required', __( 'An async invocation requires a request UUID.', 'desktop-mode' ), array( 'status' => 400 ) );
138 }
139 $message = sanitize_textarea_field( (string) $request['message'] );
140 if ( '' === trim( $message ) || strlen( $message ) > 20000 ) {
141 return new WP_Error( 'openstation_agent_job_message', __( 'Send a message between 1 and 20,000 bytes.', 'desktop-mode' ), array( 'status' => 400 ) );
142 }
143 $job = array(
144 'id' => $id,
145 'owner' => get_current_user_id(),
146 'agent' => (int) $request['id'],
147 'message' => $message,
148 'source' => (string) $request['source'],
149 'history' => openstation_agent_runner_sanitize_history( (array) $request['history'] ),
150 'status' => 'queued',
151 'created' => time(),
152 'deadline' => time() + OPENSTATION_AGENT_JOB_LIFETIME,
153 );
154 $existing = openstation_agent_job_get( $id );
155 if ( $existing ) {
156 foreach ( array( 'owner', 'agent', 'message', 'source', 'history' ) as $field ) {
157 if ( $existing[ $field ] !== $job[ $field ] ) {
158 return new WP_Error( 'openstation_agent_job_conflict', __( 'That request ID is already in use.', 'desktop-mode' ), array( 'status' => 409 ) );
159 }
160 }
161 return new WP_REST_Response( openstation_agent_job_status( $existing ), 202, array( 'Cache-Control' => 'no-store, private' ) );
162 }
163 if ( ! openstation_agent_runner_available() ) {
164 return new WP_Error( 'openstation_agent_ai_unavailable', __( 'Configure an AI connector to run agents.', 'desktop-mode' ), array( 'status' => 503 ) );
165 }
166
167 // Bound the queue: at most one outstanding request per human and agent.
168 $slot = 'openstation_agent_job_active_' . $job['owner'] . '_' . $job['agent'];
169 $slot_value = explode( '|', (string) get_option( $slot, '' ) );
170 $previous = openstation_agent_job_get( $slot_value[0] );
171 if ( ! $previous && isset( $slot_value[1] ) && time() >= (int) $slot_value[1] ) {
172 openstation_agent_job_release(
173 array_merge(
174 $job,
175 array(
176 'id' => $slot_value[0],
177 'deadline' => (int) $slot_value[1],
178 )
179 )
180 );
181 }
182 if ( $previous && ( time() >= $previous['deadline'] || in_array( $previous['status'], array( 'completed', 'failed' ), true ) ) ) {
183 openstation_agent_job_release( $previous );
184 }
185 if ( ! openstation_agent_job_insert( $slot, $id . '|' . $job['deadline'] ) ) {
186 return new WP_Error( 'openstation_agent_job_busy', __( 'This agent is still handling your previous request. Wait for its answer before sending another.', 'desktop-mode' ), array( 'status' => 409 ) );
187 }
188 if ( ! openstation_agent_job_insert( 'openstation_agent_job_' . $id, $job ) ) {
189 openstation_agent_job_release( $job );
190 return new WP_Error( 'openstation_agent_job_conflict', __( 'The request could not be saved. Retry with the same request ID.', 'desktop-mode' ), array( 'status' => 409 ) );
191 }
192
193 $scheduled = wp_schedule_single_event( time(), 'openstation_agent_job_run', array( $id ), true );
194 $cleanup = wp_schedule_single_event( time() + DAY_IN_SECONDS, 'openstation_agent_job_cleanup', array( $id ), true );
195 if ( is_wp_error( $scheduled ) || ! $scheduled || is_wp_error( $cleanup ) || ! $cleanup ) {
196 wp_clear_scheduled_hook( 'openstation_agent_job_run', array( $id ) );
197 wp_clear_scheduled_hook( 'openstation_agent_job_cleanup', array( $id ) );
198 openstation_agent_job_release( $job );
199 delete_option( 'openstation_agent_job_' . $id );
200 return new WP_Error( 'openstation_agent_job_schedule', __( 'WordPress could not schedule the background job.', 'desktop-mode' ), array( 'status' => 503 ) );
201 } else {
202 openstation_agent_job_wake( $id );
203 }
204 return new WP_REST_Response( openstation_agent_job_status( openstation_agent_job_get( $id ) ), 202, array( 'Cache-Control' => 'no-store, private' ) );
205 }
206
207 /**
208 * Wake cron; use an FPM-only post-response fallback for blocked loopbacks.
209 *
210 * @access private
211 * @param string $id Job UUID.
212 */
213 function openstation_agent_job_wake( $id ) {
214 // ALTERNATE_WP_CRON can include wp-cron.php in the current GET request.
215 if ( ! ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) && ! ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) ) {
216 spawn_cron();
217 }
218 if ( function_exists( 'fastcgi_finish_request' ) ) {
219 add_action(
220 'shutdown',
221 static function () use ( $id ) {
222 ignore_user_abort( true );
223 if ( fastcgi_finish_request() ) {
224 openstation_agent_job_run( $id );
225 }
226 },
227 PHP_INT_MAX
228 );
229 }
230 }
231
232 /**
233 * Persist a terminal result, keeping the execution claim until retention ends.
234 *
235 * @access private
236 * @param array $job Stored job.
237 * @param array|WP_Error $result Runner outcome.
238 */
239 function openstation_agent_job_finish( array $job, $result ) {
240 $job['status'] = is_wp_error( $result ) ? 'failed' : 'completed';
241 $job['result'] = is_wp_error( $result ) ? null : $result;
242 $job['error'] = is_wp_error( $result ) ? array(
243 'code' => $result->get_error_code(),
244 'message' => $result->get_error_message(),
245 ) : null;
246 update_option( 'openstation_agent_job_' . $job['id'], $job, false );
247 openstation_agent_job_release( $job );
248 /**
249 * Fires after an async job stores its terminal outcome.
250 *
251 * @param string $id Job UUID.
252 * @param int $owner Requesting human user id.
253 * @param array $status Public job status, including result or error.
254 */
255 do_action( 'openstation_agent_job_finished', $job['id'], $job['owner'], openstation_agent_job_status( $job ) );
256 }
257
258 /**
259 * Execute once, restoring the human identity before rechecking permissions.
260 *
261 * @access private
262 * @param string $id Job UUID.
263 */
264 function openstation_agent_job_run( $id ) {
265 $job = openstation_agent_job_get( $id );
266 if ( ! $job || 'queued' !== $job['status'] || ! openstation_agent_job_insert( 'openstation_agent_job_claim_' . $id, time() ) ) {
267 return;
268 }
269 if ( time() >= $job['deadline'] ) {
270 openstation_agent_job_finish( $job, new WP_Error( 'openstation_agent_job_expired', __( 'The queued job expired before a worker could start it.', 'desktop-mode' ) ) );
271 return;
272 }
273 $job['status'] = 'running';
274 update_option( 'openstation_agent_job_' . $id, $job, false );
275 $finished = false;
276 register_shutdown_function(
277 static function () use ( &$finished, $job ) {
278 if ( ! $finished ) {
279 openstation_agent_job_finish( $job, new WP_Error( 'openstation_agent_job_interrupted', __( 'The background worker stopped unexpectedly. Some work may already have been applied. Check the site before submitting again.', 'desktop-mode' ) ) );
280 }
281 }
282 );
283 ignore_user_abort( true );
284 if ( function_exists( 'set_time_limit' ) ) {
285 set_time_limit( 0 );
286 }
287 $previous = get_current_user_id();
288 try {
289 wp_set_current_user( $job['owner'] );
290 if ( ! get_userdata( $job['owner'] ) || ! openstation_agents_enabled() || ! openstation_agents_user_can_invoke() || ! openstation_agent_user_can_invoke_agent( $job['agent'], $job['source'] ) ) {
291 $result = new WP_Error( 'openstation_agents_forbidden', __( 'You no longer have permission to run this agent.', 'desktop-mode' ) );
292 } else {
293 $result = openstation_agent_invoke(
294 $job['agent'],
295 $job['message'],
296 array(
297 'source' => $job['source'],
298 'invoker' => $job['owner'],
299 'history' => $job['history'],
300 )
301 );
302 }
303 } catch ( Throwable $error ) {
304 $result = new WP_Error( 'openstation_agent_job_failed', __( 'The background agent encountered an unexpected error. Check the site before submitting again.', 'desktop-mode' ) );
305 } finally {
306 wp_set_current_user( $previous );
307 }
308 openstation_agent_job_finish( $job, $result );
309 $finished = true;
310 }
311 add_action( 'openstation_agent_job_run', 'openstation_agent_job_run' );
312
313 /**
314 * Remove input, result, claim and admission slot after one day.
315 *
316 * @access private
317 * @param string $id Job UUID.
318 */
319 function openstation_agent_job_cleanup( $id ) {
320 $job = openstation_agent_job_get( $id );
321 if ( $job ) {
322 openstation_agent_job_release( $job );
323 delete_option( 'openstation_agent_job_' . $id );
324 delete_option( 'openstation_agent_job_claim_' . $id );
325 wp_clear_scheduled_hook( 'openstation_agent_job_run', array( $id ) );
326 }
327 }
328 add_action( 'openstation_agent_job_cleanup', 'openstation_agent_job_cleanup' );
329