diff --git a/data-machine.php b/data-machine.php index 701ea06b3..e36478d79 100644 --- a/data-machine.php +++ b/data-machine.php @@ -460,6 +460,9 @@ function datamachine_deactivate_plugin() { * @param bool $network_wide Whether the plugin is being network-activated. */ function datamachine_activate_plugin( $network_wide = false ) { + // Agent tables are network-scoped — create once regardless of activation mode. + datamachine_create_network_agent_tables(); + if ( is_multisite() && $network_wide ) { datamachine_for_each_site( 'datamachine_activate_for_site' ); } else { @@ -467,6 +470,22 @@ function datamachine_activate_plugin( $network_wide = false ) { } } +/** + * Create network-scoped agent tables. + * + * Agent identity, tokens, and access grants are shared across the multisite + * network, following the WordPress pattern where wp_users/wp_usermeta use + * base_prefix while per-site content uses site-specific prefixes. + * + * Safe to call multiple times — dbDelta is idempotent. + */ +function datamachine_create_network_agent_tables() { + \DataMachine\Core\Database\Agents\Agents::create_table(); + \DataMachine\Core\Database\Agents\Agents::ensure_site_scope_column(); + \DataMachine\Core\Database\Agents\AgentAccess::create_table(); + \DataMachine\Core\Database\Agents\AgentTokens::create_table(); +} + /** * Run activation tasks for a single site. * @@ -480,10 +499,9 @@ function datamachine_activate_for_site() { // Create logs table first — other table migrations log messages during creation. \DataMachine\Core\Database\Logs\LogRepository::create_table(); - // Ensure first-class agents table exists. - \DataMachine\Core\Database\Agents\Agents::create_table(); - \DataMachine\Core\Database\Agents\AgentAccess::create_table(); - \DataMachine\Core\Database\Agents\AgentTokens::create_table(); + // Agent tables are network-scoped (base_prefix) — ensure they exist. + // Safe to call per-site because dbDelta + base_prefix is idempotent. + datamachine_create_network_agent_tables(); $db_pipelines = new \DataMachine\Core\Database\Pipelines\Pipelines(); $db_pipelines->create_table(); @@ -524,6 +542,9 @@ function datamachine_activate_for_site() { // Migrate USER.md to network-scoped paths and create NETWORK.md on multisite (idempotent). datamachine_migrate_user_md_to_network_scope(); + // Migrate per-site agents to network-scoped tables (idempotent). + datamachine_migrate_agents_to_network_scope(); + // Regenerate SITE.md with enriched content and clean up legacy SiteContext transient. datamachine_regenerate_site_md(); delete_transient( 'datamachine_site_context_data' ); diff --git a/inc/Abilities/AgentAbilities.php b/inc/Abilities/AgentAbilities.php index 2d0933124..4f5a6fd26 100644 --- a/inc/Abilities/AgentAbilities.php +++ b/inc/Abilities/AgentAbilities.php @@ -830,12 +830,12 @@ public static function deleteAgent( array $input ): array { // Delete access grants. global $wpdb; - $access_table = $wpdb->prefix . 'datamachine_agent_access'; + $access_table = $wpdb->base_prefix . 'datamachine_agent_access'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $access_table, array( 'agent_id' => $agent_id ) ); // Delete agent record. - $agents_table = $wpdb->prefix . 'datamachine_agents'; + $agents_table = $wpdb->base_prefix . 'datamachine_agents'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $deleted = $wpdb->delete( $agents_table, array( 'agent_id' => $agent_id ) ); diff --git a/inc/Core/Database/Agents/AgentAccess.php b/inc/Core/Database/Agents/AgentAccess.php index 1b3303586..aa57c1560 100644 --- a/inc/Core/Database/Agents/AgentAccess.php +++ b/inc/Core/Database/Agents/AgentAccess.php @@ -33,6 +33,16 @@ class AgentAccess extends BaseRepository { */ const VALID_ROLES = array( 'admin', 'operator', 'viewer' ); + /** + * Use network-level prefix so access grants are shared across the multisite network. + * + * @return string + */ + protected static function get_table_prefix(): string { + global $wpdb; + return $wpdb->base_prefix; + } + /** * Create agent_access table. * @@ -41,7 +51,7 @@ class AgentAccess extends BaseRepository { public static function create_table(): void { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = $wpdb->base_prefix . self::TABLE_NAME; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( diff --git a/inc/Core/Database/Agents/AgentTokens.php b/inc/Core/Database/Agents/AgentTokens.php index 02afc0588..e22d98933 100644 --- a/inc/Core/Database/Agents/AgentTokens.php +++ b/inc/Core/Database/Agents/AgentTokens.php @@ -33,6 +33,16 @@ class AgentTokens extends BaseRepository { */ const TOKEN_PREFIX = 'datamachine_'; + /** + * Use network-level prefix so tokens are shared across the multisite network. + * + * @return string + */ + protected static function get_table_prefix(): string { + global $wpdb; + return $wpdb->base_prefix; + } + /** * Create agent_tokens table. * @@ -41,7 +51,7 @@ class AgentTokens extends BaseRepository { public static function create_table(): void { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = $wpdb->base_prefix . self::TABLE_NAME; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( diff --git a/inc/Core/Database/Agents/Agents.php b/inc/Core/Database/Agents/Agents.php index c7902a128..8b91c4384 100644 --- a/inc/Core/Database/Agents/Agents.php +++ b/inc/Core/Database/Agents/Agents.php @@ -23,6 +23,16 @@ class Agents extends BaseRepository { */ const TABLE_NAME = 'datamachine_agents'; + /** + * Use network-level prefix so agents are shared across the multisite network. + * + * @return string + */ + protected static function get_table_prefix(): string { + global $wpdb; + return $wpdb->base_prefix; + } + /** * Create agents table. * @@ -31,7 +41,7 @@ class Agents extends BaseRepository { public static function create_table(): void { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = $wpdb->base_prefix . self::TABLE_NAME; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( @@ -39,6 +49,7 @@ public static function create_table(): void { agent_slug VARCHAR(200) NOT NULL, agent_name VARCHAR(200) NOT NULL, owner_id BIGINT(20) UNSIGNED NOT NULL, + site_scope BIGINT(20) UNSIGNED NULL DEFAULT NULL, agent_config LONGTEXT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -46,13 +57,35 @@ public static function create_table(): void { PRIMARY KEY (agent_id), UNIQUE KEY agent_slug (agent_slug), KEY owner_id (owner_id), - KEY status (status) + KEY status (status), + KEY site_scope (site_scope) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } + /** + * Ensure site_scope column exists on existing installs. + * + * @return void + */ + public static function ensure_site_scope_column(): void { + global $wpdb; + + $table_name = $wpdb->base_prefix . self::TABLE_NAME; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $column = $wpdb->get_var( "SHOW COLUMNS FROM `{$table_name}` LIKE 'site_scope'" ); + + if ( ! $column ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "ALTER TABLE `{$table_name}` ADD COLUMN site_scope BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER owner_id" ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "ALTER TABLE `{$table_name}` ADD KEY site_scope (site_scope)" ); + } + } + /** * Get agent by agent ID. * diff --git a/inc/Core/Database/BaseRepository.php b/inc/Core/Database/BaseRepository.php index 9925114c9..8c3b218e8 100644 --- a/inc/Core/Database/BaseRepository.php +++ b/inc/Core/Database/BaseRepository.php @@ -42,7 +42,22 @@ abstract class BaseRepository { public function __construct() { global $wpdb; $this->wpdb = $wpdb; - $this->table_name = $wpdb->prefix . static::TABLE_NAME; + $this->table_name = static::get_table_prefix() . static::TABLE_NAME; + } + + /** + * Get the table prefix for this repository. + * + * Defaults to $wpdb->prefix (per-site). Network-scoped repositories + * (agents, tokens, access) override this to return $wpdb->base_prefix + * so their tables are shared across the multisite network, following + * the same pattern WordPress uses for wp_users and wp_usermeta. + * + * @return string Table prefix. + */ + protected static function get_table_prefix(): string { + global $wpdb; + return $wpdb->prefix; } /** diff --git a/inc/migrations.php b/inc/migrations.php index 493117f8b..0b6d5cfd5 100644 --- a/inc/migrations.php +++ b/inc/migrations.php @@ -1497,7 +1497,7 @@ function datamachine_assign_orphaned_resources_to_sole_agent(): void { // Only proceed for single-agent installs. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $agent_count = (int) $wpdb->get_var( - $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'datamachine_agents' ) + $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->base_prefix . 'datamachine_agents' ) ); if ( 1 !== $agent_count ) { @@ -1509,7 +1509,7 @@ function datamachine_assign_orphaned_resources_to_sole_agent(): void { // Get the sole agent's ID. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $agent_id = (int) $wpdb->get_var( - $wpdb->prepare( 'SELECT agent_id FROM %i LIMIT 1', $wpdb->prefix . 'datamachine_agents' ) + $wpdb->prepare( 'SELECT agent_id FROM %i LIMIT 1', $wpdb->base_prefix . 'datamachine_agents' ) ); if ( $agent_id <= 0 ) { @@ -1878,3 +1878,182 @@ function datamachine_activate_scheduled_flows() { ); } } + +/** + * Migrate per-site agent rows to the network-scoped table. + * + * On multisite, agent tables previously used $wpdb->prefix (per-site). + * This migration consolidates per-site agent rows into the network table + * ($wpdb->base_prefix) and sets site_scope to the originating blog_id. + * + * Deduplication: if an agent_slug already exists in the network table, + * the per-site row is skipped (the network table wins). + * + * Idempotent — guarded by a network-level site option. + * + * @since 0.52.0 + */ +function datamachine_migrate_agents_to_network_scope() { + if ( ! is_multisite() ) { + return; + } + + if ( get_site_option( 'datamachine_agents_network_migrated' ) ) { + return; + } + + global $wpdb; + + $network_agents_table = $wpdb->base_prefix . 'datamachine_agents'; + $network_access_table = $wpdb->base_prefix . 'datamachine_agent_access'; + $network_tokens_table = $wpdb->base_prefix . 'datamachine_agent_tokens'; + $migrated_agents = 0; + $migrated_access = 0; + + $sites = get_sites( array( 'fields' => 'ids' ) ); + + foreach ( $sites as $blog_id ) { + $site_prefix = $wpdb->get_blog_prefix( $blog_id ); + + // Skip the main site — its prefix IS the base_prefix, so the table is already network-level. + if ( $site_prefix === $wpdb->base_prefix ) { + // Set site_scope on existing main-site agents that don't have one yet. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + $wpdb->prepare( + "UPDATE `{$network_agents_table}` SET site_scope = %d WHERE site_scope IS NULL", + (int) $blog_id + ) + ); + continue; + } + + $site_agents_table = $site_prefix . 'datamachine_agents'; + $site_access_table = $site_prefix . 'datamachine_agent_access'; + $site_tokens_table = $site_prefix . 'datamachine_agent_tokens'; + + // Check if per-site agents table exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $site_agents_table ) ); + if ( ! $table_exists ) { + continue; + } + + // Get all agents from the per-site table. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $site_agents = $wpdb->get_results( "SELECT * FROM `{$site_agents_table}`", ARRAY_A ); + + if ( empty( $site_agents ) ) { + continue; + } + + foreach ( $site_agents as $agent ) { + $old_agent_id = (int) $agent['agent_id']; + + // Check if slug already exists in network table. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $existing = $wpdb->get_row( + $wpdb->prepare( + "SELECT agent_id FROM `{$network_agents_table}` WHERE agent_slug = %s", + $agent['agent_slug'] + ), + ARRAY_A + ); + + if ( $existing ) { + // Slug already exists in network table — skip this agent. + continue; + } + + // Insert into network table with site_scope. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $network_agents_table, + array( + 'agent_slug' => $agent['agent_slug'], + 'agent_name' => $agent['agent_name'], + 'owner_id' => (int) $agent['owner_id'], + 'site_scope' => (int) $blog_id, + 'agent_config' => $agent['agent_config'], + 'status' => $agent['status'], + 'created_at' => $agent['created_at'], + 'updated_at' => $agent['updated_at'], + ), + array( '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' ) + ); + + $new_agent_id = (int) $wpdb->insert_id; + + if ( $new_agent_id <= 0 ) { + continue; + } + + ++$migrated_agents; + + // Migrate access grants for this agent. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $site_access = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM `{$site_access_table}` WHERE agent_id = %d", $old_agent_id ), + ARRAY_A + ); + + foreach ( $site_access as $access ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $network_access_table, + array( + 'agent_id' => $new_agent_id, + 'user_id' => (int) $access['user_id'], + 'role' => $access['role'], + 'granted_at' => $access['granted_at'], + ), + array( '%d', '%d', '%s', '%s' ) + ); + ++$migrated_access; + } + + // Migrate tokens for this agent (if any). + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $token_table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $site_tokens_table ) ); + if ( $token_table_exists ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $site_tokens = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM `{$site_tokens_table}` WHERE agent_id = %d", $old_agent_id ), + ARRAY_A + ); + + foreach ( $site_tokens as $token ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $network_tokens_table, + array( + 'agent_id' => $new_agent_id, + 'token_hash' => $token['token_hash'], + 'token_prefix' => $token['token_prefix'], + 'label' => $token['label'], + 'capabilities' => $token['capabilities'], + 'last_used_at' => $token['last_used_at'], + 'expires_at' => $token['expires_at'], + 'created_at' => $token['created_at'], + ), + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + } + } + } + } + + update_site_option( 'datamachine_agents_network_migrated', true ); + + if ( $migrated_agents > 0 || $migrated_access > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Migrated per-site agents to network-scoped tables', + array( + 'agents_migrated' => $migrated_agents, + 'access_migrated' => $migrated_access, + ) + ); + } +}