NujacsaintS Claude Opus 4.7 (1M context) commited on
Commit
5a14210
·
1 Parent(s): 93528c6

Pre-stage: roll forward sa-orchestration/ with v0.8.0 MVP leaf-link

Browse files

Adds the new class-sa-leaf-link.php (Board #40 MVP — leaf↔root
assignments link) and updates the bootstrap to register its REST
routes + admin menu. Source-of-truth: SA-Orchestration source repo.
Pre-staged copy here stays current with the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

sa-orchestration/includes/class-sa-leaf-link.php ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+ defined( 'ABSPATH' ) || exit;
3
+
4
+ /**
5
+ * Leaf↔Root Link — v0.8.0 MVP
6
+ *
7
+ * Two halves in one class, ship together in every install:
8
+ *
9
+ * ROOT half — REST endpoint /sa-orch/v1/root/assignments
10
+ * Returns Fluent Boards tasks where the authenticated user is
11
+ * an assignee (via fbs_relations object_type='task_assignee').
12
+ * Auth: WP application password (Basic auth) or session nonce.
13
+ *
14
+ * LEAF half — Admin page (SA-Orchestration → Remote Roots)
15
+ * Stores ONE remote root config in wp_options (URL + app
16
+ * password). Manual "Fetch Now" button calls the configured
17
+ * root's /assignments endpoint and renders results in a
18
+ * table on the same admin page.
19
+ *
20
+ * MVP scope (locked):
21
+ * - One root per leaf (multi-root deferred to post-MVP)
22
+ * - No new tables — single wp_option for config
23
+ * - Manual fetch only; no cadence, heartbeat, or cron
24
+ * - No Fluent Support ticket creation; just list assignments on screen
25
+ * - No outbound curated updates; bidirectional sync is post-MVP
26
+ *
27
+ * The closed loop: developer self-assigns on Fluent Boards on the dev root
28
+ * → leaf admin clicks "Fetch Now" → assignment appears in the leaf's
29
+ * Remote Roots admin page.
30
+ *
31
+ * Doctrine: leaf-initiated, always up; the dev root never reaches into
32
+ * the leaf. Per Board #40 invariants.
33
+ */
34
+ class SA_Orch_Leaf_Link {
35
+
36
+ const SUBMENU_SLUG = 'sa-orchestration-remote-roots';
37
+ const OPTION_KEY = 'sa_orch_remote_root'; // single-root MVP storage
38
+ const REST_TIMEOUT = 15;
39
+
40
+ /* ===================================================================
41
+ * Hooks
42
+ * =================================================================== */
43
+
44
+ public static function register_routes() {
45
+ // ROOT half — serves assignments to authenticated callers.
46
+ register_rest_route( 'sa-orch/v1', '/root/assignments', [
47
+ 'methods' => 'GET',
48
+ 'callback' => [ __CLASS__, 'rest_assignments' ],
49
+ 'permission_callback' => function () {
50
+ return current_user_can( 'read' ); // any logged-in user; the
51
+ // join filters by their id
52
+ },
53
+ 'args' => [
54
+ 'since' => [ 'type' => 'string', 'required' => false ],
55
+ ],
56
+ ] );
57
+
58
+ // LEAF half — manual fetch trigger (callable by admin from the page).
59
+ register_rest_route( 'sa-orch/v1', '/leaf/fetch-now', [
60
+ 'methods' => 'POST',
61
+ 'callback' => [ __CLASS__, 'rest_fetch_now' ],
62
+ 'permission_callback' => function () {
63
+ return current_user_can( 'manage_options' );
64
+ },
65
+ ] );
66
+ }
67
+
68
+ public static function register_menu() {
69
+ add_submenu_page(
70
+ SA_Orch_Admin::PARENT_SLUG,
71
+ 'Remote Roots',
72
+ 'Remote Roots',
73
+ SA_Orch_Admin::CAP,
74
+ self::SUBMENU_SLUG,
75
+ [ __CLASS__, 'render_page' ]
76
+ );
77
+ }
78
+
79
+ /* ===================================================================
80
+ * ROOT HALF — assignments endpoint
81
+ * =================================================================== */
82
+
83
+ public static function rest_assignments( WP_REST_Request $req ) {
84
+ global $wpdb;
85
+
86
+ $user_id = get_current_user_id();
87
+ if ( $user_id <= 0 ) {
88
+ return new WP_REST_Response( [
89
+ 'error' => 'unauthenticated',
90
+ 'message' => 'No authenticated user resolved.',
91
+ ], 401 );
92
+ }
93
+
94
+ // Filter by 'since' (last_modified > since); optional.
95
+ $since = $req->get_param( 'since' );
96
+ $where_since = '';
97
+ $params = [ $user_id ];
98
+ if ( $since && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$/', (string) $since ) ) {
99
+ $where_since = " AND t.updated_at > %s";
100
+ $params[] = str_replace( 'T', ' ', rtrim( (string) $since, 'Z' ) );
101
+ }
102
+
103
+ // Fluent Boards assignment join: fbs_relations.object_type = 'task_assignee',
104
+ // object_id = task_id, foreign_id = wp user_id.
105
+ $sql = "
106
+ SELECT
107
+ t.id AS root_task_id,
108
+ t.board_id,
109
+ t.title,
110
+ t.description,
111
+ t.stage_id,
112
+ t.status,
113
+ t.priority,
114
+ t.created_at,
115
+ t.updated_at,
116
+ t.last_completed_at,
117
+ b.title AS board_title,
118
+ bt.title AS stage_title
119
+ FROM {$wpdb->prefix}fbs_relations r
120
+ JOIN {$wpdb->prefix}fbs_tasks t ON r.object_id = t.id
121
+ LEFT JOIN {$wpdb->prefix}fbs_boards b ON t.board_id = b.id
122
+ LEFT JOIN {$wpdb->prefix}fbs_board_terms bt ON t.stage_id = bt.id
123
+ WHERE r.object_type = 'task_assignee'
124
+ AND r.foreign_id = %d
125
+ AND t.archived_at IS NULL
126
+ {$where_since}
127
+ ORDER BY t.updated_at DESC
128
+ LIMIT 100
129
+ ";
130
+
131
+ $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A );
132
+ if ( $rows === null ) {
133
+ return new WP_REST_Response( [
134
+ 'error' => 'query_failed',
135
+ 'message' => 'Database query failed: ' . $wpdb->last_error,
136
+ ], 500 );
137
+ }
138
+
139
+ // Trim description for transport efficiency. Full description fetched
140
+ // on demand by leaf when dev clicks into a specific assignment.
141
+ $out = [];
142
+ foreach ( $rows as $r ) {
143
+ $desc = (string) $r['description'];
144
+ $desc_excerpt = function_exists( 'mb_substr' )
145
+ ? mb_substr( wp_strip_all_tags( $desc ), 0, 800 )
146
+ : substr( wp_strip_all_tags( $desc ), 0, 800 );
147
+ $out[] = [
148
+ 'root_task_id' => (int) $r['root_task_id'],
149
+ 'board_id' => (int) $r['board_id'],
150
+ 'board_title' => (string) $r['board_title'],
151
+ 'stage_id' => (int) $r['stage_id'],
152
+ 'stage_title' => (string) $r['stage_title'],
153
+ 'status' => (string) $r['status'],
154
+ 'priority' => (string) $r['priority'],
155
+ 'title' => (string) $r['title'],
156
+ 'description_excerpt' => $desc_excerpt,
157
+ 'created_at' => (string) $r['created_at'],
158
+ 'updated_at' => (string) $r['updated_at'],
159
+ 'last_completed_at' => $r['last_completed_at'],
160
+ ];
161
+ }
162
+
163
+ return new WP_REST_Response( [
164
+ 'actor' => [
165
+ 'wp_user_id' => $user_id,
166
+ 'login' => ( $u = get_user_by( 'id', $user_id ) ) ? $u->user_login : null,
167
+ ],
168
+ 'count' => count( $out ),
169
+ 'fetched_at' => current_time( 'mysql', true ),
170
+ 'assignments' => $out,
171
+ ], 200 );
172
+ }
173
+
174
+ /* ===================================================================
175
+ * LEAF HALF — fetch logic
176
+ * =================================================================== */
177
+
178
+ /**
179
+ * Fetch assignments from the configured root.
180
+ * Returns either a parsed payload or a WP_Error.
181
+ */
182
+ public static function fetch_remote_assignments() {
183
+ $cfg = get_option( self::OPTION_KEY, [] );
184
+ if ( empty( $cfg['url'] ) || empty( $cfg['app_user'] ) || empty( $cfg['app_password'] ) ) {
185
+ return new WP_Error( 'not_configured', 'Remote root not configured. Set URL, username, and application password first.' );
186
+ }
187
+
188
+ $url = rtrim( (string) $cfg['url'], '/' ) . '/wp-json/sa-orch/v1/root/assignments';
189
+
190
+ $auth_header = 'Basic ' . base64_encode( $cfg['app_user'] . ':' . str_replace( ' ', '', $cfg['app_password'] ) );
191
+
192
+ $resp = wp_remote_get( $url, [
193
+ 'headers' => [ 'Authorization' => $auth_header ],
194
+ 'timeout' => self::REST_TIMEOUT,
195
+ ] );
196
+
197
+ if ( is_wp_error( $resp ) ) {
198
+ return $resp;
199
+ }
200
+ $code = (int) wp_remote_retrieve_response_code( $resp );
201
+ $body = (string) wp_remote_retrieve_body( $resp );
202
+ if ( $code < 200 || $code >= 300 ) {
203
+ return new WP_Error( 'remote_error', "HTTP $code from root: " . substr( $body, 0, 240 ) );
204
+ }
205
+ $payload = json_decode( $body, true );
206
+ if ( ! is_array( $payload ) ) {
207
+ return new WP_Error( 'parse_error', 'Root response was not valid JSON: ' . substr( $body, 0, 240 ) );
208
+ }
209
+
210
+ // Cache the last fetch in option for the admin page render.
211
+ update_option( 'sa_orch_remote_root_last_fetch', [
212
+ 'at' => current_time( 'mysql', true ),
213
+ 'payload' => $payload,
214
+ ], false /* not autoloaded */ );
215
+
216
+ return $payload;
217
+ }
218
+
219
+ public static function rest_fetch_now( WP_REST_Request $req ) {
220
+ $r = self::fetch_remote_assignments();
221
+ if ( is_wp_error( $r ) ) {
222
+ return new WP_REST_Response( [
223
+ 'error' => $r->get_error_code(),
224
+ 'message' => $r->get_error_message(),
225
+ ], 502 );
226
+ }
227
+ return new WP_REST_Response( $r, 200 );
228
+ }
229
+
230
+ /* ===================================================================
231
+ * LEAF HALF — admin page render
232
+ * =================================================================== */
233
+
234
+ public static function render_page() {
235
+ if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' );
236
+
237
+ $error = '';
238
+ $notice = '';
239
+
240
+ // Handle save
241
+ if ( isset( $_POST['sa_orch_save_root'] ) && check_admin_referer( 'sa_orch_save_root' ) ) {
242
+ $cfg = [
243
+ 'url' => esc_url_raw( trim( wp_unslash( $_POST['root_url'] ?? '' ) ) ),
244
+ 'app_user' => sanitize_text_field( wp_unslash( $_POST['app_user'] ?? '' ) ),
245
+ 'app_password' => trim( wp_unslash( $_POST['app_password'] ?? '' ) ),
246
+ ];
247
+ update_option( self::OPTION_KEY, $cfg, false );
248
+ $notice = 'Remote root saved.';
249
+ }
250
+
251
+ // Handle fetch button
252
+ if ( isset( $_POST['sa_orch_fetch_now'] ) && check_admin_referer( 'sa_orch_fetch_now' ) ) {
253
+ $r = self::fetch_remote_assignments();
254
+ if ( is_wp_error( $r ) ) {
255
+ $error = 'Fetch failed: ' . $r->get_error_message();
256
+ } else {
257
+ $notice = 'Fetched ' . (int) $r['count'] . ' assignment(s).';
258
+ }
259
+ }
260
+
261
+ $cfg = get_option( self::OPTION_KEY, [] );
262
+ $last_fetch = get_option( 'sa_orch_remote_root_last_fetch', [] );
263
+ $payload = $last_fetch['payload'] ?? null;
264
+ $assignments = is_array( $payload ) ? ( $payload['assignments'] ?? [] ) : [];
265
+
266
+ echo '<div class="wrap">';
267
+ echo '<h1>Remote Roots</h1>';
268
+ echo '<p>Pull assignments from a remote SA-Orchestration root (e.g., the dev root). Leaf-initiated; outbound HTTPS only.</p>';
269
+
270
+ if ( $error !== '' ) echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>';
271
+ if ( $notice !== '' ) echo '<div class="notice notice-success"><p>' . esc_html( $notice ) . '</p></div>';
272
+
273
+ // Configuration form
274
+ echo '<h2>Configuration (single root, MVP)</h2>';
275
+ echo '<form method="post">';
276
+ wp_nonce_field( 'sa_orch_save_root' );
277
+ echo '<table class="form-table">';
278
+ 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>';
279
+ 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>';
280
+ 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>';
281
+ 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>';
282
+ echo '</table>';
283
+ echo '<p><button type="submit" name="sa_orch_save_root" value="1" class="button">Save Remote Root</button></p>';
284
+ echo '</form>';
285
+
286
+ // Fetch button
287
+ echo '<h2>Pull Assignments</h2>';
288
+ echo '<form method="post">';
289
+ wp_nonce_field( 'sa_orch_fetch_now' );
290
+ echo '<p><button type="submit" name="sa_orch_fetch_now" value="1" class="button button-primary">Fetch Now</button>';
291
+ if ( ! empty( $last_fetch['at'] ) ) {
292
+ echo ' <span style="margin-left:1em;color:#666;">Last fetched: ' . esc_html( $last_fetch['at'] ) . ' UTC</span>';
293
+ }
294
+ echo '</p>';
295
+ echo '</form>';
296
+
297
+ // Render assignments
298
+ if ( $payload && isset( $payload['count'] ) ) {
299
+ $actor = $payload['actor'] ?? [];
300
+ echo '<h2>Assignments (' . (int) $payload['count'] . ')</h2>';
301
+ 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>';
302
+
303
+ if ( empty( $assignments ) ) {
304
+ echo '<p>No assignments found for this user on the configured root.</p>';
305
+ } else {
306
+ echo '<table class="widefat striped"><thead><tr>';
307
+ echo '<th>Root Task ID</th><th>Board</th><th>Stage</th><th>Status</th><th>Priority</th><th>Title</th><th>Updated</th>';
308
+ echo '</tr></thead><tbody>';
309
+ foreach ( $assignments as $a ) {
310
+ echo '<tr>';
311
+ echo '<td>' . (int) $a['root_task_id'] . '</td>';
312
+ echo '<td>' . esc_html( $a['board_title'] ) . ' (#' . (int) $a['board_id'] . ')</td>';
313
+ echo '<td>' . esc_html( $a['stage_title'] ) . '</td>';
314
+ echo '<td>' . esc_html( $a['status'] ) . '</td>';
315
+ echo '<td>' . esc_html( $a['priority'] ) . '</td>';
316
+ echo '<td>' . esc_html( $a['title'] ) . '</td>';
317
+ echo '<td>' . esc_html( $a['updated_at'] ) . '</td>';
318
+ echo '</tr>';
319
+ if ( ! empty( $a['description_excerpt'] ) ) {
320
+ 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>';
321
+ }
322
+ }
323
+ echo '</tbody></table>';
324
+ }
325
+ }
326
+
327
+ echo '</div>';
328
+ }
329
+ }
sa-orchestration/sa-orchestration.php CHANGED
@@ -51,6 +51,7 @@ require_once SA_ORCH_DIR . 'includes/class-sa-sara-rest.php';
51
  require_once SA_ORCH_DIR . 'includes/class-sa-sara-admin.php';
