Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Leaf↔Root Link — v0.8.0 MVP | |
| * | |
| * Two halves in one class, ship together in every install: | |
| * | |
| * ROOT half — REST endpoint /sa-orch/v1/root/assignments | |
| * Returns Fluent Boards tasks where the authenticated user is | |
| * an assignee (via fbs_relations object_type='task_assignee'). | |
| * Auth: WP application password (Basic auth) or session nonce. | |
| * | |
| * LEAF half — Admin page (SA-Orchestration → Remote Roots) | |
| * Stores ONE remote root config in wp_options (URL + app | |
| * password). Manual "Fetch Now" button calls the configured | |
| * root's /assignments endpoint and renders results in a | |
| * table on the same admin page. | |
| * | |
| * MVP scope (locked): | |
| * - One root per leaf (multi-root deferred to post-MVP) | |
| * - No new tables — single wp_option for config | |
| * - Manual fetch only; no cadence, heartbeat, or cron | |
| * - No Fluent Support ticket creation; just list assignments on screen | |
| * - No outbound curated updates; bidirectional sync is post-MVP | |
| * | |
| * The closed loop: developer self-assigns on Fluent Boards on the dev root | |
| * → leaf admin clicks "Fetch Now" → assignment appears in the leaf's | |
| * Remote Roots admin page. | |
| * | |
| * Doctrine: leaf-initiated, always up; the dev root never reaches into | |
| * the leaf. Per Board #40 invariants. | |
| */ | |
| class SA_Orch_Leaf_Link { | |
| const SUBMENU_SLUG = 'sa-orchestration-remote-roots'; | |
| const OPTION_KEY = 'sa_orch_remote_root'; // single-root MVP storage | |
| const REST_TIMEOUT = 15; | |
| /* =================================================================== | |
| * Hooks | |
| * =================================================================== */ | |
| public static function register_routes() { | |
| // ROOT half — serves assignments to authenticated callers. | |
| register_rest_route( 'sa-orch/v1', '/root/assignments', [ | |
| 'methods' => 'GET', | |
| 'callback' => [ __CLASS__, 'rest_assignments' ], | |
| 'permission_callback' => function () { | |
| return current_user_can( 'read' ); // any logged-in user; the | |
| // join filters by their id | |
| }, | |
| 'args' => [ | |
| 'since' => [ 'type' => 'string', 'required' => false ], | |
| ], | |
| ] ); | |
| // LEAF half — manual fetch trigger (callable by admin from the page). | |
| register_rest_route( 'sa-orch/v1', '/leaf/fetch-now', [ | |
| 'methods' => 'POST', | |
| 'callback' => [ __CLASS__, 'rest_fetch_now' ], | |
| 'permission_callback' => function () { | |
| return current_user_can( 'manage_options' ); | |
| }, | |
| ] ); | |
| } | |
| public static function register_menu() { | |
| add_submenu_page( | |
| SA_Orch_Admin::PARENT_SLUG, | |
| 'Remote Roots', | |
| 'Remote Roots', | |
| SA_Orch_Admin::CAP, | |
| self::SUBMENU_SLUG, | |
| [ __CLASS__, 'render_page' ] | |
| ); | |
| } | |
| /* =================================================================== | |
| * ROOT HALF — assignments endpoint | |
| * =================================================================== */ | |
| public static function rest_assignments( WP_REST_Request $req ) { | |
| global $wpdb; | |
| $user_id = get_current_user_id(); | |
| if ( $user_id <= 0 ) { | |
| return new WP_REST_Response( [ | |
| 'error' => 'unauthenticated', | |
| 'message' => 'No authenticated user resolved.', | |
| ], 401 ); | |
| } | |
| // Filter by 'since' (last_modified > since); optional. | |
| $since = $req->get_param( 'since' ); | |
| $where_since = ''; | |
| $params = [ $user_id ]; | |
| if ( $since && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$/', (string) $since ) ) { | |
| $where_since = " AND t.updated_at > %s"; | |
| $params[] = str_replace( 'T', ' ', rtrim( (string) $since, 'Z' ) ); | |
| } | |
| // Fluent Boards assignment join: fbs_relations.object_type = 'task_assignee', | |
| // object_id = task_id, foreign_id = wp user_id. | |
| $sql = " | |
| SELECT | |
| t.id AS root_task_id, | |
| t.board_id, | |
| t.title, | |
| t.description, | |
| t.stage_id, | |
| t.status, | |
| t.priority, | |
| t.created_at, | |
| t.updated_at, | |
| t.last_completed_at, | |
| b.title AS board_title, | |
| bt.title AS stage_title | |
| FROM {$wpdb->prefix}fbs_relations r | |
| JOIN {$wpdb->prefix}fbs_tasks t ON r.object_id = t.id | |
| LEFT JOIN {$wpdb->prefix}fbs_boards b ON t.board_id = b.id | |
| LEFT JOIN {$wpdb->prefix}fbs_board_terms bt ON t.stage_id = bt.id | |
| WHERE r.object_type = 'task_assignee' | |
| AND r.foreign_id = %d | |
| AND t.archived_at IS NULL | |
| {$where_since} | |
| ORDER BY t.updated_at DESC | |
| LIMIT 100 | |
| "; | |
| $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A ); | |
| if ( $rows === null ) { | |
| return new WP_REST_Response( [ | |
| 'error' => 'query_failed', | |
| 'message' => 'Database query failed: ' . $wpdb->last_error, | |
| ], 500 ); | |
| } | |
| // Trim description for transport efficiency. Full description fetched | |
| // on demand by leaf when dev clicks into a specific assignment. | |
| $out = []; | |
| foreach ( $rows as $r ) { | |
| $desc = (string) $r['description']; | |
| $desc_excerpt = function_exists( 'mb_substr' ) | |
| ? mb_substr( wp_strip_all_tags( $desc ), 0, 800 ) | |
| : substr( wp_strip_all_tags( $desc ), 0, 800 ); | |
| $out[] = [ | |
| 'root_task_id' => (int) $r['root_task_id'], | |
| 'board_id' => (int) $r['board_id'], | |
| 'board_title' => (string) $r['board_title'], | |
| 'stage_id' => (int) $r['stage_id'], | |
| 'stage_title' => (string) $r['stage_title'], | |
| 'status' => (string) $r['status'], | |
| 'priority' => (string) $r['priority'], | |
| 'title' => (string) $r['title'], | |
| 'description_excerpt' => $desc_excerpt, | |
| 'created_at' => (string) $r['created_at'], | |
| 'updated_at' => (string) $r['updated_at'], | |
| 'last_completed_at' => $r['last_completed_at'], | |
| ]; | |
| } | |
| return new WP_REST_Response( [ | |
| 'actor' => [ | |
| 'wp_user_id' => $user_id, | |
| 'login' => ( $u = get_user_by( 'id', $user_id ) ) ? $u->user_login : null, | |
| ], | |
| 'count' => count( $out ), | |
| 'fetched_at' => current_time( 'mysql', true ), | |
| 'assignments' => $out, | |
| ], 200 ); | |
| } | |
| /* =================================================================== | |
| * LEAF HALF — fetch logic | |
| * =================================================================== */ | |
| /** | |
| * Fetch assignments from the configured root. | |
| * Returns either a parsed payload or a WP_Error. | |
| */ | |
| public static function fetch_remote_assignments() { | |
| $cfg = get_option( self::OPTION_KEY, [] ); | |
| if ( empty( $cfg['url'] ) || empty( $cfg['app_user'] ) || empty( $cfg['app_password'] ) ) { | |
| return new WP_Error( 'not_configured', 'Remote root not configured. Set URL, username, and application password first.' ); | |
| } | |
| $url = rtrim( (string) $cfg['url'], '/' ) . '/wp-json/sa-orch/v1/root/assignments'; | |
| $auth_header = 'Basic ' . base64_encode( $cfg['app_user'] . ':' . str_replace( ' ', '', $cfg['app_password'] ) ); | |
| $resp = wp_remote_get( $url, [ | |
| 'headers' => [ 'Authorization' => $auth_header ], | |
| 'timeout' => self::REST_TIMEOUT, | |
| ] ); | |
| if ( is_wp_error( $resp ) ) { | |
| return $resp; | |
| } | |
| $code = (int) wp_remote_retrieve_response_code( $resp ); | |
| $body = (string) wp_remote_retrieve_body( $resp ); | |
| if ( $code < 200 || $code >= 300 ) { | |
| return new WP_Error( 'remote_error', "HTTP $code from root: " . substr( $body, 0, 240 ) ); | |
| } | |
| $payload = json_decode( $body, true ); | |
| if ( ! is_array( $payload ) ) { | |
| return new WP_Error( 'parse_error', 'Root response was not valid JSON: ' . substr( $body, 0, 240 ) ); | |
| } | |
| // Cache the last fetch in option for the admin page render. | |
| update_option( 'sa_orch_remote_root_last_fetch', [ | |
| 'at' => current_time( 'mysql', true ), | |
| 'payload' => $payload, | |
| ], false /* not autoloaded */ ); | |
| return $payload; | |
| } | |
| public static function rest_fetch_now( WP_REST_Request $req ) { | |
| $r = self::fetch_remote_assignments(); | |
| if ( is_wp_error( $r ) ) { | |
| return new WP_REST_Response( [ | |
| 'error' => $r->get_error_code(), | |
| 'message' => $r->get_error_message(), | |
| ], 502 ); | |
| } | |
| return new WP_REST_Response( $r, 200 ); | |
| } | |
| /* =================================================================== | |
| * LEAF HALF — admin page render | |
| * =================================================================== */ | |
| public static function render_page() { | |
| if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' ); | |
| $error = ''; | |
| $notice = ''; | |
| // Handle save | |
| if ( isset( $_POST['sa_orch_save_root'] ) && check_admin_referer( 'sa_orch_save_root' ) ) { | |
| $cfg = [ | |
| 'url' => esc_url_raw( trim( wp_unslash( $_POST['root_url'] ?? '' ) ) ), | |
| 'app_user' => sanitize_text_field( wp_unslash( $_POST['app_user'] ?? '' ) ), | |
| 'app_password' => trim( wp_unslash( $_POST['app_password'] ?? '' ) ), | |
| ]; | |
| update_option( self::OPTION_KEY, $cfg, false ); | |
| $notice = 'Remote root saved.'; | |
| } | |
| // Handle fetch button | |
| if ( isset( $_POST['sa_orch_fetch_now'] ) && check_admin_referer( 'sa_orch_fetch_now' ) ) { | |
| $r = self::fetch_remote_assignments(); | |
| if ( is_wp_error( $r ) ) { | |
| $error = 'Fetch failed: ' . $r->get_error_message(); | |
| } else { | |
| $notice = 'Fetched ' . (int) $r['count'] . ' assignment(s).'; | |
| } | |
| } | |
| $cfg = get_option( self::OPTION_KEY, [] ); | |
| $last_fetch = get_option( 'sa_orch_remote_root_last_fetch', [] ); | |
| $payload = $last_fetch['payload'] ?? null; | |
| $assignments = is_array( $payload ) ? ( $payload['assignments'] ?? [] ) : []; | |
| echo '<div class="wrap">'; | |
| echo '<h1>Remote Roots</h1>'; | |
| echo '<p>Pull assignments from a remote SA-Orchestration root (e.g., the dev root). Leaf-initiated; outbound HTTPS only.</p>'; | |
| if ( $error !== '' ) echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>'; | |
| if ( $notice !== '' ) echo '<div class="notice notice-success"><p>' . esc_html( $notice ) . '</p></div>'; | |
| // Configuration form | |
| echo '<h2>Configuration (single root, MVP)</h2>'; | |
| echo '<form method="post">'; | |
| wp_nonce_field( 'sa_orch_save_root' ); | |
| echo '<table class="form-table">'; | |
| echo '<tr><th>Root URL</th><td><input type="url" name="root_url" class="regular-text" placeholder="https://sleeperagendev.wpenginepowered.com" value="' . esc_attr( $cfg['url'] ?? '' ) . '" required></td></tr>'; | |
| echo '<tr><th>App Username</th><td><input type="text" name="app_user" class="regular-text" placeholder="user@example.com" value="' . esc_attr( $cfg['app_user'] ?? '' ) . '" required></td></tr>'; | |
| echo '<tr><th>App Password</th><td><input type="password" name="app_password" class="regular-text" placeholder="XXXX XXXX XXXX XXXX XXXX XXXX" value="' . esc_attr( $cfg['app_password'] ?? '' ) . '" required>'; | |
| echo '<p class="description">Generate at <code>{root}/wp-admin/profile.php</code> → Application Passwords. Stored in wp_options (not autoloaded). Revoke from same page anytime.</p></td></tr>'; | |
| echo '</table>'; | |
| echo '<p><button type="submit" name="sa_orch_save_root" value="1" class="button">Save Remote Root</button></p>'; | |
| echo '</form>'; | |
| // Fetch button | |
| echo '<h2>Pull Assignments</h2>'; | |
| echo '<form method="post">'; | |
| wp_nonce_field( 'sa_orch_fetch_now' ); | |
| echo '<p><button type="submit" name="sa_orch_fetch_now" value="1" class="button button-primary">Fetch Now</button>'; | |
| if ( ! empty( $last_fetch['at'] ) ) { | |
| echo ' <span style="margin-left:1em;color:#666;">Last fetched: ' . esc_html( $last_fetch['at'] ) . ' UTC</span>'; | |
| } | |
| echo '</p>'; | |
| echo '</form>'; | |
| // Render assignments | |
| if ( $payload && isset( $payload['count'] ) ) { | |
| $actor = $payload['actor'] ?? []; | |
| echo '<h2>Assignments (' . (int) $payload['count'] . ')</h2>'; | |
| echo '<p style="color:#666;">Authenticated to root as: <code>' . esc_html( $actor['login'] ?? '' ) . '</code> (wp_user_id=' . (int) ( $actor['wp_user_id'] ?? 0 ) . ')</p>'; | |
| if ( empty( $assignments ) ) { | |
| echo '<p>No assignments found for this user on the configured root.</p>'; | |
| } else { | |
| echo '<table class="widefat striped"><thead><tr>'; | |
| echo '<th>Root Task ID</th><th>Board</th><th>Stage</th><th>Status</th><th>Priority</th><th>Title</th><th>Updated</th>'; | |
| echo '</tr></thead><tbody>'; | |
| foreach ( $assignments as $a ) { | |
| echo '<tr>'; | |
| echo '<td>' . (int) $a['root_task_id'] . '</td>'; | |
| echo '<td>' . esc_html( $a['board_title'] ) . ' (#' . (int) $a['board_id'] . ')</td>'; | |
| echo '<td>' . esc_html( $a['stage_title'] ) . '</td>'; | |
| echo '<td>' . esc_html( $a['status'] ) . '</td>'; | |
| echo '<td>' . esc_html( $a['priority'] ) . '</td>'; | |
| echo '<td>' . esc_html( $a['title'] ) . '</td>'; | |
| echo '<td>' . esc_html( $a['updated_at'] ) . '</td>'; | |
| echo '</tr>'; | |
| if ( ! empty( $a['description_excerpt'] ) ) { | |
| echo '<tr><td colspan="7" style="background:#f8f8f8;font-size:11.5px;color:#444;padding:6px 12px;"><em>excerpt:</em> ' . esc_html( substr( $a['description_excerpt'], 0, 400 ) ) . ( strlen( $a['description_excerpt'] ) > 400 ? '...' : '' ) . '</td></tr>'; | |
| } | |
| } | |
| echo '</tbody></table>'; | |
| } | |
| } | |
| echo '</div>'; | |
| } | |
| } | |