File size: 15,117 Bytes
5a14210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
<?php
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>';
    }
}