52
  require_once SA_ORCH_DIR . 'includes/class-sa-asterion-projection.php'; // Board #30
53
  require_once SA_ORCH_DIR . 'includes/class-sa-seed-generator.php'; // Board #26
 
54
  require_once SA_ORCH_DIR . 'includes/class-sa-admin.php';
55
 
56
  register_activation_hook( __FILE__, [ 'SA_Orch_Migrations', 'install' ] );
@@ -59,10 +60,12 @@ add_action( 'rest_api_init', [ 'SA_Orch_Rest', 'register_routes' ] );
59
  add_action( 'rest_api_init', [ 'SA_Orch_Sara_Rest', 'register_routes' ] );
60
  add_action( 'rest_api_init', [ 'SA_Orch_Asterion_Projection', 'register_routes' ] ); // Board #30
61
  add_action( 'rest_api_init', [ 'SA_Orch_Seed_Generator', 'register_routes' ] ); // Board #26
 
62
  add_action( 'admin_menu', [ 'SA_Orch_Admin', 'register_menu' ] );
63
  add_action( 'admin_menu', [ 'SA_Orch_Asterion_Explorer', 'register_menu' ] );
64
  add_action( 'admin_menu', [ 'SA_Orch_Actor_Admin', 'register_menu' ] );
65
  add_action( 'admin_menu', [ 'SA_Orch_Seed_Generator', 'register_menu' ] ); // Board #26
 
66
  add_action( 'init', [ 'SA_Orch_Actor_Registry', 'register_role' ] );
67
  add_action( 'admin_init', [ 'SA_Orch_Actor_Admin', 'maybe_handle_action' ] );
68
  add_action( 'init', [ 'SA_Orch_Projection_Fluent_Support', 'register_hooks' ] );
 
51
  require_once SA_ORCH_DIR . 'includes/class-sa-sara-admin.php';
52
  require_once SA_ORCH_DIR . 'includes/class-sa-asterion-projection.php'; // Board #30
53
  require_once SA_ORCH_DIR . 'includes/class-sa-seed-generator.php'; // Board #26
54
+ require_once SA_ORCH_DIR . 'includes/class-sa-leaf-link.php'; // Board #40 v0.8.0 MVP
55
  require_once SA_ORCH_DIR . 'includes/class-sa-admin.php';
56
 
57
  register_activation_hook( __FILE__, [ 'SA_Orch_Migrations', 'install' ] );
 
60
  add_action( 'rest_api_init', [ 'SA_Orch_Sara_Rest', 'register_routes' ] );
61
  add_action( 'rest_api_init', [ 'SA_Orch_Asterion_Projection', 'register_routes' ] ); // Board #30
62
  add_action( 'rest_api_init', [ 'SA_Orch_Seed_Generator', 'register_routes' ] ); // Board #26
63
+ add_action( 'rest_api_init', [ 'SA_Orch_Leaf_Link', 'register_routes' ] ); // Board #40 v0.8.0 MVP
64
  add_action( 'admin_menu', [ 'SA_Orch_Admin', 'register_menu' ] );
65
  add_action( 'admin_menu', [ 'SA_Orch_Asterion_Explorer', 'register_menu' ] );
66
  add_action( 'admin_menu', [ 'SA_Orch_Actor_Admin', 'register_menu' ] );
67
  add_action( 'admin_menu', [ 'SA_Orch_Seed_Generator', 'register_menu' ] ); // Board #26
68
+ add_action( 'admin_menu', [ 'SA_Orch_Leaf_Link', 'register_menu' ] ); // Board #40 v0.8.0 MVP
69
  add_action( 'init', [ 'SA_Orch_Actor_Registry', 'register_role' ] );
70
  add_action( 'admin_init', [ 'SA_Orch_Actor_Admin', 'maybe_handle_action' ] );
71
  add_action( 'init', [ 'SA_Orch_Projection_Fluent_Support', 'register_hooks' ] );