diff --git a/data-machine.php b/data-machine.php index 29f7dc456..89abb09d1 100644 --- a/data-machine.php +++ b/data-machine.php @@ -42,318 +42,14 @@ } -function datamachine_run_datamachine_plugin() { - - // Set Action Scheduler timeout to 10 minutes (600 seconds) for large tasks - add_filter( - 'action_scheduler_timeout_period', - function () { - return 600; - } - ); - - // Initialize translation readiness tracking for lazy tool resolution - \DataMachine\Engine\AI\Tools\ToolManager::init(); - - // Cache invalidation hooks for dynamic registration - add_action( - 'datamachine_handler_registered', - function () { - \DataMachine\Abilities\HandlerAbilities::clearCache(); - } - ); - add_action( - 'datamachine_step_type_registered', - function () { - \DataMachine\Abilities\StepTypeAbilities::clearCache(); - } - ); - - datamachine_register_utility_filters(); - datamachine_register_admin_filters(); - datamachine_register_oauth_system(); - datamachine_register_core_actions(); - - // Load step types - they self-register via constructors - datamachine_load_step_types(); - - // Load and instantiate all handlers - they self-register via constructors - datamachine_load_handlers(); - - // Initialize FetchHandler to register skip_item tool for all fetch-type handlers - \DataMachine\Core\Steps\Fetch\Handlers\FetchHandler::init(); - - // Register all tools - must happen AFTER step types and handlers are registered. - \DataMachine\Engine\AI\Tools\ToolServiceProvider::register(); - - \DataMachine\Api\Execute::register(); - \DataMachine\Api\WebhookTrigger::register(); - \DataMachine\Api\Pipelines\Pipelines::register(); - \DataMachine\Api\Pipelines\PipelineSteps::register(); - \DataMachine\Api\Pipelines\PipelineFlows::register(); - \DataMachine\Api\Flows\Flows::register(); - \DataMachine\Api\Flows\FlowSteps::register(); - \DataMachine\Api\Flows\FlowQueue::register(); - \DataMachine\Api\AgentPing::register(); - \DataMachine\Api\AgentFiles::register(); - \DataMachine\Api\FlowFiles::register(); - \DataMachine\Api\Users::register(); - \DataMachine\Api\Agents::register(); - \DataMachine\Api\Logs::register(); - \DataMachine\Api\ProcessedItems::register(); - \DataMachine\Api\Jobs::register(); - \DataMachine\Api\Settings::register(); - \DataMachine\Api\Auth::register(); - \DataMachine\Api\Chat\Chat::register(); - \DataMachine\Api\System\System::register(); - \DataMachine\Api\Handlers::register(); - \DataMachine\Api\StepTypes::register(); - \DataMachine\Api\Tools::register(); - \DataMachine\Api\Providers::register(); - \DataMachine\Api\Analytics::register(); - \DataMachine\Api\InternalLinks::register(); - \DataMachine\Api\Email::register(); - - // Agent runtime authentication middleware. - new \DataMachine\Core\Auth\AgentAuthMiddleware(); - - // Agent browser-based authorization flow. - new \DataMachine\Core\Auth\AgentAuthorize(); - - // Agent auth callback handler (receives tokens from external DM instances). - new \DataMachine\Core\Auth\AgentAuthCallback(); - - // Load abilities - require_once __DIR__ . '/inc/Abilities/AuthAbilities.php'; - require_once __DIR__ . '/inc/Abilities/File/FileConstants.php'; - require_once __DIR__ . '/inc/Abilities/File/AgentFileAbilities.php'; - require_once __DIR__ . '/inc/Abilities/File/FlowFileAbilities.php'; - require_once __DIR__ . '/inc/Abilities/File/ScaffoldAbilities.php'; - require_once __DIR__ . '/inc/Abilities/FlowAbilities.php'; - require_once __DIR__ . '/inc/Abilities/FlowStepAbilities.php'; - require_once __DIR__ . '/inc/Abilities/JobAbilities.php'; - require_once __DIR__ . '/inc/Abilities/LogAbilities.php'; - require_once __DIR__ . '/inc/Abilities/PostQueryAbilities.php'; - require_once __DIR__ . '/inc/Abilities/PipelineAbilities.php'; - require_once __DIR__ . '/inc/Abilities/PipelineStepAbilities.php'; - require_once __DIR__ . '/inc/Core/Similarity/SimilarityResult.php'; - require_once __DIR__ . '/inc/Core/Similarity/SimilarityEngine.php'; - require_once __DIR__ . '/inc/Abilities/DuplicateCheck/DuplicateCheckAbility.php'; - require_once __DIR__ . '/inc/Abilities/ProcessedItemsAbilities.php'; - require_once __DIR__ . '/inc/Abilities/SettingsAbilities.php'; - require_once __DIR__ . '/inc/Abilities/HandlerAbilities.php'; - require_once __DIR__ . '/inc/Abilities/StepTypeAbilities.php'; - require_once __DIR__ . '/inc/Abilities/LocalSearchAbilities.php'; - require_once __DIR__ . '/inc/Abilities/SystemAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Media/AltTextAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Media/ImageGenerationAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Media/MediaAbilities.php'; - require_once __DIR__ . '/inc/Abilities/SEO/MetaDescriptionAbilities.php'; - require_once __DIR__ . '/inc/Abilities/SEO/IndexNowAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Media/ImageTemplateAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Analytics/BingWebmasterAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Analytics/PageSpeedAbilities.php'; - require_once __DIR__ . '/inc/Abilities/AgentPingAbilities.php'; - require_once __DIR__ . '/inc/Abilities/TaxonomyAbilities.php'; - require_once __DIR__ . '/inc/Abilities/AgentAbilities.php'; - require_once __DIR__ . '/inc/Abilities/AgentMemoryAbilities.php'; - require_once __DIR__ . '/inc/Abilities/DailyMemoryAbilities.php'; - // WorkspaceAbilities moved to data-machine-code extension. - require_once __DIR__ . '/inc/Abilities/ChatAbilities.php'; - require_once __DIR__ . '/inc/Abilities/InternalLinkingAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Content/BlockSanitizer.php'; - require_once __DIR__ . '/inc/Abilities/Content/PendingDiffStore.php'; - require_once __DIR__ . '/inc/Abilities/Content/CanonicalDiffPreview.php'; - require_once __DIR__ . '/inc/Abilities/Content/GetPostBlocksAbility.php'; - require_once __DIR__ . '/inc/Abilities/Content/EditPostBlocksAbility.php'; - require_once __DIR__ . '/inc/Abilities/Content/ReplacePostBlocksAbility.php'; - require_once __DIR__ . '/inc/Abilities/Content/ResolveDiffAbility.php'; - // GitHubAbilities moved to data-machine-code extension. - require_once __DIR__ . '/inc/Abilities/Fetch/FetchFilesAbility.php'; - require_once __DIR__ . '/inc/Abilities/Email/EmailAbilities.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/FetchEmailAbility.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/FetchRssAbility.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressApiAbility.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressMediaAbility.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/GetWordPressPostAbility.php'; - require_once __DIR__ . '/inc/Abilities/Fetch/QueryWordPressPostsAbility.php'; - require_once __DIR__ . '/inc/Abilities/Publish/PublishWordPressAbility.php'; - require_once __DIR__ . '/inc/Abilities/Publish/SendEmailAbility.php'; - require_once __DIR__ . '/inc/Abilities/Update/UpdateWordPressAbility.php'; - require_once __DIR__ . '/inc/Abilities/Handler/TestHandlerAbility.php'; - // Defer ability instantiation to init so translations are loaded. - add_action( 'init', function () { - new \DataMachine\Abilities\AuthAbilities(); - new \DataMachine\Abilities\File\AgentFileAbilities(); - new \DataMachine\Abilities\File\FlowFileAbilities(); - new \DataMachine\Abilities\File\ScaffoldAbilities(); - new \DataMachine\Abilities\FlowAbilities(); - new \DataMachine\Abilities\FlowStepAbilities(); - new \DataMachine\Abilities\JobAbilities(); - new \DataMachine\Abilities\LogAbilities(); - new \DataMachine\Abilities\PostQueryAbilities(); - new \DataMachine\Abilities\PipelineAbilities(); - new \DataMachine\Abilities\PipelineStepAbilities(); - new \DataMachine\Abilities\DuplicateCheck\DuplicateCheckAbility(); - new \DataMachine\Abilities\ProcessedItemsAbilities(); - new \DataMachine\Abilities\SettingsAbilities(); - new \DataMachine\Abilities\HandlerAbilities(); - new \DataMachine\Abilities\StepTypeAbilities(); - new \DataMachine\Abilities\LocalSearchAbilities(); - new \DataMachine\Abilities\SystemAbilities(); - new \DataMachine\Engine\AI\System\SystemAgentServiceProvider(); - new \DataMachine\Abilities\Media\AltTextAbilities(); - new \DataMachine\Abilities\Media\ImageGenerationAbilities(); - new \DataMachine\Abilities\Media\MediaAbilities(); - new \DataMachine\Abilities\SEO\MetaDescriptionAbilities(); - new \DataMachine\Abilities\SEO\IndexNowAbilities(); - new \DataMachine\Abilities\Media\ImageTemplateAbilities(); - new \DataMachine\Abilities\Analytics\BingWebmasterAbilities(); - new \DataMachine\Abilities\Analytics\GoogleAnalyticsAbilities(); - new \DataMachine\Abilities\Analytics\GoogleSearchConsoleAbilities(); - new \DataMachine\Abilities\Analytics\PageSpeedAbilities(); - new \DataMachine\Abilities\AgentPingAbilities(); - new \DataMachine\Abilities\TaxonomyAbilities(); - new \DataMachine\Abilities\AgentAbilities(); - new \DataMachine\Abilities\AgentTokenAbilities(); - new \DataMachine\Abilities\AgentMemoryAbilities(); - new \DataMachine\Abilities\DailyMemoryAbilities(); - // WorkspaceAbilities moved to data-machine-code extension. - new \DataMachine\Abilities\ChatAbilities(); - new \DataMachine\Abilities\InternalLinkingAbilities(); - new \DataMachine\Abilities\Content\GetPostBlocksAbility(); - new \DataMachine\Abilities\Content\EditPostBlocksAbility(); - new \DataMachine\Abilities\Content\ReplacePostBlocksAbility(); - new \DataMachine\Abilities\Content\InsertContentAbility(); - new \DataMachine\Abilities\Content\ResolveDiffAbility(); - // GitHubAbilities moved to data-machine-code extension. - new \DataMachine\Abilities\Fetch\FetchFilesAbility(); - new \DataMachine\Abilities\Email\EmailAbilities(); - new \DataMachine\Abilities\Fetch\FetchEmailAbility(); - new \DataMachine\Abilities\Fetch\FetchRssAbility(); - new \DataMachine\Abilities\Fetch\FetchWordPressApiAbility(); - new \DataMachine\Abilities\Fetch\FetchWordPressMediaAbility(); - new \DataMachine\Abilities\Fetch\GetWordPressPostAbility(); - new \DataMachine\Abilities\Fetch\QueryWordPressPostsAbility(); - new \DataMachine\Abilities\Publish\PublishWordPressAbility(); - new \DataMachine\Abilities\Publish\SendEmailAbility(); - new \DataMachine\Abilities\Update\UpdateWordPressAbility(); - new \DataMachine\Abilities\Handler\TestHandlerAbility(); - } ); - - // Clean up identity index rows when posts are permanently deleted. - add_action( - 'before_delete_post', - function ( $post_id ) { - $index = new \DataMachine\Core\Database\PostIdentityIndex\PostIdentityIndex(); - $index->delete( (int) $post_id ); - } - ); -} - // Plugin activation hook to initialize default settings register_activation_hook( __FILE__, 'datamachine_activate_plugin_defaults' ); -function datamachine_activate_plugin_defaults( $network_wide = false ) { - if ( is_multisite() && $network_wide ) { - datamachine_for_each_site( 'datamachine_activate_defaults_for_site' ); - } else { - datamachine_activate_defaults_for_site(); - } -} - -/** - * Set default settings for a single site. - */ -function datamachine_activate_defaults_for_site() { - $default_settings = array( - 'disabled_tools' => array(), // Opt-out pattern: empty = all tools enabled - 'enabled_pages' => array( - 'pipelines' => true, - 'jobs' => true, - 'logs' => true, - 'settings' => true, - ), - 'site_context_enabled' => true, - 'cleanup_job_data_on_failure' => true, - ); - - add_option( 'datamachine_settings', $default_settings ); -} - add_action( 'plugins_loaded', 'datamachine_run_datamachine_plugin', 20 ); -/** - * Load and instantiate all step types - they self-register via constructors. - * Uses StepTypeRegistrationTrait for standardized registration. - */ -function datamachine_load_step_types() { - new \DataMachine\Core\Steps\Fetch\FetchStep(); - new \DataMachine\Core\Steps\Publish\PublishStep(); - new \DataMachine\Core\Steps\Update\UpdateStep(); - new \DataMachine\Core\Steps\AI\AIStep(); - new \DataMachine\Core\Steps\AgentPing\AgentPingStep(); - new \DataMachine\Core\Steps\WebhookGate\WebhookGateStep(); - new \DataMachine\Core\Steps\SystemTask\SystemTaskStep(); -} - -/** - * Load and instantiate all handlers - they self-register via constructors. - * Clean, explicit approach using composer PSR-4 autoloading. - */ -function datamachine_load_handlers() { - // Publish Handlers (core only - social handlers moved to data-machine-socials plugin) - new \DataMachine\Core\Steps\Publish\Handlers\WordPress\WordPress(); - new \DataMachine\Core\Steps\Publish\Handlers\Email\Email(); - - // Fetch Handlers - new \DataMachine\Core\Steps\Fetch\Handlers\WordPress\WordPress(); - new \DataMachine\Core\Steps\Fetch\Handlers\WordPressAPI\WordPressAPI(); - new \DataMachine\Core\Steps\Fetch\Handlers\WordPressMedia\WordPressMedia(); - new \DataMachine\Core\Steps\Fetch\Handlers\Rss\Rss(); - new \DataMachine\Core\Steps\Fetch\Handlers\Email\Email(); - new \DataMachine\Core\Steps\Fetch\Handlers\Files\Files(); - // GitHub handler moved to data-machine-code extension. - // Workspace fetch handler moved to data-machine-code extension. - - // Update Handlers - new \DataMachine\Core\Steps\Update\Handlers\WordPress\WordPress(); - - // Workspace publish handler moved to data-machine-code extension. -} - -/** - * Scan directory for PHP files and instantiate classes. - * Classes are expected to self-register in their constructors. - */ -function datamachine_scan_and_instantiate( $directory ) { - $files = glob( $directory . '/*.php' ); - - foreach ( $files as $file ) { - // Skip if it's a *Filters.php file (will be deleted) - if ( strpos( basename( $file ), 'Filters.php' ) !== false ) { - continue; - } - - // Skip if it's a *Settings.php file - if ( strpos( basename( $file ), 'Settings.php' ) !== false ) { - continue; - } - - // Include the file - classes will auto-instantiate - include_once $file; - } -} - -function datamachine_allow_json_upload( $mimes ) { - $mimes['json'] = 'application/json'; - return $mimes; -} add_filter( 'upload_mimes', 'datamachine_allow_json_upload' ); add_action( 'update_option_datamachine_settings', array( \DataMachine\Core\PluginSettings::class, 'clearCache' ) ); @@ -370,347 +66,7 @@ function () { register_activation_hook( __FILE__, 'datamachine_activate_plugin' ); register_deactivation_hook( __FILE__, 'datamachine_deactivate_plugin' ); -/** - * Register Data Machine custom capabilities on roles. - * - * @since 0.37.0 - * @return void - */ -function datamachine_register_capabilities(): void { - $capabilities = array( - 'datamachine_manage_agents', - 'datamachine_manage_flows', - 'datamachine_manage_settings', - 'datamachine_chat', - 'datamachine_use_tools', - 'datamachine_view_logs', - 'datamachine_create_own_agent', - ); - - $administrator = get_role( 'administrator' ); - if ( $administrator ) { - foreach ( $capabilities as $capability ) { - $administrator->add_cap( $capability ); - } - } - - $editor = get_role( 'editor' ); - if ( $editor ) { - $editor->add_cap( 'datamachine_chat' ); - $editor->add_cap( 'datamachine_use_tools' ); - $editor->add_cap( 'datamachine_view_logs' ); - $editor->add_cap( 'datamachine_create_own_agent' ); - } - - $author = get_role( 'author' ); - if ( $author ) { - $author->add_cap( 'datamachine_chat' ); - $author->add_cap( 'datamachine_use_tools' ); - $author->add_cap( 'datamachine_create_own_agent' ); - } - - $contributor = get_role( 'contributor' ); - if ( $contributor ) { - $contributor->add_cap( 'datamachine_chat' ); - $contributor->add_cap( 'datamachine_create_own_agent' ); - } - - $subscriber = get_role( 'subscriber' ); - if ( $subscriber ) { - $subscriber->add_cap( 'datamachine_chat' ); - $subscriber->add_cap( 'datamachine_create_own_agent' ); - } -} - -/** - * Remove Data Machine custom capabilities from roles. - * - * @since 0.37.0 - * @return void - */ -function datamachine_remove_capabilities(): void { - $capabilities = array( - 'datamachine_manage_agents', - 'datamachine_manage_flows', - 'datamachine_manage_settings', - 'datamachine_chat', - 'datamachine_use_tools', - 'datamachine_view_logs', - 'datamachine_create_own_agent', - ); - - $roles = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); - - foreach ( $roles as $role_name ) { - $role = get_role( $role_name ); - if ( ! $role ) { - continue; - } - - foreach ( $capabilities as $capability ) { - $role->remove_cap( $capability ); - } - } -} - -function datamachine_deactivate_plugin() { - datamachine_remove_capabilities(); - - // Unschedule all recurring maintenance actions. - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( 'datamachine_cleanup_stale_claims', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_failed_jobs', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_completed_jobs', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_logs', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_processed_items', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_as_actions', array(), 'datamachine-maintenance' ); - as_unschedule_all_actions( 'datamachine_cleanup_old_files', array(), 'datamachine-files' ); - as_unschedule_all_actions( 'datamachine_cleanup_chat_sessions', array(), 'datamachine-chat' ); - } -} - -/** - * Plugin activation handler. - * - * Creates database tables, log directory, and re-schedules any flows - * with non-manual scheduling intervals. - * - * @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 { - datamachine_activate_for_site(); - } -} - -/** - * 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. - * - * Creates tables, log directory, default memory files, and re-schedules flows. - * Called directly on single-site, or per-site during network activation and - * new site creation. - */ -function datamachine_activate_for_site() { - datamachine_register_capabilities(); - - // Create logs table first — other table migrations log messages during creation. - \DataMachine\Core\Database\Logs\LogRepository::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(); - $db_pipelines->migrate_columns(); - - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $db_flows->create_table(); - $db_flows->migrate_columns(); - - $db_jobs = new \DataMachine\Core\Database\Jobs\Jobs(); - $db_jobs->create_table(); - - $db_processed_items = new \DataMachine\Core\Database\ProcessedItems\ProcessedItems(); - $db_processed_items->create_table(); - - $db_identity_index = new \DataMachine\Core\Database\PostIdentityIndex\PostIdentityIndex(); - $db_identity_index->create_table(); - - \DataMachine\Core\Database\Chat\Chat::create_table(); - \DataMachine\Core\Database\Chat\Chat::ensure_context_column(); - \DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column(); - - // Ensure default agent memory files exist. - datamachine_ensure_default_memory_files(); - - // Run layered architecture migration (idempotent). - datamachine_migrate_to_layered_architecture(); - - // Migrate flow_config handler keys from singular to plural (idempotent). - datamachine_migrate_handler_keys_to_plural(); - - // Backfill agent_id on pipelines, flows, and jobs from user_id→owner_id mapping (idempotent). - datamachine_backfill_agent_ids(); - - // Assign orphaned resources (agent_id IS NULL) to sole agent on single-agent installs (idempotent). - datamachine_assign_orphaned_resources_to_sole_agent(); - - // 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(); - - // Drop orphaned per-site agent tables left behind by the migration (idempotent). - datamachine_drop_orphaned_agent_tables(); - - // Regenerate SITE.md with enriched content and clean up legacy SiteContext transient. - datamachine_regenerate_site_md(); - delete_transient( 'datamachine_site_context_data' ); - - // Clean up legacy per-agent-type log level options (idempotent). - foreach ( array( 'pipeline', 'chat', 'system' ) as $legacy_agent_type ) { - delete_option( "datamachine_log_level_{$legacy_agent_type}" ); - } - - // Re-schedule any flows with non-manual scheduling - datamachine_activate_scheduled_flows(); - - // Track DB schema version so deploy-time migrations auto-run. - update_option( 'datamachine_db_version', DATAMACHINE_VERSION, true ); -} - -/** - * Resolve or create first-class agent ID for a WordPress user. - * - * @since 0.37.0 - * - * @param int $user_id WordPress user ID. - * @return int Agent ID, or 0 when resolution fails. - */ -function datamachine_resolve_or_create_agent_id( int $user_id ): int { - $user_id = absint( $user_id ); - - if ( $user_id <= 0 ) { - return 0; - } - - $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); - $existing = $agents_repo->get_by_owner_id( $user_id ); - - if ( ! empty( $existing['agent_id'] ) ) { - return (int) $existing['agent_id']; - } - - $user = get_user_by( 'id', $user_id ); - if ( ! $user ) { - return 0; - } - - $agent_slug = sanitize_title( (string) $user->user_login ); - $agent_name = (string) $user->display_name; - $agent_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); - - return $agents_repo->create_if_missing( - $agent_slug, - $agent_name, - $user_id, - array( - 'model' => array( - 'default' => $agent_model, - ), - ) - ); -} - -/** - * Run a callback for every site on the network. - * - * Switches to each site, runs the callback, then restores. Used by - * activation hooks and new site hooks to ensure per-site setup. - * - * @param callable $callback Function to call in each site context. - */ -function datamachine_for_each_site( callable $callback ) { - $sites = get_sites( array( 'fields' => 'ids' ) ); - foreach ( $sites as $blog_id ) { - switch_to_blog( $blog_id ); - $callback(); - restore_current_blog(); - } -} - -/** - * Create Data Machine tables and defaults when a new site is added to the network. - * - * Only runs if Data Machine is network-active. - * - * @param WP_Site $new_site New site object. - */ -function datamachine_on_new_site( \WP_Site $new_site ) { - if ( ! is_plugin_active_for_network( plugin_basename( __FILE__ ) ) ) { - return; - } - - switch_to_blog( $new_site->blog_id ); - datamachine_activate_defaults_for_site(); - datamachine_activate_for_site(); - restore_current_blog(); -} add_action( 'wp_initialize_site', 'datamachine_on_new_site', 200 ); // Migrations, scaffolding, and activation helpers. require_once __DIR__ . '/inc/migrations.php'; - - -function datamachine_check_requirements() { - if ( version_compare( PHP_VERSION, '8.0', '<' ) ) { - add_action( - 'admin_notices', - function () { - echo '

'; - printf( - esc_html( 'Data Machine requires PHP %2$s or higher. You are running PHP %1$s.' ), - esc_html( PHP_VERSION ), - '8.0' - ); - echo '

'; - } - ); - return false; - } - - global $wp_version; - $current_wp_version = $wp_version ?? '0.0.0'; - if ( version_compare( $current_wp_version, '6.9', '<' ) ) { - add_action( - 'admin_notices', - function () use ( $current_wp_version ) { - echo '

'; - printf( - esc_html( 'Data Machine requires WordPress %2$s or higher. You are running WordPress %1$s.' ), - esc_html( $current_wp_version ), - '6.9' - ); - echo '

'; - } - ); - return false; - } - - if ( ! file_exists( __DIR__ . '/vendor/autoload.php' ) ) { - add_action( - 'admin_notices', - function () { - echo '

'; - echo esc_html( 'Data Machine: Composer dependencies are missing. Please run "composer install" or contact Chubes to report a bug.' ); - echo '

'; - } - ); - return false; - } - - return true; -} diff --git a/data-machine/datamachine.php b/data-machine/datamachine.php new file mode 100644 index 000000000..ebb3199fe --- /dev/null +++ b/data-machine/datamachine.php @@ -0,0 +1,397 @@ +//! datamachine — extracted from data-machine.php. + + +function datamachine_activate_plugin_defaults( $network_wide = false ) { + if ( is_multisite() && $network_wide ) { + datamachine_for_each_site( 'datamachine_activate_defaults_for_site' ); + } else { + datamachine_activate_defaults_for_site(); + } +} + +/** + * Set default settings for a single site. + */ +function datamachine_activate_defaults_for_site() { + $default_settings = array( + 'disabled_tools' => array(), // Opt-out pattern: empty = all tools enabled + 'enabled_pages' => array( + 'pipelines' => true, + 'jobs' => true, + 'logs' => true, + 'settings' => true, + ), + 'site_context_enabled' => true, + 'cleanup_job_data_on_failure' => true, + ); + + add_option( 'datamachine_settings', $default_settings ); +} + +/** + * Scan directory for PHP files and instantiate classes. + * Classes are expected to self-register in their constructors. + */ +function datamachine_scan_and_instantiate( $directory ) { + $files = glob( $directory . '/*.php' ); + + foreach ( $files as $file ) { + // Skip if it's a *Filters.php file (will be deleted) + if ( strpos( basename( $file ), 'Filters.php' ) !== false ) { + continue; + } + + // Skip if it's a *Settings.php file + if ( strpos( basename( $file ), 'Settings.php' ) !== false ) { + continue; + } + + // Include the file - classes will auto-instantiate + include_once $file; + } +} + +function datamachine_allow_json_upload( $mimes ) { + $mimes['json'] = 'application/json'; + return $mimes; +} + +/** + * Register Data Machine custom capabilities on roles. + * + * @since 0.37.0 + * @return void + */ +function datamachine_register_capabilities(): void { + $capabilities = array( + 'datamachine_manage_agents', + 'datamachine_manage_flows', + 'datamachine_manage_settings', + 'datamachine_chat', + 'datamachine_use_tools', + 'datamachine_view_logs', + 'datamachine_create_own_agent', + ); + + $administrator = get_role( 'administrator' ); + if ( $administrator ) { + foreach ( $capabilities as $capability ) { + $administrator->add_cap( $capability ); + } + } + + $editor = get_role( 'editor' ); + if ( $editor ) { + $editor->add_cap( 'datamachine_chat' ); + $editor->add_cap( 'datamachine_use_tools' ); + $editor->add_cap( 'datamachine_view_logs' ); + $editor->add_cap( 'datamachine_create_own_agent' ); + } + + $author = get_role( 'author' ); + if ( $author ) { + $author->add_cap( 'datamachine_chat' ); + $author->add_cap( 'datamachine_use_tools' ); + $author->add_cap( 'datamachine_create_own_agent' ); + } + + $contributor = get_role( 'contributor' ); + if ( $contributor ) { + $contributor->add_cap( 'datamachine_chat' ); + $contributor->add_cap( 'datamachine_create_own_agent' ); + } + + $subscriber = get_role( 'subscriber' ); + if ( $subscriber ) { + $subscriber->add_cap( 'datamachine_chat' ); + $subscriber->add_cap( 'datamachine_create_own_agent' ); + } +} + +/** + * Remove Data Machine custom capabilities from roles. + * + * @since 0.37.0 + * @return void + */ +function datamachine_remove_capabilities(): void { + $capabilities = array( + 'datamachine_manage_agents', + 'datamachine_manage_flows', + 'datamachine_manage_settings', + 'datamachine_chat', + 'datamachine_use_tools', + 'datamachine_view_logs', + 'datamachine_create_own_agent', + ); + + $roles = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); + + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( ! $role ) { + continue; + } + + foreach ( $capabilities as $capability ) { + $role->remove_cap( $capability ); + } + } +} + +function datamachine_deactivate_plugin() { + datamachine_remove_capabilities(); + + // Unschedule all recurring maintenance actions. + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( 'datamachine_cleanup_stale_claims', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_failed_jobs', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_completed_jobs', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_logs', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_processed_items', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_as_actions', array(), 'datamachine-maintenance' ); + as_unschedule_all_actions( 'datamachine_cleanup_old_files', array(), 'datamachine-files' ); + as_unschedule_all_actions( 'datamachine_cleanup_chat_sessions', array(), 'datamachine-chat' ); + } +} + +/** + * Plugin activation handler. + * + * Creates database tables, log directory, and re-schedules any flows + * with non-manual scheduling intervals. + * + * @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 { + datamachine_activate_for_site(); + } +} + +/** + * 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. + * + * Creates tables, log directory, default memory files, and re-schedules flows. + * Called directly on single-site, or per-site during network activation and + * new site creation. + */ +function datamachine_activate_for_site() { + datamachine_register_capabilities(); + + // Create logs table first — other table migrations log messages during creation. + \DataMachine\Core\Database\Logs\LogRepository::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(); + $db_pipelines->migrate_columns(); + + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $db_flows->create_table(); + $db_flows->migrate_columns(); + + $db_jobs = new \DataMachine\Core\Database\Jobs\Jobs(); + $db_jobs->create_table(); + + $db_processed_items = new \DataMachine\Core\Database\ProcessedItems\ProcessedItems(); + $db_processed_items->create_table(); + + $db_identity_index = new \DataMachine\Core\Database\PostIdentityIndex\PostIdentityIndex(); + $db_identity_index->create_table(); + + \DataMachine\Core\Database\Chat\Chat::create_table(); + \DataMachine\Core\Database\Chat\Chat::ensure_context_column(); + \DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column(); + + // Ensure default agent memory files exist. + datamachine_ensure_default_memory_files(); + + // Run layered architecture migration (idempotent). + datamachine_migrate_to_layered_architecture(); + + // Migrate flow_config handler keys from singular to plural (idempotent). + datamachine_migrate_handler_keys_to_plural(); + + // Backfill agent_id on pipelines, flows, and jobs from user_id→owner_id mapping (idempotent). + datamachine_backfill_agent_ids(); + + // Assign orphaned resources (agent_id IS NULL) to sole agent on single-agent installs (idempotent). + datamachine_assign_orphaned_resources_to_sole_agent(); + + // 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(); + + // Drop orphaned per-site agent tables left behind by the migration (idempotent). + datamachine_drop_orphaned_agent_tables(); + + // Regenerate SITE.md with enriched content and clean up legacy SiteContext transient. + datamachine_regenerate_site_md(); + delete_transient( 'datamachine_site_context_data' ); + + // Clean up legacy per-agent-type log level options (idempotent). + foreach ( array( 'pipeline', 'chat', 'system' ) as $legacy_agent_type ) { + delete_option( "datamachine_log_level_{$legacy_agent_type}" ); + } + + // Re-schedule any flows with non-manual scheduling + datamachine_activate_scheduled_flows(); + + // Track DB schema version so deploy-time migrations auto-run. + update_option( 'datamachine_db_version', DATAMACHINE_VERSION, true ); +} + +/** + * Resolve or create first-class agent ID for a WordPress user. + * + * @since 0.37.0 + * + * @param int $user_id WordPress user ID. + * @return int Agent ID, or 0 when resolution fails. + */ +function datamachine_resolve_or_create_agent_id( int $user_id ): int { + $user_id = absint( $user_id ); + + if ( $user_id <= 0 ) { + return 0; + } + + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + $existing = $agents_repo->get_by_owner_id( $user_id ); + + if ( ! empty( $existing['agent_id'] ) ) { + return (int) $existing['agent_id']; + } + + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return 0; + } + + $agent_slug = sanitize_title( (string) $user->user_login ); + $agent_name = (string) $user->display_name; + $agent_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); + + return $agents_repo->create_if_missing( + $agent_slug, + $agent_name, + $user_id, + array( + 'model' => array( + 'default' => $agent_model, + ), + ) + ); +} + +/** + * Run a callback for every site on the network. + * + * Switches to each site, runs the callback, then restores. Used by + * activation hooks and new site hooks to ensure per-site setup. + * + * @param callable $callback Function to call in each site context. + */ +function datamachine_for_each_site( callable $callback ) { + $sites = get_sites( array( 'fields' => 'ids' ) ); + foreach ( $sites as $blog_id ) { + switch_to_blog( $blog_id ); + $callback(); + restore_current_blog(); + } +} + +/** + * Create Data Machine tables and defaults when a new site is added to the network. + * + * Only runs if Data Machine is network-active. + * + * @param WP_Site $new_site New site object. + */ +function datamachine_on_new_site( \WP_Site $new_site ) { + if ( ! is_plugin_active_for_network( plugin_basename( __FILE__ ) ) ) { + return; + } + + switch_to_blog( $new_site->blog_id ); + datamachine_activate_defaults_for_site(); + datamachine_activate_for_site(); + restore_current_blog(); +} + +function datamachine_check_requirements() { + if ( version_compare( PHP_VERSION, '8.0', '<' ) ) { + add_action( + 'admin_notices', + function () { + echo '

'; + printf( + esc_html( 'Data Machine requires PHP %2$s or higher. You are running PHP %1$s.' ), + esc_html( PHP_VERSION ), + '8.0' + ); + echo '

'; + } + ); + return false; + } + + global $wp_version; + $current_wp_version = $wp_version ?? '0.0.0'; + if ( version_compare( $current_wp_version, '6.9', '<' ) ) { + add_action( + 'admin_notices', + function () use ( $current_wp_version ) { + echo '

'; + printf( + esc_html( 'Data Machine requires WordPress %2$s or higher. You are running WordPress %1$s.' ), + esc_html( $current_wp_version ), + '6.9' + ); + echo '

'; + } + ); + return false; + } + + if ( ! file_exists( __DIR__ . '/vendor/autoload.php' ) ) { + add_action( + 'admin_notices', + function () { + echo '

'; + echo esc_html( 'Data Machine: Composer dependencies are missing. Please run "composer install" or contact Chubes to report a bug.' ); + echo '

'; + } + ); + return false; + } + + return true; +} diff --git a/data-machine/datamachine_load.php b/data-machine/datamachine_load.php new file mode 100644 index 000000000..5156449a0 --- /dev/null +++ b/data-machine/datamachine_load.php @@ -0,0 +1,252 @@ +//! datamachine_load — extracted from data-machine.php. + + +function datamachine_run_datamachine_plugin() { + + // Set Action Scheduler timeout to 10 minutes (600 seconds) for large tasks + add_filter( + 'action_scheduler_timeout_period', + function () { + return 600; + } + ); + + // Initialize translation readiness tracking for lazy tool resolution + \DataMachine\Engine\AI\Tools\ToolManager::init(); + + // Cache invalidation hooks for dynamic registration + add_action( + 'datamachine_handler_registered', + function () { + \DataMachine\Abilities\HandlerAbilities::clearCache(); + } + ); + add_action( + 'datamachine_step_type_registered', + function () { + \DataMachine\Abilities\StepTypeAbilities::clearCache(); + } + ); + + datamachine_register_utility_filters(); + datamachine_register_admin_filters(); + datamachine_register_oauth_system(); + datamachine_register_core_actions(); + + // Load step types - they self-register via constructors + datamachine_load_step_types(); + + // Load and instantiate all handlers - they self-register via constructors + datamachine_load_handlers(); + + // Initialize FetchHandler to register skip_item tool for all fetch-type handlers + \DataMachine\Core\Steps\Fetch\Handlers\FetchHandler::init(); + + // Register all tools - must happen AFTER step types and handlers are registered. + \DataMachine\Engine\AI\Tools\ToolServiceProvider::register(); + + \DataMachine\Api\Execute::register(); + \DataMachine\Api\WebhookTrigger::register(); + \DataMachine\Api\Pipelines\Pipelines::register(); + \DataMachine\Api\Pipelines\PipelineSteps::register(); + \DataMachine\Api\Pipelines\PipelineFlows::register(); + \DataMachine\Api\Flows\Flows::register(); + \DataMachine\Api\Flows\FlowSteps::register(); + \DataMachine\Api\Flows\FlowQueue::register(); + \DataMachine\Api\AgentPing::register(); + \DataMachine\Api\AgentFiles::register(); + \DataMachine\Api\FlowFiles::register(); + \DataMachine\Api\Users::register(); + \DataMachine\Api\Agents::register(); + \DataMachine\Api\Logs::register(); + \DataMachine\Api\ProcessedItems::register(); + \DataMachine\Api\Jobs::register(); + \DataMachine\Api\Settings::register(); + \DataMachine\Api\Auth::register(); + \DataMachine\Api\Chat\Chat::register(); + \DataMachine\Api\System\System::register(); + \DataMachine\Api\Handlers::register(); + \DataMachine\Api\StepTypes::register(); + \DataMachine\Api\Tools::register(); + \DataMachine\Api\Providers::register(); + \DataMachine\Api\Analytics::register(); + \DataMachine\Api\InternalLinks::register(); + \DataMachine\Api\Email::register(); + + // Agent runtime authentication middleware. + new \DataMachine\Core\Auth\AgentAuthMiddleware(); + + // Agent browser-based authorization flow. + new \DataMachine\Core\Auth\AgentAuthorize(); + + // Agent auth callback handler (receives tokens from external DM instances). + new \DataMachine\Core\Auth\AgentAuthCallback(); + + // Load abilities + require_once __DIR__ . '/inc/Abilities/AuthAbilities.php'; + require_once __DIR__ . '/inc/Abilities/File/FileConstants.php'; + require_once __DIR__ . '/inc/Abilities/File/AgentFileAbilities.php'; + require_once __DIR__ . '/inc/Abilities/File/FlowFileAbilities.php'; + require_once __DIR__ . '/inc/Abilities/File/ScaffoldAbilities.php'; + require_once __DIR__ . '/inc/Abilities/FlowAbilities.php'; + require_once __DIR__ . '/inc/Abilities/FlowStepAbilities.php'; + require_once __DIR__ . '/inc/Abilities/JobAbilities.php'; + require_once __DIR__ . '/inc/Abilities/LogAbilities.php'; + require_once __DIR__ . '/inc/Abilities/PostQueryAbilities.php'; + require_once __DIR__ . '/inc/Abilities/PipelineAbilities.php'; + require_once __DIR__ . '/inc/Abilities/PipelineStepAbilities.php'; + require_once __DIR__ . '/inc/Core/Similarity/SimilarityResult.php'; + require_once __DIR__ . '/inc/Core/Similarity/SimilarityEngine.php'; + require_once __DIR__ . '/inc/Abilities/DuplicateCheck/DuplicateCheckAbility.php'; + require_once __DIR__ . '/inc/Abilities/ProcessedItemsAbilities.php'; + require_once __DIR__ . '/inc/Abilities/SettingsAbilities.php'; + require_once __DIR__ . '/inc/Abilities/HandlerAbilities.php'; + require_once __DIR__ . '/inc/Abilities/StepTypeAbilities.php'; + require_once __DIR__ . '/inc/Abilities/LocalSearchAbilities.php'; + require_once __DIR__ . '/inc/Abilities/SystemAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Media/AltTextAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Media/ImageGenerationAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Media/MediaAbilities.php'; + require_once __DIR__ . '/inc/Abilities/SEO/MetaDescriptionAbilities.php'; + require_once __DIR__ . '/inc/Abilities/SEO/IndexNowAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Media/ImageTemplateAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Analytics/BingWebmasterAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Analytics/PageSpeedAbilities.php'; + require_once __DIR__ . '/inc/Abilities/AgentPingAbilities.php'; + require_once __DIR__ . '/inc/Abilities/TaxonomyAbilities.php'; + require_once __DIR__ . '/inc/Abilities/AgentAbilities.php'; + require_once __DIR__ . '/inc/Abilities/AgentMemoryAbilities.php'; + require_once __DIR__ . '/inc/Abilities/DailyMemoryAbilities.php'; + // WorkspaceAbilities moved to data-machine-code extension. + require_once __DIR__ . '/inc/Abilities/ChatAbilities.php'; + require_once __DIR__ . '/inc/Abilities/InternalLinkingAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Content/BlockSanitizer.php'; + require_once __DIR__ . '/inc/Abilities/Content/PendingDiffStore.php'; + require_once __DIR__ . '/inc/Abilities/Content/CanonicalDiffPreview.php'; + require_once __DIR__ . '/inc/Abilities/Content/GetPostBlocksAbility.php'; + require_once __DIR__ . '/inc/Abilities/Content/EditPostBlocksAbility.php'; + require_once __DIR__ . '/inc/Abilities/Content/ReplacePostBlocksAbility.php'; + require_once __DIR__ . '/inc/Abilities/Content/ResolveDiffAbility.php'; + // GitHubAbilities moved to data-machine-code extension. + require_once __DIR__ . '/inc/Abilities/Fetch/FetchFilesAbility.php'; + require_once __DIR__ . '/inc/Abilities/Email/EmailAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/FetchEmailAbility.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/FetchRssAbility.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressApiAbility.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressMediaAbility.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/GetWordPressPostAbility.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/QueryWordPressPostsAbility.php'; + require_once __DIR__ . '/inc/Abilities/Publish/PublishWordPressAbility.php'; + require_once __DIR__ . '/inc/Abilities/Publish/SendEmailAbility.php'; + require_once __DIR__ . '/inc/Abilities/Update/UpdateWordPressAbility.php'; + require_once __DIR__ . '/inc/Abilities/Handler/TestHandlerAbility.php'; + // Defer ability instantiation to init so translations are loaded. + add_action( 'init', function () { + new \DataMachine\Abilities\AuthAbilities(); + new \DataMachine\Abilities\File\AgentFileAbilities(); + new \DataMachine\Abilities\File\FlowFileAbilities(); + new \DataMachine\Abilities\File\ScaffoldAbilities(); + new \DataMachine\Abilities\FlowAbilities(); + new \DataMachine\Abilities\FlowStepAbilities(); + new \DataMachine\Abilities\JobAbilities(); + new \DataMachine\Abilities\LogAbilities(); + new \DataMachine\Abilities\PostQueryAbilities(); + new \DataMachine\Abilities\PipelineAbilities(); + new \DataMachine\Abilities\PipelineStepAbilities(); + new \DataMachine\Abilities\DuplicateCheck\DuplicateCheckAbility(); + new \DataMachine\Abilities\ProcessedItemsAbilities(); + new \DataMachine\Abilities\SettingsAbilities(); + new \DataMachine\Abilities\HandlerAbilities(); + new \DataMachine\Abilities\StepTypeAbilities(); + new \DataMachine\Abilities\LocalSearchAbilities(); + new \DataMachine\Abilities\SystemAbilities(); + new \DataMachine\Engine\AI\System\SystemAgentServiceProvider(); + new \DataMachine\Abilities\Media\AltTextAbilities(); + new \DataMachine\Abilities\Media\ImageGenerationAbilities(); + new \DataMachine\Abilities\Media\MediaAbilities(); + new \DataMachine\Abilities\SEO\MetaDescriptionAbilities(); + new \DataMachine\Abilities\SEO\IndexNowAbilities(); + new \DataMachine\Abilities\Media\ImageTemplateAbilities(); + new \DataMachine\Abilities\Analytics\BingWebmasterAbilities(); + new \DataMachine\Abilities\Analytics\GoogleAnalyticsAbilities(); + new \DataMachine\Abilities\Analytics\GoogleSearchConsoleAbilities(); + new \DataMachine\Abilities\Analytics\PageSpeedAbilities(); + new \DataMachine\Abilities\AgentPingAbilities(); + new \DataMachine\Abilities\TaxonomyAbilities(); + new \DataMachine\Abilities\AgentAbilities(); + new \DataMachine\Abilities\AgentTokenAbilities(); + new \DataMachine\Abilities\AgentMemoryAbilities(); + new \DataMachine\Abilities\DailyMemoryAbilities(); + // WorkspaceAbilities moved to data-machine-code extension. + new \DataMachine\Abilities\ChatAbilities(); + new \DataMachine\Abilities\InternalLinkingAbilities(); + new \DataMachine\Abilities\Content\GetPostBlocksAbility(); + new \DataMachine\Abilities\Content\EditPostBlocksAbility(); + new \DataMachine\Abilities\Content\ReplacePostBlocksAbility(); + new \DataMachine\Abilities\Content\InsertContentAbility(); + new \DataMachine\Abilities\Content\ResolveDiffAbility(); + // GitHubAbilities moved to data-machine-code extension. + new \DataMachine\Abilities\Fetch\FetchFilesAbility(); + new \DataMachine\Abilities\Email\EmailAbilities(); + new \DataMachine\Abilities\Fetch\FetchEmailAbility(); + new \DataMachine\Abilities\Fetch\FetchRssAbility(); + new \DataMachine\Abilities\Fetch\FetchWordPressApiAbility(); + new \DataMachine\Abilities\Fetch\FetchWordPressMediaAbility(); + new \DataMachine\Abilities\Fetch\GetWordPressPostAbility(); + new \DataMachine\Abilities\Fetch\QueryWordPressPostsAbility(); + new \DataMachine\Abilities\Publish\PublishWordPressAbility(); + new \DataMachine\Abilities\Publish\SendEmailAbility(); + new \DataMachine\Abilities\Update\UpdateWordPressAbility(); + new \DataMachine\Abilities\Handler\TestHandlerAbility(); + } ); + + // Clean up identity index rows when posts are permanently deleted. + add_action( + 'before_delete_post', + function ( $post_id ) { + $index = new \DataMachine\Core\Database\PostIdentityIndex\PostIdentityIndex(); + $index->delete( (int) $post_id ); + } + ); +} + +/** + * Load and instantiate all step types - they self-register via constructors. + * Uses StepTypeRegistrationTrait for standardized registration. + */ +function datamachine_load_step_types() { + new \DataMachine\Core\Steps\Fetch\FetchStep(); + new \DataMachine\Core\Steps\Publish\PublishStep(); + new \DataMachine\Core\Steps\Update\UpdateStep(); + new \DataMachine\Core\Steps\AI\AIStep(); + new \DataMachine\Core\Steps\AgentPing\AgentPingStep(); + new \DataMachine\Core\Steps\WebhookGate\WebhookGateStep(); + new \DataMachine\Core\Steps\SystemTask\SystemTaskStep(); +} + +/** + * Load and instantiate all handlers - they self-register via constructors. + * Clean, explicit approach using composer PSR-4 autoloading. + */ +function datamachine_load_handlers() { + // Publish Handlers (core only - social handlers moved to data-machine-socials plugin) + new \DataMachine\Core\Steps\Publish\Handlers\WordPress\WordPress(); + new \DataMachine\Core\Steps\Publish\Handlers\Email\Email(); + + // Fetch Handlers + new \DataMachine\Core\Steps\Fetch\Handlers\WordPress\WordPress(); + new \DataMachine\Core\Steps\Fetch\Handlers\WordPressAPI\WordPressAPI(); + new \DataMachine\Core\Steps\Fetch\Handlers\WordPressMedia\WordPressMedia(); + new \DataMachine\Core\Steps\Fetch\Handlers\Rss\Rss(); + new \DataMachine\Core\Steps\Fetch\Handlers\Email\Email(); + new \DataMachine\Core\Steps\Fetch\Handlers\Files\Files(); + // GitHub handler moved to data-machine-code extension. + // Workspace fetch handler moved to data-machine-code extension. + + // Update Handlers + new \DataMachine\Core\Steps\Update\Handlers\WordPress\WordPress(); + + // Workspace publish handler moved to data-machine-code extension. +} diff --git a/docs/admin-interface/pipeline-builder.md b/docs/admin-interface/pipeline-builder.md index 0e9e1be7a..385b04f2e 100644 --- a/docs/admin-interface/pipeline-builder.md +++ b/docs/admin-interface/pipeline-builder.md @@ -102,7 +102,7 @@ Modal state is also held in the UI store (`activeModal`, `modalData`) but is not The Pipelines UI calls REST endpoints through `inc/Core/Admin/Pages/Pipelines/assets/react/utils/api.js` (a wrapper around `@wordpress/api-fetch` that reads the nonce/namespace from `window.dataMachineConfig`). -For the authoritative list of endpoints used by the UI, refer to `inc/Core/Admin/Pages/Pipelines/assets/react/utils/api.js` and the PHP REST controllers under `data-machine/inc/Api/`. +For the authoritative list of endpoints used by the UI, refer to `inc/Core/Admin/Pages/Pipelines/assets/react/utils/api.js` and the PHP REST controllers under `inc/Api/`. ### Benefits of React Architecture diff --git a/docs/api/endpoints/files.md b/docs/api/endpoints/files.md index 0569ebb3c..3567dcfc9 100644 --- a/docs/api/endpoints/files.md +++ b/docs/api/endpoints/files.md @@ -1,6 +1,6 @@ # Files Endpoint -**Implementation**: `inc/Api/Files.php`, `inc/Api/AgentFiles.php` +**Implementation**: `inc/Core/Steps/Fetch/Handlers/Files/Files.php`, `inc/Api/AgentFiles.php` **Base URL**: `/wp-json/datamachine/v1/files` @@ -8,7 +8,7 @@ The Files endpoint handles two distinct scopes: -1. **Flow files** — File uploads for pipeline processing with flow-isolated storage, security validation, and automatic URL generation (`inc/Api/Files.php`) +1. **Flow files** — File uploads for pipeline processing with flow-isolated storage, security validation, and automatic URL generation (`inc/Core/Steps/Fetch/Handlers/Files/Files.php`) 2. **Agent files** — Agent memory file management with 3-layer directory resolution for SOUL.md, MEMORY.md, USER.md, and daily memory journals (`inc/Api/AgentFiles.php`) ## Authentication @@ -130,7 +130,7 @@ Delete a file by filename. **Implementation**: `inc/Api/AgentFiles.php` (@since v0.38.0) Agent files use a 3-layer directory resolution system: -1. **Shared layer** (`shared/`) — Site-wide files like SITE.md +1. **Shared layer** (`inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/`) — Site-wide files like SITE.md 2. **Agent layer** (`agents/{slug}/`) — Agent-specific files: SOUL.md, MEMORY.md 3. **User layer** (`users/{id}/`) — User-specific files: USER.md @@ -561,5 +561,5 @@ curl -X POST https://example.com/wp-json/datamachine/v1/files \ --- **Base URL**: `/wp-json/datamachine/v1/files` -**Implementation**: `inc/Api/Files.php` (flow files), `inc/Api/AgentFiles.php` (agent files) +**Implementation**: `inc/Core/Steps/Fetch/Handlers/Files/Files.php` (flow files), `inc/Api/AgentFiles.php` (agent files) **Max File Size**: WordPress `wp_max_upload_size()` setting diff --git a/docs/api/index.md b/docs/api/index.md index d23875366..a8ba08c90 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -10,7 +10,7 @@ Complete REST API reference for Data Machine **Permissions**: Most endpoints require `manage_options` capability -**Implementation**: All endpoints are implemented in `data-machine/inc/Api/`. The project is migrating business logic to the WordPress 6.9 Abilities API; REST handlers should prefer calling abilities (via `wp_get_ability()` / `wp_ability_execute`) where available. Service managers may still be instantiated as a transitional implementation during migration. +**Implementation**: All endpoints are implemented in `inc/Api/`. The project is migrating business logic to the WordPress 6.9 Abilities API; REST handlers should prefer calling abilities (via `wp_get_ability()` / `wp_ability_execute`) where available. Service managers may still be instantiated as a transitional implementation during migration. ## Endpoint Categories @@ -75,7 +75,7 @@ Endpoints returning lists support pagination parameters: ## Implementation Guide -All endpoints are implemented in `data-machine/inc/Api/` using the services layer architecture for direct method calls, with automatic registration via `rest_api_init`: +All endpoints are implemented in `inc/Api/` using the services layer architecture for direct method calls, with automatic registration via `rest_api_init`: ```php // Example endpoint registration using services layer diff --git a/docs/core-system/abilities-api.md b/docs/core-system/abilities-api.md index d07922bc2..728ce597d 100644 --- a/docs/core-system/abilities-api.md +++ b/docs/core-system/abilities-api.md @@ -18,13 +18,13 @@ All abilities support `agent_id` and `user_id` parameters for multi-agent scopin | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-pipelines` | List pipelines with pagination, or get single by ID | `Pipeline/GetPipelinesAbility.php` | -| `datamachine/create-pipeline` | Create new pipeline | `Pipeline/CreatePipelineAbility.php` | -| `datamachine/update-pipeline` | Update pipeline properties | `Pipeline/UpdatePipelineAbility.php` | -| `datamachine/delete-pipeline` | Delete pipeline and associated flows | `Pipeline/DeletePipelineAbility.php` | -| `datamachine/duplicate-pipeline` | Duplicate pipeline with flows | `Pipeline/DuplicatePipelineAbility.php` | -| `datamachine/import-pipelines` | Import pipelines from JSON | `Pipeline/ImportExportAbility.php` | -| `datamachine/export-pipelines` | Export pipelines to JSON | `Pipeline/ImportExportAbility.php` | +| `datamachine/get-pipelines` | List pipelines with pagination, or get single by ID | `inc/Abilities/Pipeline/GetPipelinesAbility.php` | +| `datamachine/create-pipeline` | Create new pipeline | `inc/Abilities/Pipeline/CreatePipelineAbility.php` | +| `datamachine/update-pipeline` | Update pipeline properties | `inc/Abilities/Pipeline/UpdatePipelineAbility.php` | +| `datamachine/delete-pipeline` | Delete pipeline and associated flows | `inc/Abilities/Pipeline/DeletePipelineAbility.php` | +| `datamachine/duplicate-pipeline` | Duplicate pipeline with flows | `inc/Abilities/Pipeline/DuplicatePipelineAbility.php` | +| `datamachine/import-pipelines` | Import pipelines from JSON | `inc/Abilities/Pipeline/ImportExportAbility.php` | +| `datamachine/export-pipelines` | Export pipelines to JSON | `inc/Abilities/Pipeline/ImportExportAbility.php` | ### Pipeline Steps (5 abilities) @@ -40,56 +40,56 @@ All abilities support `agent_id` and `user_id` parameters for multi-agent scopin | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-flows` | List flows with filtering, or get single by ID | `Flow/GetFlowsAbility.php` | -| `datamachine/create-flow` | Create new flow from pipeline | `Flow/CreateFlowAbility.php` | -| `datamachine/update-flow` | Update flow properties | `Flow/UpdateFlowAbility.php` | -| `datamachine/delete-flow` | Delete flow and associated jobs | `Flow/DeleteFlowAbility.php` | -| `datamachine/duplicate-flow` | Duplicate flow within pipeline | `Flow/DuplicateFlowAbility.php` | +| `datamachine/get-flows` | List flows with filtering, or get single by ID | `inc/Abilities/Flow/GetFlowsAbility.php` | +| `datamachine/create-flow` | Create new flow from pipeline | `inc/Abilities/Flow/CreateFlowAbility.php` | +| `datamachine/update-flow` | Update flow properties | `inc/Abilities/Flow/UpdateFlowAbility.php` | +| `datamachine/delete-flow` | Delete flow and associated jobs | `inc/Abilities/Flow/DeleteFlowAbility.php` | +| `datamachine/duplicate-flow` | Duplicate flow within pipeline | `inc/Abilities/Flow/DuplicateFlowAbility.php` | ### Flow Steps (4 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-flow-steps` | List steps for a flow, or get single by ID | `FlowStep/GetFlowStepsAbility.php` | -| `datamachine/update-flow-step` | Update flow step config | `FlowStep/UpdateFlowStepAbility.php` | -| `datamachine/configure-flow-steps` | Bulk configure flow steps | `FlowStep/ConfigureFlowStepsAbility.php` | -| `datamachine/validate-flow-steps-config` | Validate flow steps configuration | `FlowStep/ValidateFlowStepsConfigAbility.php` | +| `datamachine/get-flow-steps` | List steps for a flow, or get single by ID | `inc/Abilities/FlowStep/GetFlowStepsAbility.php` | +| `datamachine/update-flow-step` | Update flow step config | `inc/Abilities/FlowStep/UpdateFlowStepAbility.php` | +| `datamachine/configure-flow-steps` | Bulk configure flow steps | `inc/Abilities/FlowStep/ConfigureFlowStepsAbility.php` | +| `datamachine/validate-flow-steps-config` | Validate flow steps configuration | `inc/Abilities/FlowStep/ValidateFlowStepsConfigAbility.php` | ### Queue Management (7 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/queue-add` | Add item to flow queue | `Flow/QueueAbility.php` | -| `datamachine/queue-list` | List queue entries | `Flow/QueueAbility.php` | -| `datamachine/queue-clear` | Clear queue | `Flow/QueueAbility.php` | -| `datamachine/queue-remove` | Remove item from queue | `Flow/QueueAbility.php` | -| `datamachine/queue-update` | Update queue item | `Flow/QueueAbility.php` | -| `datamachine/queue-move` | Reorder queue item | `Flow/QueueAbility.php` | -| `datamachine/queue-settings` | Get/set queue settings | `Flow/QueueAbility.php` | +| `datamachine/queue-add` | Add item to flow queue | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-list` | List queue entries | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-clear` | Clear queue | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-remove` | Remove item from queue | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-update` | Update queue item | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-move` | Reorder queue item | `inc/Abilities/Flow/QueueAbility.php` | +| `datamachine/queue-settings` | Get/set queue settings | `inc/Abilities/Flow/QueueAbility.php` | ### Webhook Triggers (5 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/webhook-trigger-enable` | Enable webhook trigger for a flow and generate Bearer token | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-disable` | Disable webhook trigger, revoke token | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-regenerate` | Regenerate webhook token (old token immediately invalidated) | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-rate-limit` | Set rate limiting for flow webhook trigger | `Flow/WebhookTriggerAbility.php` | -| `datamachine/webhook-trigger-status` | Get webhook trigger status for a flow | `Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-enable` | Enable webhook trigger for a flow and generate Bearer token | `inc/Abilities/Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-disable` | Disable webhook trigger, revoke token | `inc/Abilities/Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-regenerate` | Regenerate webhook token (old token immediately invalidated) | `inc/Abilities/Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-rate-limit` | Set rate limiting for flow webhook trigger | `inc/Abilities/Flow/WebhookTriggerAbility.php` | +| `datamachine/webhook-trigger-status` | Get webhook trigger status for a flow | `inc/Abilities/Flow/WebhookTriggerAbility.php` | ### Job Execution (9 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-jobs` | List jobs with filtering, or get single by ID | `Job/GetJobsAbility.php` | -| `datamachine/get-jobs-summary` | Get job status summary counts | `Job/JobsSummaryAbility.php` | -| `datamachine/delete-jobs` | Delete jobs by criteria | `Job/DeleteJobsAbility.php` | -| `datamachine/execute-workflow` | Execute workflow | `Job/ExecuteWorkflowAbility.php` | -| `datamachine/get-flow-health` | Get flow health metrics | `Job/FlowHealthAbility.php` | -| `datamachine/get-problem-flows` | List flows exceeding failure threshold | `Job/ProblemFlowsAbility.php` | -| `datamachine/recover-stuck-jobs` | Recover jobs stuck in processing state | `Job/RecoverStuckJobsAbility.php` | -| `datamachine/retry-job` | Retry a failed job | `Job/RetryJobAbility.php` | -| `datamachine/fail-job` | Manually fail a processing job | `Job/FailJobAbility.php` | +| `datamachine/get-jobs` | List jobs with filtering, or get single by ID | `inc/Abilities/Job/GetJobsAbility.php` | +| `datamachine/get-jobs-summary` | Get job status summary counts | `inc/Abilities/Job/JobsSummaryAbility.php` | +| `datamachine/delete-jobs` | Delete jobs by criteria | `inc/Abilities/Job/DeleteJobsAbility.php` | +| `datamachine/execute-workflow` | Execute workflow | `inc/Abilities/Job/ExecuteWorkflowAbility.php` | +| `datamachine/get-flow-health` | Get flow health metrics | `inc/Abilities/Job/FlowHealthAbility.php` | +| `datamachine/get-problem-flows` | List flows exceeding failure threshold | `inc/Abilities/Job/ProblemFlowsAbility.php` | +| `datamachine/recover-stuck-jobs` | Recover jobs stuck in processing state | `inc/Abilities/Job/RecoverStuckJobsAbility.php` | +| `datamachine/retry-job` | Retry a failed job | `inc/Abilities/Job/RetryJobAbility.php` | +| `datamachine/fail-job` | Manually fail a processing job | `inc/Abilities/Job/FailJobAbility.php` | ### Engine (4 abilities) @@ -97,10 +97,10 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/run-flow` | Run a flow | `Engine/RunFlowAbility.php` | -| `datamachine/execute-step` | Execute a pipeline step | `Engine/ExecuteStepAbility.php` | -| `datamachine/schedule-next-step` | Schedule the next step in pipeline execution | `Engine/ScheduleNextStepAbility.php` | -| `datamachine/schedule-flow` | Schedule a flow for execution | `Engine/ScheduleFlowAbility.php` | +| `datamachine/run-flow` | Run a flow | `inc/Abilities/Engine/RunFlowAbility.php` | +| `datamachine/execute-step` | Execute a pipeline step | `inc/Abilities/Engine/ExecuteStepAbility.php` | +| `datamachine/schedule-next-step` | Schedule the next step in pipeline execution | `inc/Abilities/Engine/ScheduleNextStepAbility.php` | +| `datamachine/schedule-flow` | Schedule a flow for execution | `inc/Abilities/Engine/ScheduleFlowAbility.php` | ### Agent Management (6 abilities) @@ -136,21 +136,21 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/list-agent-files` | List memory files from agent identity and user layers | `File/AgentFileAbilities.php` | -| `datamachine/get-agent-file` | Get a single agent memory file with content | `File/AgentFileAbilities.php` | -| `datamachine/write-agent-file` | Write or update content for an agent memory file | `File/AgentFileAbilities.php` | -| `datamachine/delete-agent-file` | Delete an agent memory file (protected files cannot be deleted) | `File/AgentFileAbilities.php` | -| `datamachine/upload-agent-file` | Upload a file to the agent memory directory | `File/AgentFileAbilities.php` | +| `datamachine/list-agent-files` | List memory files from agent identity and user layers | `inc/Abilities/File/AgentFileAbilities.php` | +| `datamachine/get-agent-file` | Get a single agent memory file with content | `inc/Abilities/File/AgentFileAbilities.php` | +| `datamachine/write-agent-file` | Write or update content for an agent memory file | `inc/Abilities/File/AgentFileAbilities.php` | +| `datamachine/delete-agent-file` | Delete an agent memory file (protected files cannot be deleted) | `inc/Abilities/File/AgentFileAbilities.php` | +| `datamachine/upload-agent-file` | Upload a file to the agent memory directory | `inc/Abilities/File/AgentFileAbilities.php` | ### Flow Files (5 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/list-flow-files` | List uploaded files for a flow step | `File/FlowFileAbilities.php` | -| `datamachine/get-flow-file` | Get metadata for a single flow file | `File/FlowFileAbilities.php` | -| `datamachine/delete-flow-file` | Delete an uploaded file from a flow step | `File/FlowFileAbilities.php` | -| `datamachine/upload-flow-file` | Upload a file to a flow step | `File/FlowFileAbilities.php` | -| `datamachine/cleanup-flow-files` | Cleanup data packets and temporary files for a job or flow | `File/FlowFileAbilities.php` | +| `datamachine/list-flow-files` | List uploaded files for a flow step | `inc/Abilities/File/FlowFileAbilities.php` | +| `datamachine/get-flow-file` | Get metadata for a single flow file | `inc/Abilities/File/FlowFileAbilities.php` | +| `datamachine/delete-flow-file` | Delete an uploaded file from a flow step | `inc/Abilities/File/FlowFileAbilities.php` | +| `datamachine/upload-flow-file` | Upload a file to a flow step | `inc/Abilities/File/FlowFileAbilities.php` | +| `datamachine/cleanup-flow-files` | Cleanup data packets and temporary files for a job or flow | `inc/Abilities/File/FlowFileAbilities.php` | ### Workspace (16 abilities) @@ -177,10 +177,10 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/create-chat-session` | Create a new chat session for a user | `Chat/CreateChatSessionAbility.php` | -| `datamachine/list-chat-sessions` | List chat sessions with pagination and context filtering | `Chat/ListChatSessionsAbility.php` | -| `datamachine/get-chat-session` | Retrieve a chat session with conversation and metadata | `Chat/GetChatSessionAbility.php` | -| `datamachine/delete-chat-session` | Delete a chat session after verifying ownership | `Chat/DeleteChatSessionAbility.php` | +| `datamachine/create-chat-session` | Create a new chat session for a user | `inc/Abilities/Chat/CreateChatSessionAbility.php` | +| `datamachine/list-chat-sessions` | List chat sessions with pagination and context filtering | `inc/Abilities/Chat/ListChatSessionsAbility.php` | +| `datamachine/get-chat-session` | Retrieve a chat session with conversation and metadata | `inc/Abilities/Chat/GetChatSessionAbility.php` | +| `datamachine/delete-chat-session` | Delete a chat session after verifying ownership | `inc/Abilities/Chat/DeleteChatSessionAbility.php` | ### GitHub (6 abilities) @@ -197,22 +197,22 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/fetch-rss` | Fetch items from RSS/Atom feeds | `Fetch/FetchRssAbility.php` | -| `datamachine/fetch-files` | Process uploaded files | `Fetch/FetchFilesAbility.php` | -| `datamachine/fetch-wordpress-api` | Fetch posts from WordPress REST API | `Fetch/FetchWordPressApiAbility.php` | -| `datamachine/fetch-wordpress-media` | Query WordPress media library | `Fetch/FetchWordPressMediaAbility.php` | -| `datamachine/get-wordpress-post` | Retrieve single WordPress post by ID/URL | `Fetch/GetWordPressPostAbility.php` | -| `datamachine/query-wordpress-posts` | Query WordPress posts with filters | `Fetch/QueryWordPressPostsAbility.php` | -| `datamachine/publish-wordpress` | Create WordPress posts | `Publish/PublishWordPressAbility.php` | -| `datamachine/update-wordpress` | Update existing WordPress posts | `Update/UpdateWordPressAbility.php` | +| `datamachine/fetch-rss` | Fetch items from RSS/Atom feeds | `inc/Abilities/Fetch/FetchRssAbility.php` | +| `datamachine/fetch-files` | Process uploaded files | `inc/Abilities/Fetch/FetchFilesAbility.php` | +| `datamachine/fetch-wordpress-api` | Fetch posts from WordPress REST API | `inc/Abilities/Fetch/FetchWordPressApiAbility.php` | +| `datamachine/fetch-wordpress-media` | Query WordPress media library | `inc/Abilities/Fetch/FetchWordPressMediaAbility.php` | +| `datamachine/get-wordpress-post` | Retrieve single WordPress post by ID/URL | `inc/Abilities/Fetch/GetWordPressPostAbility.php` | +| `datamachine/query-wordpress-posts` | Query WordPress posts with filters | `inc/Abilities/Fetch/QueryWordPressPostsAbility.php` | +| `datamachine/publish-wordpress` | Create WordPress posts | `inc/Abilities/Publish/PublishWordPressAbility.php` | +| `datamachine/update-wordpress` | Update existing WordPress posts | `inc/Abilities/Update/UpdateWordPressAbility.php` | | `datamachine/fetch-reddit` | Fetch posts from Reddit API | `Fetch/FetchRedditAbility.php` | ### Duplicate Check (2 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/check-duplicate` | Check if similar content exists as published post or in queue | `DuplicateCheck/DuplicateCheckAbility.php` | -| `datamachine/titles-match` | Compare two titles for semantic equivalence using similarity engine | `DuplicateCheck/DuplicateCheckAbility.php` | +| `datamachine/check-duplicate` | Check if similar content exists as published post or in queue | `inc/Abilities/DuplicateCheck/DuplicateCheckAbility.php` | +| `datamachine/titles-match` | Compare two titles for semantic equivalence using similarity engine | `inc/Abilities/DuplicateCheck/DuplicateCheckAbility.php` | ### Post Query (2 abilities) @@ -225,34 +225,34 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-post-blocks` | Get Gutenberg blocks from a post | `Content/GetPostBlocksAbility.php` | -| `datamachine/edit-post-blocks` | Update Gutenberg blocks in a post | `Content/EditPostBlocksAbility.php` | -| `datamachine/replace-post-blocks` | Replace specific blocks in a post | `Content/ReplacePostBlocksAbility.php` | +| `datamachine/get-post-blocks` | Get Gutenberg blocks from a post | `inc/Abilities/Content/GetPostBlocksAbility.php` | +| `datamachine/edit-post-blocks` | Update Gutenberg blocks in a post | `inc/Abilities/Content/EditPostBlocksAbility.php` | +| `datamachine/replace-post-blocks` | Replace specific blocks in a post | `inc/Abilities/Content/ReplacePostBlocksAbility.php` | ### Media (7 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/generate-alt-text` | Queue system agent generation of alt text for images | `Media/AltTextAbilities.php` | -| `datamachine/diagnose-alt-text` | Report alt text coverage for image attachments | `Media/AltTextAbilities.php` | -| `datamachine/generate-image` | Generate images using AI models via Replicate API | `Media/ImageGenerationAbilities.php` | -| `datamachine/upload-media` | Upload or fetch a media file (image/video), store in repository | `Media/MediaAbilities.php` | -| `datamachine/validate-media` | Validate a media file against platform-specific constraints | `Media/MediaAbilities.php` | -| `datamachine/video-metadata` | Extract video metadata (duration, resolution, codec) via ffprobe | `Media/MediaAbilities.php` | -| `datamachine/render-image-template` | Generate branded graphics from registered GD templates | `Media/ImageTemplateAbilities.php` | +| `datamachine/generate-alt-text` | Queue system agent generation of alt text for images | `inc/Abilities/Media/AltTextAbilities.php` | +| `datamachine/diagnose-alt-text` | Report alt text coverage for image attachments | `inc/Abilities/Media/AltTextAbilities.php` | +| `datamachine/generate-image` | Generate images using AI models via Replicate API | `inc/Abilities/Media/ImageGenerationAbilities.php` | +| `datamachine/upload-media` | Upload or fetch a media file (image/video), store in repository | `inc/Abilities/Media/MediaAbilities.php` | +| `datamachine/validate-media` | Validate a media file against platform-specific constraints | `inc/Abilities/Media/MediaAbilities.php` | +| `datamachine/video-metadata` | Extract video metadata (duration, resolution, codec) via ffprobe | `inc/Abilities/Media/MediaAbilities.php` | +| `datamachine/render-image-template` | Generate branded graphics from registered GD templates | `inc/Abilities/Media/ImageTemplateAbilities.php` | ### Image Templates (1 ability) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/list-image-templates` | List all registered image generation templates | `Media/ImageTemplateAbilities.php` | +| `datamachine/list-image-templates` | List all registered image generation templates | `inc/Abilities/Media/ImageTemplateAbilities.php` | ### Image Optimization (2 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/diagnose-images` | Scan media library for oversized images, missing WebP, missing thumbnails | `Media/ImageOptimizationAbilities.php` | -| `datamachine/optimize-images` | Compress oversized images and generate WebP variants | `Media/ImageOptimizationAbilities.php` | +| `datamachine/diagnose-images` | Scan media library for oversized images, missing WebP, missing thumbnails | `inc/Abilities/Media/ImageOptimizationAbilities.php` | +| `datamachine/optimize-images` | Compress oversized images and generate WebP variants | `inc/Abilities/Media/ImageOptimizationAbilities.php` | ### Internal Linking (6 abilities) @@ -269,36 +269,36 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/generate-meta-description` | Queue system agent generation of meta descriptions | `SEO/MetaDescriptionAbilities.php` | -| `datamachine/diagnose-meta-descriptions` | Report post excerpt (meta description) coverage | `SEO/MetaDescriptionAbilities.php` | +| `datamachine/generate-meta-description` | Queue system agent generation of meta descriptions | `inc/Abilities/SEO/MetaDescriptionAbilities.php` | +| `datamachine/diagnose-meta-descriptions` | Report post excerpt (meta description) coverage | `inc/Abilities/SEO/MetaDescriptionAbilities.php` | ### SEO — IndexNow (4 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/indexnow-submit` | Submit URLs to IndexNow for instant search engine indexing | `SEO/IndexNowAbilities.php` | -| `datamachine/indexnow-status` | Get IndexNow integration status (enabled, API key, endpoint) | `SEO/IndexNowAbilities.php` | -| `datamachine/indexnow-generate-key` | Generate a new IndexNow API key | `SEO/IndexNowAbilities.php` | -| `datamachine/indexnow-verify-key` | Verify that the IndexNow key file is accessible | `SEO/IndexNowAbilities.php` | +| `datamachine/indexnow-submit` | Submit URLs to IndexNow for instant search engine indexing | `inc/Abilities/SEO/IndexNowAbilities.php` | +| `datamachine/indexnow-status` | Get IndexNow integration status (enabled, API key, endpoint) | `inc/Abilities/SEO/IndexNowAbilities.php` | +| `datamachine/indexnow-generate-key` | Generate a new IndexNow API key | `inc/Abilities/SEO/IndexNowAbilities.php` | +| `datamachine/indexnow-verify-key` | Verify that the IndexNow key file is accessible | `inc/Abilities/SEO/IndexNowAbilities.php` | ### Analytics (4 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/bing-webmaster` | Fetch search analytics from Bing Webmaster Tools API | `Analytics/BingWebmasterAbilities.php` | -| `datamachine/google-search-console` | Fetch search analytics from Google Search Console API | `Analytics/GoogleSearchConsoleAbilities.php` | -| `datamachine/google-analytics` | Fetch visitor analytics from Google Analytics (GA4) Data API | `Analytics/GoogleAnalyticsAbilities.php` | -| `datamachine/pagespeed` | Run Lighthouse audits via PageSpeed Insights API | `Analytics/PageSpeedAbilities.php` | +| `datamachine/bing-webmaster` | Fetch search analytics from Bing Webmaster Tools API | `inc/Abilities/Analytics/BingWebmasterAbilities.php` | +| `datamachine/google-search-console` | Fetch search analytics from Google Search Console API | `inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php` | +| `datamachine/google-analytics` | Fetch visitor analytics from Google Analytics (GA4) Data API | `inc/Abilities/Analytics/GoogleAnalyticsAbilities.php` | +| `datamachine/pagespeed` | Run Lighthouse audits via PageSpeed Insights API | `inc/Abilities/Analytics/PageSpeedAbilities.php` | ### Taxonomy (5 abilities) | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/get-taxonomy-terms` | List taxonomy terms | `Taxonomy/GetTaxonomyTermsAbility.php` | -| `datamachine/create-taxonomy-term` | Create a taxonomy term | `Taxonomy/CreateTaxonomyTermAbility.php` | -| `datamachine/update-taxonomy-term` | Update a taxonomy term | `Taxonomy/UpdateTaxonomyTermAbility.php` | -| `datamachine/delete-taxonomy-term` | Delete a taxonomy term | `Taxonomy/DeleteTaxonomyTermAbility.php` | -| `datamachine/resolve-term` | Resolve a term by name or slug | `Taxonomy/ResolveTermAbility.php` | +| `datamachine/get-taxonomy-terms` | List taxonomy terms | `inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php` | +| `datamachine/create-taxonomy-term` | Create a taxonomy term | `inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php` | +| `datamachine/update-taxonomy-term` | Update a taxonomy term | `inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php` | +| `datamachine/delete-taxonomy-term` | Delete a taxonomy term | `inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php` | +| `datamachine/resolve-term` | Resolve a term by name or slug | `inc/Abilities/Taxonomy/ResolveTermAbility.php` | ### Settings (7 abilities) @@ -365,7 +365,7 @@ Internal abilities for the pipeline execution engine. | Ability | Description | Location | |---------|-------------|----------| -| `datamachine/send-ping` | Send agent ping notification | `AgentPing/SendPingAbility.php` | +| `datamachine/send-ping` | Send agent ping notification | `inc/Abilities/AgentPing/SendPingAbility.php` | ### System Infrastructure (4 abilities) @@ -423,9 +423,9 @@ Note: many ability implementations are already self-contained and do not call se Several top-level ability classes serve as facades that instantiate sub-ability classes from subdirectories: -- `ChatAbilities.php` → `Chat/CreateChatSessionAbility.php`, etc. -- `EngineAbilities.php` → `Engine/RunFlowAbility.php`, etc. -- `FlowAbilities.php` → `Flow/QueueAbility.php`, `Flow/WebhookTriggerAbility.php`, etc. +- `ChatAbilities.php` → `inc/Abilities/Chat/CreateChatSessionAbility.php`, etc. +- `EngineAbilities.php` → `inc/Abilities/Engine/RunFlowAbility.php`, etc. +- `FlowAbilities.php` → `inc/Abilities/Flow/QueueAbility.php`, `inc/Abilities/Flow/WebhookTriggerAbility.php`, etc. ### Ability Registration diff --git a/docs/core-system/ai-directives.md b/docs/core-system/ai-directives.md index 3380b7c44..023f1a34a 100644 --- a/docs/core-system/ai-directives.md +++ b/docs/core-system/ai-directives.md @@ -48,7 +48,6 @@ Directives are applied in ascending priority order (lowest number = highest prio ### PipelineCoreDirective (Priority 10) -**Location**: `inc/Core/Steps/AI/Directives/PipelineCoreDirective.php` **Contexts**: Pipeline only **Purpose**: Establishes foundational agent identity for pipeline AI agents @@ -60,7 +59,6 @@ Provides the static core directive covering: ### ChatAgentDirective (Priority 15) -**Location**: `inc/Api/Chat/ChatAgentDirective.php` **Contexts**: Chat only **Since**: 0.2.0 **Purpose**: Defines chat agent identity and capabilities @@ -75,7 +73,6 @@ Provides comprehensive behavioral instructions for the chat interface: ### SystemAgentDirective (Priority 20) -**Location**: `inc/Api/System/SystemAgentDirective.php` **Contexts**: System only **Since**: 0.13.7 **Purpose**: Defines system agent identity for internal operations diff --git a/docs/core-system/system-tasks.md b/docs/core-system/system-tasks.md index c195fca7f..cb3b6de41 100644 --- a/docs/core-system/system-tasks.md +++ b/docs/core-system/system-tasks.md @@ -355,7 +355,6 @@ Compresses oversized images and generates WebP variants using WordPress's native ### GitHubIssueTask **Type:** `github_create_issue` -**Source:** `inc/Engine/AI/System/Tasks/GitHubIssueTask.php` **Undo:** No The simplest task — creates GitHub issues by delegating entirely to `GitHubAbilities::createIssue()`. Validates the result and completes or fails the job. No prompt building, no normalization. diff --git a/docs/core-system/wordpress-as-agent-memory.md b/docs/core-system/wordpress-as-agent-memory.md index 9862b567b..65547de1b 100644 --- a/docs/core-system/wordpress-as-agent-memory.md +++ b/docs/core-system/wordpress-as-agent-memory.md @@ -24,11 +24,10 @@ Four layers, each serving a different purpose: ### 1. Agent Files — Identity and Knowledge -**Location:** Three-layer directory system under `wp-content/uploads/datamachine-files/`: | Layer | Directory | Contents | |-------|-----------|----------| -| **Shared** (site-wide) | `shared/` | `SITE.md`, `RULES.md` — site-level context shared by all agents | +| **Shared** (site-wide) | `inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/` | `SITE.md`, `RULES.md` — site-level context shared by all agents | | **Agent** (identity + knowledge) | `agents/{agent_slug}/` | `SOUL.md`, `MEMORY.md`, `daily/` — agent-specific identity and knowledge | | **User** (personal) | `users/{user_id}/` | `USER.md` — user preferences that follow the human across agents | diff --git a/docs/core-system/workspace-system.md b/docs/core-system/workspace-system.md index 7a903183e..6f46e3ddc 100644 --- a/docs/core-system/workspace-system.md +++ b/docs/core-system/workspace-system.md @@ -30,7 +30,6 @@ The workspace directory is outside the WordPress web root. It's created on first ## Workspace Service -**Source:** `inc/Core/FilesRepository/Workspace.php` **Since:** v0.30.0 The `Workspace` class is the core service handling repository management, Git operations, and path security. @@ -85,7 +84,6 @@ The Workspace class enforces several security measures: ## WorkspaceReader -**Source:** `inc/Core/FilesRepository/WorkspaceReader.php` Read-only file operations within workspace repos. @@ -108,7 +106,6 @@ Returns `{success, repo, path, entries[]}`. ## WorkspaceWriter -**Source:** `inc/Core/FilesRepository/WorkspaceWriter.php` Write and edit operations within workspace repos. @@ -130,7 +127,6 @@ Returns `{success, file, occurrences, message}`. ## Abilities -**Source:** `inc/Abilities/WorkspaceAbilities.php` Sixteen abilities registered under the `datamachine` category, split into read-only and mutating operations: @@ -168,7 +164,6 @@ Read-only abilities have `show_in_rest: true`. Mutating abilities have `show_in_ > **Note:** WorkspaceTools have been moved to the `data-machine-code` extension plugin. -**Source:** `inc/Engine/AI/Tools/Global/WorkspaceTools.php` (in extension) **Tool ID:** Various (`workspace_path`, `workspace_list`, `workspace_show`, `workspace_ls`, `workspace_read`) **Contexts:** `chat`, `pipeline`, `standalone` @@ -186,7 +181,6 @@ These tools are available whenever the workspace is configured (`is_configured() ### Scoped Tools (WorkspaceScopedTools) -**Source:** `inc/Core/Steps/Workspace/Tools/WorkspaceScopedTools.php` Handler-scoped tools registered by the Workspace fetch and publish handlers. These tools enforce per-handler path allowlists — operations are restricted to the paths configured in the handler settings. @@ -209,7 +203,6 @@ All operations validate paths against the handler's configured allowlist before ### Fetch Handler -**Source:** `inc/Core/Steps/Fetch/Handlers/Workspace/Workspace.php` Reads data from workspace repositories as a pipeline fetch source. Configured with: @@ -226,7 +219,6 @@ The fetch handler produces structured JSON data packets and registers scoped `wo ### Publish Handler -**Source:** `inc/Core/Steps/Publish/Handlers/Workspace/Workspace.php` Writes data to workspace repositories as a pipeline publish target. Configured with: @@ -246,7 +238,6 @@ The publish handler registers scoped tools for writing, editing, and Git operati ## CLI -**Source:** `inc/Cli/Commands/WorkspaceCommand.php` ### Repository Management diff --git a/docs/development/hooks/core-actions.md b/docs/development/hooks/core-actions.md index 14a29683d..77ec61d5f 100644 --- a/docs/development/hooks/core-actions.md +++ b/docs/development/hooks/core-actions.md @@ -2,7 +2,6 @@ Comprehensive reference for all WordPress actions used by Data Machine for pipeline execution, data processing, and system operations. -**Note**: Most core operations use the Abilities API (`DataMachine\Abilities`) for direct method calls. These actions remain primarily for extensibility and backward compatibility. ## Abilities API Integration @@ -364,4 +363,3 @@ Input data is sanitized before processing: $pipeline_name = sanitize_text_field($data['pipeline_name'] ?? ''); $config_json = wp_json_encode($config_data); ``` - diff --git a/docs/overview.md b/docs/overview.md index d01e67240..ef43f3487 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -49,7 +49,6 @@ Data Machine supports **multiple agents on a single WordPress installation** (@s - **Access Control**: The `datamachine_agent_access` table implements role-based access (viewer, operator, admin) for sharing agents across WordPress users. - **Resource Scoping**: All major resources (pipelines, flows, jobs, chat sessions) carry an `agent_id` column. Queries filter by agent context automatically. - **Filesystem Isolation**: Each agent gets its own directory under `agents/{slug}/` for identity files (SOUL.md, MEMORY.md) and daily memory. -- **Three-Layer Directory System**: Memory files are organized into shared (site-wide), agent (identity), and user (personal) layers under `wp-content/uploads/datamachine-files/`. See [Multi-Agent Architecture](core-system/wordpress-as-agent-memory.md#multi-agent-architecture) for details. @@ -63,7 +62,7 @@ Markdown files organized in three layers: | Layer | Directory | Contents | |-------|-----------|----------| -| **Shared** | `shared/` | SITE.md, RULES.md (site-wide context) | +| **Shared** | `inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/` | SITE.md, RULES.md (site-wide context) | | **Agent** | `agents/{slug}/` | SOUL.md, MEMORY.md (agent identity and knowledge) | | **User** | `users/{id}/` | USER.md, MEMORY.md (human preferences) | diff --git a/homeboy.json b/homeboy.json index 996fb7d03..f5d309df6 100644 --- a/homeboy.json +++ b/homeboy.json @@ -1,6 +1,1360 @@ { "auto_cleanup": false, "baselines": { + "audit": { + "context_id": "data-machine", + "created_at": "2026-03-28T15:42:49Z", + "item_count": 1296, + "known_fingerprints": [ + "Abilities::inc/Abilities/InternalLinkingAbilities.php::MissingMethod", + "Abilities::inc/Abilities/InternalLinkingAbilities.php::MissingRegistration", + "Abilities::inc/Abilities/PermissionHelper.php::MissingImport", + "Abilities::inc/Abilities/PermissionHelper.php::NamingMismatch", + "Agents::inc/Core/Database/Agents/Agents.php::MissingImport", + "Analytics::inc/Abilities/Analytics/PageSpeedAbilities.php::MissingMethod", + "Api::inc/Api/Email.php::SignatureMismatch", + "Api::inc/Api/Execute.php::MissingMethod", + "Api::inc/Api/Handlers.php::MissingMethod", + "Api::inc/Api/Providers.php::MissingMethod", + "Api::inc/Api/StepTypes.php::MissingMethod", + "Api::inc/Api/Tools.php::MissingMethod", + "Api::inc/Api/WebhookTrigger.php::MissingMethod", + "Auth::inc/Core/Auth/AgentAuthMiddleware.php::MissingMethod", + "Chat::inc/Abilities/Chat/ChatSessionHelpers.php::NamingMismatch", + "Content::inc/Abilities/Content/BlockSanitizer.php::NamingMismatch", + "Content::inc/Abilities/Content/CanonicalDiffPreview.php::NamingMismatch", + "Content::inc/Abilities/Content/PendingDiffStore.php::NamingMismatch", + "Directives::inc/Engine/AI/Directives/DirectiveOutputValidator.php::MissingMethod", + "Directives::inc/Engine/AI/Directives/DirectiveRenderer.php::MissingMethod", + "Directives::inc/Engine/AI/Directives/MemoryFilesReader.php::MissingMethod", + "Email::inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php::MissingMethod", + "Engine::inc/Abilities/Engine/EngineHelpers.php::NamingMismatch", + "Engine::inc/Abilities/Engine/PipelineBatchScheduler.php::NamingMismatch", + "File::inc/Abilities/File/FileConstants.php::NamingMismatch", + "Flow::inc/Abilities/Flow/FlowHelpers.php::NamingMismatch", + "Flow::inc/Abilities/Flow/QueueAbility.php::MissingMethod", + "Flow::inc/Abilities/Flow/QueueAbility.php::MissingMethod", + "Flow::inc/Abilities/Flow/QueueAbility.php::MissingMethod", + "Flow::inc/Abilities/Flow/QueueAbility.php::MissingRegistration", + "Flow::inc/Abilities/Flow/WebhookTriggerAbility.php::MissingMethod", + "Flow::inc/Abilities/Flow/WebhookTriggerAbility.php::MissingMethod", + "FlowStep::inc/Abilities/FlowStep/FlowStepHelpers.php::NamingMismatch", + "Flows::inc/Cli/Commands/Flows/FlowsCommand.php::MissingMethod", + "Global::inc/Engine/AI/Tools/Global/AgentMemory.php::NamespaceMismatch", + "Job::inc/Abilities/Job/JobHelpers.php::NamingMismatch", + "Pipeline::inc/Abilities/Pipeline/ImportExportAbility.php::MissingMethod", + "Pipeline::inc/Abilities/Pipeline/ImportExportAbility.php::MissingMethod", + "Pipeline::inc/Abilities/Pipeline/PipelineHelpers.php::NamingMismatch", + "Tools::inc/Api/Chat/Tools/SchedulingDocumentation.php::MissingMethod", + "Tools::inc/Api/Chat/Tools/SchedulingDocumentation.php::MissingMethod", + "Tools::inc/Api/Chat/Tools/SchedulingDocumentation.php::MissingMethod", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine.php::UnreferencedExport", + "dead_code::data-machine/datamachine_load.php::UnreferencedExport", + "dead_code::data-machine/datamachine_load.php::UnreferencedExport", + "dead_code::data-machine/datamachine_load.php::UnreferencedExport", + "dead_code::inc/Abilities/AgentAbilities.php::UnusedParameter", + "dead_code::inc/Abilities/AgentPingAbilities.php::UnreferencedExport", + "dead_code::inc/Abilities/Content/BlockSanitizer.php::UnreferencedExport", + "dead_code::inc/Abilities/Content/ResolveDiffAbility.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/connect.php::UnusedParameter", + "dead_code::inc/Abilities/Email/EmailAbilities/helpers.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/helpers.php::UnreferencedExport", + "dead_code::inc/Abilities/Email/EmailAbilities/helpers.php::UnreferencedExport", + "dead_code::inc/Abilities/File/ScaffoldAbilities.php::UnreferencedExport", + "dead_code::inc/Abilities/File/ScaffoldAbilities.php::UnreferencedExport", + "dead_code::inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php::UnreferencedExport", + "dead_code::inc/Abilities/Job/JobsSummaryAbility.php::UnusedParameter", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnreferencedExport", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnusedParameter", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnusedParameter", + "dead_code::inc/Abilities/Media/GDRenderer.php::UnusedParameter", + "dead_code::inc/Abilities/SEO/IndexNowAbilities.php::UnusedParameter", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/AgentFiles.php::UnreferencedExport", + "dead_code::inc/Api/Agents.php::UnusedParameter", + "dead_code::inc/Api/Agents.php::UnusedParameter", + "dead_code::inc/Api/Chat/ChatFilters.php::UnreferencedExport", + "dead_code::inc/Api/Chat/ChatPipelinesDirective.php::UnusedParameter", + "dead_code::inc/Api/Chat/ChatPipelinesDirective.php::UnusedParameter", + "dead_code::inc/Api/Chat/ChatPipelinesDirective.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/AddPipelineStep.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ApiQuery.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ApiQuery.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/AssignTaxonomyTerm.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/AuthenticateHandler.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ConfigureFlowSteps.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ConfigurePipelineStep.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/CopyFlow.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/CreateFlow.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/CreatePipeline.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/CreateTaxonomyTerm.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/DeleteFile.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/DeleteFlow.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/DeletePipeline.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/DeletePipelineStep.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ExecuteWorkflowTool.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/GetHandlerDefaults.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/GetProblemFlows.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ListFlows.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ManageJobs.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ManageLogs.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ManageQueue.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/MergeTaxonomyTerms.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ReadLogs.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/ReorderPipelineSteps.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/RunFlow.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/SearchTaxonomyTerms.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/SendPing.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/SetHandlerDefaults.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/SystemHealthCheck.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/UpdateFlow.php::UnusedParameter", + "dead_code::inc/Api/Chat/Tools/UpdateTaxonomyTerm.php::UnusedParameter", + "dead_code::inc/Api/FlowFiles.php::UnreferencedExport", + "dead_code::inc/Api/FlowFiles.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/FlowQueue.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle_bulk.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle_bulk.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle_get.php::UnreferencedExport", + "dead_code::inc/Api/Flows/Flows/handle_get.php::UnreferencedExport", + "dead_code::inc/Api/Pipelines/PipelineFlows.php::UnreferencedExport", + "dead_code::inc/Api/Providers.php::UnreferencedExport", + "dead_code::inc/Api/StepTypes.php::UnreferencedExport", + "dead_code::inc/Api/StepTypes.php::UnreferencedExport", + "dead_code::inc/Api/Users.php::UnreferencedExport", + "dead_code::inc/Api/Users.php::UnreferencedExport", + "dead_code::inc/Api/Users.php::UnreferencedExport", + "dead_code::inc/Api/Users.php::UnreferencedExport", + "dead_code::inc/Api/Users.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/AgentsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/AltTextCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/AltTextCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/AnalyticsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/AnalyticsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/AnalyticsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/AuthCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/AuthCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/BatchCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/BatchCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/BatchCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/BatchCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/BlocksCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/BlocksCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/ChatCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ChatCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/EmailCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ExternalCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/ExternalCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ExternalCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/Flows/BulkConfigCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/Flows/QueueCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/Flows/WebhookCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/HandlersCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ImageCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/IndexNowCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/JobsCommand/delete.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/JobsCommand/delete.php::UnusedParameter", + "dead_code::inc/Cli/Commands/JobsCommand/delete.php::UnusedParameter", + "dead_code::inc/Cli/Commands/JobsCommand/helpers.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/JobsCommand/helpers.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/JobsCommand/helpers.php::UnusedParameter", + "dead_code::inc/Cli/Commands/JobsCommand/show.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/JobsCommand/show.php::UnusedParameter", + "dead_code::inc/Cli/Commands/JobsCommand/show.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LinksCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LogsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LogsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/LogsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/MemoryCommand/agent_files_multi.php::UnusedParameter", + "dead_code::inc/Cli/Commands/MemoryCommand/daily.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/MemoryCommand/sections.php::UnusedParameter", + "dead_code::inc/Cli/Commands/MetaDescriptionCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/MetaDescriptionCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/PostsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ProcessedItemsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ProcessedItemsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ProcessedItemsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/ProcessedItemsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/RetentionCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/RetentionCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/SettingsCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SettingsCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/StepTypesCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/StepTypesCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/StepTypesCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/SystemCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/TaxonomyCommand.php::UnreferencedExport", + "dead_code::inc/Cli/Commands/TaxonomyCommand.php::UnusedParameter", + "dead_code::inc/Cli/Commands/TaxonomyCommand.php::UnusedParameter", + "dead_code::inc/Core/ActionScheduler/QueueTuning.php::UnreferencedExport", + "dead_code::inc/Core/ActionScheduler/QueueTuning.php::UnreferencedExport", + "dead_code::inc/Core/Admin/AdminRootFilters.php::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Jobs/JobsFilters.php::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Logs/assets/react/components/LogsAgentTabs.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/PipelinesApp.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/chat/ChatSidebar.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/chat/ChatSidebar.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/chat/ChatToggle.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/EmptyFlowCard.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/FlowCard.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/FlowCard.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/FlowFooter.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/FlowMemoryFiles.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/ConfigureStepModal.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/ContextFilesModal.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/FlowMemoryFilesModal.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/MemoryFilesModal.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/configure-step/ConfigurationWarning.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/handler-settings/files/AutoCleanupOption.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/handler-settings/files/FileStatusTable.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/handler-settings/files/FileUploadInterface.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/import-export/ExportTab.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/import-export/ImportTab.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/oauth/AccountDetails.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/oauth/ConnectionStatus.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/modals/oauth/RedirectUrlDisplay.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/EmptyStepCard.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineContextFiles.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineMemoryFiles.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineSelector.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/DailyMemorySelector.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/DataFlowArrow.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/LoadingSpinner.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/ModalManager.jsx::UnreferencedExport", + "dead_code::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/ModalSwitch.jsx::UnreferencedExport", + "dead_code::inc/Core/Auth/AgentAuthorize.php::UnreferencedExport", + "dead_code::inc/Core/Auth/AgentAuthorize.php::UnreferencedExport", + "dead_code::inc/Core/Database/Jobs/JobsOperations.php::UnreferencedExport", + "dead_code::inc/Core/Database/Jobs/JobsOperations.php::UnusedParameter", + "dead_code::inc/Core/OAuth/BaseOAuth2Provider.php::UnusedParameter", + "dead_code::inc/Core/OAuth/OAuth2Handler.php::UnreferencedExport", + "dead_code::inc/Core/OAuth/OAuth2Handler.php::UnreferencedExport", + "dead_code::inc/Core/Similarity/SimilarityEngine.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/PipelineSystemPromptDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/AI/Directives/PipelineSystemPromptDirective.php::UnusedParameter", + "dead_code::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::UnusedParameter", + "dead_code::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::UnusedParameter", + "dead_code::inc/Core/Steps/Fetch/Handlers/WordPress/WordPressSettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/Fetch/Handlers/WordPressAPI/WordPressAPISettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMediaSettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/Publish/Handlers/Email/EmailSettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/Publish/Handlers/PublishHandler.php::UnusedParameter", + "dead_code::inc/Core/Steps/Publish/Handlers/WordPress/WordPressSettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/Update/Handlers/WordPress/WordPress.php::UnusedParameter", + "dead_code::inc/Core/Steps/Update/Handlers/WordPress/WordPress.php::UnusedParameter", + "dead_code::inc/Core/Steps/Update/Handlers/WordPress/WordPressSettings.php::UnusedParameter", + "dead_code::inc/Core/Steps/WebhookGate/WebhookGateStep.php::UnreferencedExport", + "dead_code::inc/Core/WordPress/SiteContext.php::UnreferencedExport", + "dead_code::inc/Core/WordPress/SiteContext.php::UnreferencedExport", + "dead_code::inc/Core/WordPress/WordPressFilters.php::UnreferencedExport", + "dead_code::inc/Engine/AI/Directives/ClientContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/ClientContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/ClientContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/CoreMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/CoreMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/CoreMemoryFilesDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/DailyMemorySelectorDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/DailyMemorySelectorDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/DailyMemorySelectorDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/SiteContextDirective.php::UnreferencedExport", + "dead_code::inc/Engine/AI/Directives/SiteContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/SiteContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/SiteContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/Directives/SiteContextDirective.php::UnusedParameter", + "dead_code::inc/Engine/AI/System/Tasks/ImageOptimizationTask.php::UnusedParameter", + "dead_code::inc/Engine/AI/System/Tasks/ImageOptimizationTask.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/BaseTool.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/AgentDailyMemory.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/AgentMemory.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/BingWebmaster.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/GoogleAnalytics.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/GoogleAnalytics.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/GoogleSearch.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/GoogleSearchConsole.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/GoogleSearchConsole.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/ImageGeneration.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/InternalLinkAudit.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/LocalSearch.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/PageSpeed.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/QueueValidator.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/WebFetch.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/Global/WordPressPostReader.php::UnusedParameter", + "dead_code::inc/Engine/AI/Tools/ToolManager.php::UnusedParameter", + "dead_code::inc/Engine/Actions/Engine.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Admin.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Admin.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Admin.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Admin.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Admin.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/DataMachineFilters.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/Handlers.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/OAuth.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/SchedulerIntervals.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/SchedulerIntervals.php::UnreferencedExport", + "dead_code::inc/Engine/Filters/SchedulerIntervals.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/Engine/Logger.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_ensure.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_register.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_register.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnreferencedExport", + "dead_code::inc/migrations/datamachine_scaffold.php::UnusedParameter", + "dead_code::inc/migrations/datamachine_scaffold.php::UnusedParameter", + "dead_code::tests/Unit/AI/System/Tasks/ImageGenerationTaskTest.php::UnusedParameter", + "dead_code::tests/Unit/AI/System/Tasks/ImageGenerationTaskTest.php::UnusedParameter", + "dead_code::tests/Unit/AI/System/Tasks/ImageGenerationTaskTest.php::UnusedParameter", + "dead_code::tests/Unit/AI/Tools/ToolExecutorValidationTest.php::UnusedParameter", + "dead_code::tests/Unit/AI/Tools/ToolPolicyResolverTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/ImageGenerationPromptRefinementTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/ImageGenerationPromptRefinementTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/ImageGenerationPromptRefinementTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/ImageGenerationPromptRefinementTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/JobAbilitiesTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/JobAbilitiesTest.php::UnusedParameter", + "dead_code::tests/Unit/Abilities/JobAbilitiesTest.php::UnusedParameter", + "dead_code::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::UnusedParameter", + "dead_code::uninstall.php::UnreferencedExport", + "dead_code::uninstall.php::UnreferencedExport", + "dead_code::uninstall.php::UnreferencedExport", + "docs::docs/api/endpoints/files.md::BrokenDocReference", + "docs::docs/architecture.md::BrokenDocReference", + "docs::docs/architecture.md::BrokenDocReference", + "docs::docs/architecture.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/abilities-api.md::BrokenDocReference", + "docs::docs/core-system/daily-memory-system.md::BrokenDocReference", + "docs::docs/core-system/multi-agent-architecture.md::BrokenDocReference", + "docs::docs/core-system/multi-agent-architecture.md::BrokenDocReference", + "docs::docs/core-system/system-tasks.md::BrokenDocReference", + "docs::docs/core-system/wordpress-as-agent-memory.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/core-system/workspace-system.md::BrokenDocReference", + "docs::docs/overview.md::BrokenDocReference", + "duplication::inc/Abilities/Analytics/GoogleAnalyticsAbilities.php::DuplicateFunction", + "duplication::inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php::DuplicateFunction", + "duplication::inc/Abilities/File/AgentFileAbilities.php::DuplicateFunction", + "duplication::inc/Core/OAuth/OAuth1Handler.php::DuplicateFunction", + "duplication::inc/Core/OAuth/OAuth1Handler.php::DuplicateFunction", + "duplication::inc/Core/OAuth/OAuth2Handler.php::DuplicateFunction", + "duplication::inc/Core/OAuth/OAuth2Handler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Publish/Handlers/PublishHandler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Publish/Handlers/PublishHandler.php::DuplicateFunction", + "duplication::inc/Core/Steps/Publish/Handlers/PublishHandler.php::DuplicateFunction", + "duplication::inc/Engine/AI/MemoryFileRegistry.php::DuplicateFunction", + "field_patterns::inc/Core/Auth/AgentAuthCallback.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Auth/AgentAuthorize.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/AI/AIStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/AI/AIStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/AgentPing/AgentPingStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/AgentPing/AgentPingStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/AgentPing/AgentPingStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/FetchStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/Email/Email.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/Files/Files.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/Rss/Rss.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/WordPressAPI/WordPressAPI.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Publish/Handlers/Email/Email.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Publish/Handlers/WordPress/WordPress.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Publish/PublishStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/SystemTask/SystemTaskStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/SystemTask/SystemTaskStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/SystemTask/SystemTaskStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Update/Handlers/WordPress/WordPress.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/Update/UpdateStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/WebhookGate/WebhookGateStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/WebhookGate/WebhookGateStep.php::RepeatedFieldPattern", + "field_patterns::inc/Core/Steps/WebhookGate/WebhookGateStep.php::RepeatedFieldPattern", + "intra-method-duplication::data-machine/datamachine.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/AgentAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/AgentAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/AgentMemoryAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/DailyMemoryAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Email/EmailAbilities/helpers.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Engine/ExecuteStepAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchEmailAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchEmailAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchEmailAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchFilesAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchRssAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchWordPressApiAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Fetch/FetchWordPressMediaAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Flow/CreateFlowAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Flow/GetFlowsAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Flow/WebhookTriggerAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/FlowStep/ConfigureFlowStepsAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/FlowStep/FlowStepHelpers.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/FlowStep/UpdateFlowStepAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/FlowStep/ValidateFlowStepsConfigAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/HandlerAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/InternalLinkingAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/InternalLinkingAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/InternalLinkingAbilities/registerAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Job/ExecuteWorkflowAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Job/ExecuteWorkflowAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Job/GetJobsAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Job/RecoverStuckJobsAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/LogAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Media/AltTextAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Media/AltTextAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Media/ImageOptimizationAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Media/ImageTemplateAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Media/TemplateRegistry.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Pipeline/GetPipelinesAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/PostQueryAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/SEO/MetaDescriptionAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/SettingsAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/SystemAbilities.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/AgentFiles.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/AgentPing.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Agents.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Auth.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Chat/Chat.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Chat/Tools/ConfigureFlowSteps.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Chat/Tools/CreateFlow.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Chat/Tools/GetHandlerDefaults.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Email.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/FlowFiles.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Flows/FlowQueue.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Flows/FlowScheduling.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Flows/FlowSteps.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Flows/Flows/register.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Handlers.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Jobs.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Logs.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Pipelines/PipelineSteps.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Pipelines/Pipelines.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Pipelines/Pipelines.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Settings.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Users.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/Users.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Api/WebhookTrigger.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/ExternalCommand.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/Flows/BulkConfigCommand.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/Flows/FlowsCommand/helpers.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/MetaDescriptionCommand.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Cli/Commands/PipelinesCommand.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Admin/Pages/Pipelines/assets/react/PipelinesApp.jsx::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Admin/Pages/Pipelines/assets/react/components/flows/FlowCard.jsx::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/DailyMemorySelector.jsx::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Admin/Pages/Pipelines/assets/react/components/shared/ModalManager.jsx::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Admin/shared/components/AgentSwitcher.jsx::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Auth/AgentAuthCallback.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Auth/AgentAuthorize.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Database/Jobs/Jobs.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/FilesRepository/FileStorage.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/FilesRepository/FileStorage.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/OAuth/BaseOAuth2Provider.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Steps/QueueableTrait.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Steps/SystemTask/SystemTaskStep.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Core/Steps/Update/Handlers/WordPress/WordPress.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/AI/Directives/CoreMemoryFilesDirective.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/AI/PromptBuilder.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/AI/System/Tasks/DailyMemoryTask.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/AI/System/Tasks/ImageGenerationTask.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Actions/Engine.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Actions/Handlers/FailJobHandler.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Actions/Handlers/MarkItemProcessedHandler.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Filters/DataMachineFilters.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Filters/SchedulerIntervals.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Tasks/TaskScheduler.php::IntraMethodDuplicate", + "intra-method-duplication::inc/Engine/Tasks/TaskScheduler.php::IntraMethodDuplicate", + "intra-method-duplication::tests/Unit/Abilities/PipelineStepAbilitiesTest.php::IntraMethodDuplicate", + "intra-method-duplication::tests/Unit/Abilities/PostQueryAbilitiesTest.php::IntraMethodDuplicate", + "intra-method-duplication::tests/Unit/Cli/BulkConfigCommandTest.php::IntraMethodDuplicate", + "parallel-implementation::inc/Abilities/Flow/FlowHelpers.php::ParallelImplementation", + "parallel-implementation::inc/Abilities/Flow/QueueAbility/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Abilities/Flow/QueueAbility/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Abilities/PipelineStepAbilities.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/AuthCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/BlocksCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/BlocksCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ChatCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/EmailCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/Flows/WebhookCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/HandlersCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ImageCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/JobsCommand/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/JobsCommand/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/JobsCommand/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/JobsCommand/helpers.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/LinksCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/daily.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/daily.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/daily.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/sections.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/write.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/MemoryCommand/write.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ProcessedItemsCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ProcessedItemsCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ProcessedItemsCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ProcessedItemsCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/ProcessedItemsCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/SystemCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/SystemCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/SystemCommand.php::ParallelImplementation", + "parallel-implementation::inc/Cli/Commands/TestCommand.php::ParallelImplementation", + "parallel-implementation::inc/Core/Admin/Pages/Logs/assets/react/components/LogsAgentTabs.jsx::ParallelImplementation", + "parallel-implementation::inc/Core/Admin/shared/components/AgentSwitcher.jsx::ParallelImplementation", + "parallel-implementation::inc/Core/Database/Agents/AgentAccess.php::ParallelImplementation", + "parallel-implementation::inc/Core/Database/Agents/AgentTokens.php::ParallelImplementation", + "parallel-implementation::inc/Core/FilesRepository/DailyMemory.php::ParallelImplementation", + "parallel-implementation::inc/Core/FilesRepository/DirectoryManager.php::ParallelImplementation", + "parallel-implementation::inc/Engine/AI/System/Tasks/ImageGenerationTask.php::ParallelImplementation", + "parallel-implementation::inc/Engine/AI/System/Tasks/ImageOptimizationTask.php::ParallelImplementation", + "structural::inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php::GodFile", + "structural::inc/Api/Chat/Tools::DirectorySprawl", + "structural::inc/Cli/Commands::DirectorySprawl", + "structural::inc/Core/Admin/Pages/Pipelines/assets/react/queries/flows.js::HighItemCount", + "structural::inc/Core/Admin/Pages/Pipelines/assets/react/utils/api.js::HighItemCount", + "structural::inc/migrations/datamachine.php::GodFile", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AgentMemoryAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/AgentPingAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/AgentTokenAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Analytics/BingWebmasterAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Analytics/GoogleAnalyticsAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Analytics/PageSpeedAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/AuthAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/Chat/ChatSessionHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Chat/CreateChatSessionAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Chat/DeleteChatSessionAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Chat/GetChatSessionAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Chat/ListChatSessionsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/ChatAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/BlockSanitizer.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/CanonicalDiffPreview.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/EditPostBlocksAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/GetPostBlocksAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/InsertContentAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/PendingDiffStore.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/ReplacePostBlocksAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Content/ResolveDiffAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/DailyMemoryAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Email/EmailAbilities/connect.php::MissingTestFile", + "test_coverage::inc/Abilities/Email/EmailAbilities/helpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/EngineHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/ExecuteStepAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/PipelineBatchScheduler.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/RunFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/ScheduleFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Engine/ScheduleNextStepAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/EngineAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/FetchEmailAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/FetchFilesAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/FetchRssAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/FetchWordPressApiAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/FetchWordPressMediaAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/GetWordPressPostAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Fetch/QueryWordPressPostsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/File/AgentFileAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/File/FileConstants.php::MissingTestFile", + "test_coverage::inc/Abilities/File/FlowFileAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/File/ScaffoldAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/CreateFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/DeleteFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/DuplicateFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/FlowHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/GetFlowsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/PauseFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/QueueAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/QueueAbility/helpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/ResumeFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/UpdateFlowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Flow/WebhookTriggerAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowStep/ConfigureFlowStepsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowStep/FlowStepHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowStep/GetFlowStepsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowStep/UpdateFlowStepAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowStep/ValidateFlowStepsConfigAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/FlowStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/FlowStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/HandlerAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/InternalLinkingAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php::MissingTestFile", + "test_coverage::inc/Abilities/InternalLinkingAbilities/registerAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/DeleteJobsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/ExecuteWorkflowAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/FailJobAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/FlowHealthAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/GetJobsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/JobHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/JobsSummaryAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/ProblemFlowsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/RecoverStuckJobsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Job/RetryJobAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/JobAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LocalSearchAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LogAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LogAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LogAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LogAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/LogAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/Media/AltTextAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/GDRenderer.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/ImageGenerationAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/ImageOptimizationAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/ImageTemplateAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/MediaAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/PlatformPresets.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/TemplateInterface.php::MissingTestFile", + "test_coverage::inc/Abilities/Media/TemplateRegistry.php::MissingTestFile", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/PermissionHelper.php::MissingTestMethod", + "test_coverage::inc/Abilities/Pipeline/CreatePipelineAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/DeletePipelineAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/DuplicatePipelineAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/GetPipelinesAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/ImportExportAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/PipelineHelpers.php::MissingTestFile", + "test_coverage::inc/Abilities/Pipeline/UpdatePipelineAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PipelineStepAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PostQueryAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PostQueryAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PostQueryAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PostQueryAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/PostQueryAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/ProcessedItemsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/ProcessedItemsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/ProcessedItemsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/ProcessedItemsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/Publish/PublishWordPressAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Publish/SendEmailAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/SEO/IndexNowAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/SEO/MetaDescriptionAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SettingsAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/StepTypeAbilities.php::MissingTestFile", + "test_coverage::inc/Abilities/SystemAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SystemAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/SystemAbilities.php::MissingTestMethod", + "test_coverage::inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Taxonomy/ResolveTermAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php::MissingTestFile", + "test_coverage::inc/Abilities/TaxonomyAbilities.php::MissingTestFile", + "test_coverage::inc/Api/AgentFiles.php::MissingTestFile", + "test_coverage::inc/Api/AgentPing.php::MissingTestFile", + "test_coverage::inc/Api/Agents.php::MissingTestFile", + "test_coverage::inc/Api/Analytics.php::MissingTestFile", + "test_coverage::inc/Api/Auth.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Chat.php::MissingTestFile", + "test_coverage::inc/Api/Chat/ChatFilters.php::MissingTestFile", + "test_coverage::inc/Api/Chat/ChatOrchestrator.php::MissingTestFile", + "test_coverage::inc/Api/Chat/ChatPipelinesDirective.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/AddPipelineStep.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ApiQuery.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/AssignTaxonomyTerm.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/AuthenticateHandler.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ConfigureFlowSteps.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ConfigurePipelineStep.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/CopyFlow.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/CreateFlow.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/CreatePipeline.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/CreateTaxonomyTerm.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/DeleteFile.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/DeleteFlow.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/DeletePipeline.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/DeletePipelineStep.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ExecuteWorkflowTool.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/GetHandlerDefaults.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/GetProblemFlows.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ListFlows.php::MissingTestMethod", + "test_coverage::inc/Api/Chat/Tools/ListFlows.php::MissingTestMethod", + "test_coverage::inc/Api/Chat/Tools/ManageJobs.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ManageLogs.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ManageQueue.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/MergeTaxonomyTerms.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ReadLogs.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/ReorderPipelineSteps.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/RunFlow.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/SchedulingDocumentation.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/SearchTaxonomyTerms.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/SendPing.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/SetHandlerDefaults.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/SystemHealthCheck.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/UpdateFlow.php::MissingTestFile", + "test_coverage::inc/Api/Chat/Tools/UpdateTaxonomyTerm.php::MissingTestFile", + "test_coverage::inc/Api/Email.php::MissingTestFile", + "test_coverage::inc/Api/Execute.php::MissingTestFile", + "test_coverage::inc/Api/FlowFiles.php::MissingTestFile", + "test_coverage::inc/Api/Flows/FlowQueue.php::MissingTestFile", + "test_coverage::inc/Api/Flows/FlowScheduling.php::MissingTestFile", + "test_coverage::inc/Api/Flows/FlowSteps.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows/handle.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows/handle_bulk.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows/handle_get.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows/register.php::MissingTestFile", + "test_coverage::inc/Api/Flows/Flows/sanitize_daily_memory.php::MissingTestFile", + "test_coverage::inc/Api/Handlers.php::MissingTestFile", + "test_coverage::inc/Api/InternalLinks.php::MissingTestFile", + "test_coverage::inc/Api/Jobs.php::MissingTestFile", + "test_coverage::inc/Api/Logs.php::MissingTestFile", + "test_coverage::inc/Api/Pipelines/PipelineFlows.php::MissingTestFile", + "test_coverage::inc/Api/Pipelines/PipelineSteps.php::MissingTestFile", + "test_coverage::inc/Api/Pipelines/Pipelines.php::MissingTestFile", + "test_coverage::inc/Api/ProcessedItems.php::MissingTestFile", + "test_coverage::inc/Api/Providers.php::MissingTestFile", + "test_coverage::inc/Api/Settings.php::MissingTestFile", + "test_coverage::inc/Api/StepTypes.php::MissingTestFile", + "test_coverage::inc/Api/Tools.php::MissingTestFile", + "test_coverage::inc/Api/Users.php::MissingTestFile", + "test_coverage::inc/Api/WebhookTrigger.php::MissingTestFile", + "test_coverage::inc/Cli/AgentResolver.php::MissingTestMethod", + "test_coverage::inc/Cli/AgentResolver.php::MissingTestMethod", + "test_coverage::inc/Cli/AgentResolver.php::MissingTestMethod", + "test_coverage::inc/Cli/BaseCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Bootstrap.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AgentsCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/AltTextCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/AnalyticsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/AuthCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/BatchCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/BlocksCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/ChatCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/EmailCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/ExternalCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/BulkConfigCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/FlowsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/FlowsCommand/helpers.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/QueueCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/Flows/WebhookCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/HandlersCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/ImageCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/IndexNowCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/JobsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/JobsCommand/delete.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/JobsCommand/helpers.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/JobsCommand/show.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/LinksCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/LogsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand/agent_files_multi.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand/daily.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand/search.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand/sections.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MemoryCommand/write.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/MetaDescriptionCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/PipelinesCommand.php::MissingTestMethod", + "test_coverage::inc/Cli/Commands/PostsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/ProcessedItemsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/RetentionCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/SettingsCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/StepTypesCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/SystemCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/TaxonomyCommand.php::MissingTestFile", + "test_coverage::inc/Cli/Commands/TestCommand.php::MissingTestFile", + "test_coverage::inc/Cli/UserResolver.php::MissingTestMethod", + "test_coverage::inc/Core/ActionScheduler/ActionsCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/ClaimsCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/CompletedJobsCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/JobsCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/LogCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/ProcessedItemsCleanup.php::MissingTestFile", + "test_coverage::inc/Core/ActionScheduler/QueueTuning.php::MissingTestFile", + "test_coverage::inc/Core/Admin/AdminRootFilters.php::MissingTestFile", + "test_coverage::inc/Core/Admin/DateFormatter.php::MissingTestMethod", + "test_coverage::inc/Core/Admin/DateFormatter.php::MissingTestMethod", + "test_coverage::inc/Core/Admin/DateFormatter.php::MissingTestMethod", + "test_coverage::inc/Core/Admin/DateFormatter.php::MissingTestMethod", + "test_coverage::inc/Core/Admin/DateFormatter.php::MissingTestMethod", + "test_coverage::inc/Core/Admin/FlowFormatter.php::MissingTestFile", + "test_coverage::inc/Core/Auth/AgentAuthCallback.php::MissingTestFile", + "test_coverage::inc/Core/Auth/AgentAuthMiddleware.php::MissingTestFile", + "test_coverage::inc/Core/Auth/AgentAuthorize.php::MissingTestFile", + "test_coverage::inc/Core/DataPacket.php::MissingTestFile", + "test_coverage::inc/Core/Database/Agents/AgentAccess.php::MissingTestFile", + "test_coverage::inc/Core/Database/Agents/AgentTokens.php::MissingTestFile", + "test_coverage::inc/Core/Database/Agents/Agents.php::MissingTestFile", + "test_coverage::inc/Core/Database/Jobs/Jobs.php::MissingTestFile", + "test_coverage::inc/Core/Database/Jobs/JobsOperations.php::MissingTestFile", + "test_coverage::inc/Core/Database/Jobs/JobsStatus.php::MissingTestFile", + "test_coverage::inc/Core/EngineData.php::MissingTestFile", + "test_coverage::inc/Core/ExecutionContext.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/AgentMemory.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/DailyMemory.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/DirectoryManager.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/FileCleanup.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/FileRetrieval.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/FileStorage.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/FilesystemHelper.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/ImageValidator.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/MediaValidator.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/RemoteFileDownloader.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/VideoMetadata.php::MissingTestFile", + "test_coverage::inc/Core/FilesRepository/VideoValidator.php::MissingTestFile", + "test_coverage::inc/Core/HttpClient.php::MissingTestFile", + "test_coverage::inc/Core/JobStatus.php::MissingTestFile", + "test_coverage::inc/Core/NetworkSettings.php::MissingTestMethod", + "test_coverage::inc/Core/NetworkSettings.php::MissingTestMethod", + "test_coverage::inc/Core/NetworkSettings.php::MissingTestMethod", + "test_coverage::inc/Core/NetworkSettings.php::MissingTestMethod", + "test_coverage::inc/Core/NetworkSettings.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseAuthProvider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseAuthProvider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseAuthProvider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth1Provider.php::MissingTestFile", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/BaseOAuth2Provider.php::MissingTestMethod", + "test_coverage::inc/Core/OAuth/OAuth1Handler.php::MissingTestFile", + "test_coverage::inc/Core/OAuth/OAuth2Handler.php::MissingTestFile", + "test_coverage::inc/Core/PluginSettings.php::MissingTestFile", + "test_coverage::inc/Core/Similarity/SimilarityEngine.php::MissingTestFile", + "test_coverage::inc/Core/Similarity/SimilarityResult.php::MissingTestFile", + "test_coverage::inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php::MissingTestFile", + "test_coverage::inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php::MissingTestFile", + "test_coverage::inc/Core/Steps/AI/Directives/PipelineSystemPromptDirective.php::MissingTestFile", + "test_coverage::inc/Core/Steps/AgentPing/AgentPingSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/AgentPing/AgentPingStep.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Email/Email.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/FetchHandler.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/FetchHandlerSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Files/Files.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Files/FilesSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Rss/Rss.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/Rss/RssSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPress/WordPressSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPressAPI/WordPressAPI.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPressAPI/WordPressAPISettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMediaSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/FlowStepConfig.php::MissingTestFile", + "test_coverage::inc/Core/Steps/HandlerRegistrationTrait.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/Email/Email.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/Email/EmailSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/PublishHandler.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/PublishHandlerSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/WordPress/WordPress.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Publish/Handlers/WordPress/WordPressSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/QueueableTrait.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Settings/SettingsDisplayService.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Settings/SettingsHandler.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Step.php::MissingTestFile", + "test_coverage::inc/Core/Steps/StepTypeRegistrationTrait.php::MissingTestFile", + "test_coverage::inc/Core/Steps/SystemTask/SystemTaskSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/SystemTask/SystemTaskStep.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Update/Handlers/WordPress/WordPress.php::MissingTestFile", + "test_coverage::inc/Core/Steps/Update/Handlers/WordPress/WordPressSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/WebhookGate/WebhookGateSettings.php::MissingTestFile", + "test_coverage::inc/Core/Steps/WebhookGate/WebhookGateStep.php::MissingTestMethod", + "test_coverage::inc/Core/Steps/WebhookGate/WebhookGateStep.php::MissingTestMethod", + "test_coverage::inc/Core/Steps/WebhookGate/WebhookGateStep.php::MissingTestMethod", + "test_coverage::inc/Core/WordPress/DuplicateDetection.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/PostTracking.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/SiteContext.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/TaxonomyHandler.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/WordPressFilters.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/WordPressPublishHelper.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/WordPressSettingsHandler.php::MissingTestFile", + "test_coverage::inc/Core/WordPress/WordPressSettingsResolver.php::MissingTestFile", + "test_coverage::inc/Engine/AI/AIConversationLoop.php::MissingTestFile", + "test_coverage::inc/Engine/AI/ConversationManager.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/ClientContextDirective.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/CoreMemoryFilesDirective.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/DailyMemorySelectorDirective.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/DirectiveInterface.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/DirectiveOutputValidator.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/DirectiveRenderer.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/MemoryFilesReader.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Directives/SiteContextDirective.php::MissingTestFile", + "test_coverage::inc/Engine/AI/MemoryFileRegistry.php::MissingTestFile", + "test_coverage::inc/Engine/AI/PromptBuilder.php::MissingTestFile", + "test_coverage::inc/Engine/AI/RequestBuilder.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/AltTextTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/DailyMemoryTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/ImageGenerationTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/ImageOptimizationTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/InternalLinkingTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/MetaDescriptionTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/System/Tasks/SystemTask.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/BaseTool.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/AgentDailyMemory.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/AgentMemory.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/BingWebmaster.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/GoogleAnalytics.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/GoogleSearch.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/GoogleSearchConsole.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/ImageGeneration.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/InternalLinkAudit.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/LocalSearch.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/PageSpeed.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/QueueValidator.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/WebFetch.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/Global/WordPressPostReader.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolExecutor.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolManager.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolParameters.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolPolicyResolver.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolResultFinder.php::MissingTestFile", + "test_coverage::inc/Engine/AI/Tools/ToolServiceProvider.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/DataMachineActions.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/Engine.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/Handlers/FailJobHandler.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/Handlers/JobCompleteHandler.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/Handlers/LogHandler.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/Handlers/MarkItemProcessedHandler.php::MissingTestFile", + "test_coverage::inc/Engine/Actions/ImportExport.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/Admin.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/DataMachineFilters.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/EngineData.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/Handlers.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/OAuth.php::MissingTestFile", + "test_coverage::inc/Engine/Filters/SchedulerIntervals.php::MissingTestFile", + "test_coverage::inc/Engine/Logger.php::MissingTestFile", + "test_coverage::inc/Engine/StepNavigator.php::MissingTestFile", + "test_coverage::inc/Engine/Tasks/TaskRegistry.php::MissingTestFile", + "test_coverage::inc/Engine/Tasks/TaskScheduler.php::MissingTestFile", + "test_coverage::inc/bootstrap.php::MissingTestFile", + "test_coverage::inc/migrations.php::MissingTestFile", + "test_coverage::inc/migrations/build_content.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine_default.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine_ensure.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine_regenerate.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine_register.php::MissingTestFile", + "test_coverage::inc/migrations/datamachine_scaffold.php::MissingTestFile", + "test_coverage::tests/Unit/AI/System/Tasks/AltTextTaskTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/System/Tasks/ImageGenerationTaskTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/BingWebmasterTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ChatToolsAvailabilityTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/GoogleSearchConfigTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ImageGenerationTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ImageGenerationToolCallTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/PipelineToolsAvailabilityTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/QueueValidatorTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ToolExecutorValidationTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ToolPolicyResolverTest.php::OrphanedTest", + "test_coverage::tests/Unit/AI/Tools/ToolResultFinderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AgentContextPropagationTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AllAbilitiesRegisteredTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AltTextAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AuthAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AuthAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AuthAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/AuthAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/BingWebmasterAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/DuplicateCheckAbilityTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/FileAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/FlowAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/FlowAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/FlowAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/HandlerAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ImageGenerationAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ImageGenerationPromptRefinementTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/LocalSearchAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/MediaAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/MultiAgentScopingTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PipelineAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PipelineAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PipelineAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PipelineAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PipelineStepAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/PostQueryAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/ProcessedItemsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SettingsAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Abilities/SystemAbilitiesTest.php::OrphanedTest", + "test_coverage::tests/Unit/Api/Chat/Tools/ListFlowsTest.php::OrphanedTest", + "test_coverage::tests/Unit/Api/Flows/FlowSchedulingStaggerTest.php::OrphanedTest", + "test_coverage::tests/Unit/Api/Flows/FlowsEndpointTest.php::OrphanedTest", + "test_coverage::tests/Unit/Cli/BulkConfigCommandTest.php::OrphanedTest", + "test_coverage::tests/Unit/Cli/Commands/PipelinesCommandTest.php::OrphanedTest", + "test_coverage::tests/Unit/Cli/Commands/PipelinesCommandTest.php::OrphanedTest", + "test_coverage::tests/Unit/Cli/FlowsCommandTest.php::OrphanedTest", + "test_coverage::tests/Unit/Cli/SettingsCommandTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/EngineDataVideoTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/ExecutionContextAgentTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/JobStatusWaitingTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/NetworkSettingsTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/OAuth/BaseOAuth2ProviderTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/SimilarityEngineTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/StandaloneJobTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/Steps/WebhookGate/WebhookGateStepTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/Steps/WebhookGate/WebhookGateStepTest.php::OrphanedTest", + "test_coverage::tests/Unit/Core/Steps/WebhookGate/WebhookGateStepTest.php::OrphanedTest", + "test_coverage::tests/Unit/Engine/AdminFiltersTest.php::OrphanedTest", + "test_coverage::tests/Unit/Engine/DataMachineFiltersTest.php::OrphanedTest", + "test_coverage::tests/Unit/Engine/PipelineBatchSchedulerTest.php::OrphanedTest", + "test_coverage::tests/Unit/Engine/TaskRegistryTest.php::OrphanedTest", + "test_coverage::tests/Unit/Engine/TaskSchedulerTest.php::OrphanedTest", + "test_coverage::tests/Unit/FilesRepository/MediaValidatorTest.php::OrphanedTest", + "test_coverage::tests/Unit/FilesRepository/VideoMetadataTest.php::OrphanedTest", + "test_coverage::tests/Unit/FilesRepository/VideoValidatorTest.php::OrphanedTest" + ], + "metadata": { + "alignment_score": 0.7877358198165894, + "known_outliers": [ + "inc/Abilities/PermissionHelper.php", + "inc/Abilities/InternalLinkingAbilities.php", + "inc/Core/Database/Agents/Agents.php", + "inc/Abilities/Analytics/PageSpeedAbilities.php", + "inc/Api/WebhookTrigger.php", + "inc/Api/Handlers.php", + "inc/Api/Providers.php", + "inc/Api/Execute.php", + "inc/Api/StepTypes.php", + "inc/Api/Tools.php", + "inc/Api/Email.php", + "inc/Core/Auth/AgentAuthMiddleware.php", + "inc/Abilities/Chat/ChatSessionHelpers.php", + "inc/Abilities/Content/CanonicalDiffPreview.php", + "inc/Abilities/Content/PendingDiffStore.php", + "inc/Abilities/Content/BlockSanitizer.php", + "inc/Engine/AI/Directives/MemoryFilesReader.php", + "inc/Engine/AI/Directives/DirectiveOutputValidator.php", + "inc/Engine/AI/Directives/DirectiveRenderer.php", + "inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php", + "inc/Abilities/Engine/EngineHelpers.php", + "inc/Abilities/Engine/PipelineBatchScheduler.php", + "inc/Abilities/File/FileConstants.php", + "inc/Abilities/Flow/FlowHelpers.php", + "inc/Abilities/Flow/QueueAbility.php", + "inc/Abilities/Flow/WebhookTriggerAbility.php", + "inc/Abilities/FlowStep/FlowStepHelpers.php", + "inc/Cli/Commands/Flows/FlowsCommand.php", + "inc/Engine/AI/Tools/Global/AgentMemory.php", + "inc/Engine/Actions/Handlers/MarkItemProcessedHandler.php", + "inc/Engine/Actions/Handlers/FailJobHandler.php", + "inc/Engine/Actions/Handlers/LogHandler.php", + "inc/Abilities/Job/JobHelpers.php", + "inc/Core/Database/Jobs/JobsOperations.php", + "inc/Core/Database/Jobs/JobsStatus.php", + "inc/Core/OAuth/OAuth2Handler.php", + "inc/Core/OAuth/OAuth1Handler.php", + "inc/Core/OAuth/BaseAuthProvider.php", + "inc/Abilities/Pipeline/PipelineHelpers.php", + "inc/Abilities/Pipeline/ImportExportAbility.php", + "inc/Engine/AI/System/Tasks/DailyMemoryTask.php", + "inc/Engine/AI/System/Tasks/ImageGenerationTask.php", + "inc/Engine/AI/System/Tasks/ImageOptimizationTask.php", + "inc/Engine/AI/System/Tasks/SystemTask.php", + "inc/Api/Chat/Tools/SchedulingDocumentation.php" + ], + "outliers_count": 45 + } + }, "lint": { "context_id": "data-machine", "created_at": "2026-03-06T04:47:29Z", @@ -14,13 +1368,13 @@ "changelog_target": "docs/CHANGELOG.md", "extensions": { "wordpress": { - "php": "8.2", - "node": "20", "database_type": "mysql", - "mysql_host": "localhost", "mysql_database": "wptests", + "mysql_host": "localhost", + "mysql_password": "", "mysql_user": "root", - "mysql_password": "" + "node": "20", + "php": "8.2" } }, "id": "data-machine", @@ -42,4 +1396,4 @@ "pattern": "\"version\":\\s*\"([0-9.]+)\"" } ] -} +} \ No newline at end of file diff --git a/inc/Abilities/AgentPingAbilities.php b/inc/Abilities/AgentPingAbilities.php index efddb3a59..da9a6b55c 100644 --- a/inc/Abilities/AgentPingAbilities.php +++ b/inc/Abilities/AgentPingAbilities.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Abilities\AgentPing\SendPingAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -23,6 +24,7 @@ class AgentPingAbilities { private SendPingAbility $send_ping; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/AgentTokenAbilities.php b/inc/Abilities/AgentTokenAbilities.php index de36844d9..aed2581d7 100644 --- a/inc/Abilities/AgentTokenAbilities.php +++ b/inc/Abilities/AgentTokenAbilities.php @@ -13,6 +13,7 @@ use DataMachine\Core\Database\Agents\Agents; use DataMachine\Core\Database\Agents\AgentTokens; +use DataMachine\Abilities\PermissionHelper; defined( 'ABSPATH' ) || exit; @@ -234,11 +235,11 @@ public function executeCreateToken( array $input ): array { 'info', 'Agent token created', array( - 'agent_id' => $agent_id, - 'agent_slug' => $agent['agent_slug'], - 'token_id' => $result['token_id'], - 'label' => $label, - 'has_expiry' => null !== $expires_at, + 'agent_id' => $agent_id, + 'agent_slug' => $agent['agent_slug'], + 'token_id' => $result['token_id'], + 'label' => $label, + 'has_expiry' => null !== $expires_at, 'has_cap_restriction' => null !== $capabilities, ) ); diff --git a/inc/Abilities/Analytics/BingWebmasterAbilities.php b/inc/Abilities/Analytics/BingWebmasterAbilities.php index c211fcf28..b0320427a 100644 --- a/inc/Abilities/Analytics/BingWebmasterAbilities.php +++ b/inc/Abilities/Analytics/BingWebmasterAbilities.php @@ -13,6 +13,8 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\HttpClient; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; +use DataMachine\Abilities\Media\ImageGenerationAbilities; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php b/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php index a1244eb90..56a282d95 100644 --- a/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php +++ b/inc/Abilities/Analytics/GoogleAnalyticsAbilities.php @@ -19,6 +19,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\HttpClient; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php b/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php index 58372c9e7..e12e3dc88 100644 --- a/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php +++ b/inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php @@ -13,6 +13,8 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\HttpClient; +use DataMachine\Abilities\Analytics\GoogleAnalyticsAbilities; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Analytics/PageSpeedAbilities.php b/inc/Abilities/Analytics/PageSpeedAbilities.php index 92f1419ca..fc89b970b 100644 --- a/inc/Abilities/Analytics/PageSpeedAbilities.php +++ b/inc/Abilities/Analytics/PageSpeedAbilities.php @@ -18,6 +18,8 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\HttpClient; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/AuthAbilities.php b/inc/Abilities/AuthAbilities.php index 516d85a2a..e392fb615 100644 --- a/inc/Abilities/AuthAbilities.php +++ b/inc/Abilities/AuthAbilities.php @@ -12,6 +12,8 @@ namespace DataMachine\Abilities; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; +use DataMachine\Core\NetworkSettings; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/ChatAbilities.php b/inc/Abilities/ChatAbilities.php index 82d47d785..70256791a 100644 --- a/inc/Abilities/ChatAbilities.php +++ b/inc/Abilities/ChatAbilities.php @@ -27,6 +27,7 @@ class ChatAbilities { private CreateChatSessionAbility $create_session; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/Content/EditPostBlocksAbility.php b/inc/Abilities/Content/EditPostBlocksAbility.php index 377dace87..ee0cca3a0 100644 --- a/inc/Abilities/Content/EditPostBlocksAbility.php +++ b/inc/Abilities/Content/EditPostBlocksAbility.php @@ -46,32 +46,32 @@ private function registerAbility(): void { 'type' => 'integer', 'description' => __( 'Post ID to edit', 'data-machine' ), ), - 'edits' => array( - 'type' => 'array', - 'description' => __( 'Array of edit operations', 'data-machine' ), - 'items' => array( - 'type' => 'object', - 'required' => array( 'block_index', 'find', 'replace' ), - 'properties' => array( - 'block_index' => array( - 'type' => 'integer', - 'description' => __( 'Zero-based block index to edit', 'data-machine' ), - ), - 'find' => array( - 'type' => 'string', - 'description' => __( 'Text to find within the block', 'data-machine' ), - ), - 'replace' => array( - 'type' => 'string', - 'description' => __( 'Replacement text', 'data-machine' ), + 'edits' => array( + 'type' => 'array', + 'description' => __( 'Array of edit operations', 'data-machine' ), + 'items' => array( + 'type' => 'object', + 'required' => array( 'block_index', 'find', 'replace' ), + 'properties' => array( + 'block_index' => array( + 'type' => 'integer', + 'description' => __( 'Zero-based block index to edit', 'data-machine' ), + ), + 'find' => array( + 'type' => 'string', + 'description' => __( 'Text to find within the block', 'data-machine' ), + ), + 'replace' => array( + 'type' => 'string', + 'description' => __( 'Replacement text', 'data-machine' ), + ), ), ), ), - ), - 'preview' => array( - 'type' => 'boolean', - 'description' => __( 'When true, return diff preview without applying changes', 'data-machine' ), - ), + 'preview' => array( + 'type' => 'boolean', + 'description' => __( 'When true, return diff preview without applying changes', 'data-machine' ), + ), ), ), 'output_schema' => array( diff --git a/inc/Abilities/Content/InsertContentAbility.php b/inc/Abilities/Content/InsertContentAbility.php index ed066b0e5..6e3e3dd6a 100644 --- a/inc/Abilities/Content/InsertContentAbility.php +++ b/inc/Abilities/Content/InsertContentAbility.php @@ -66,9 +66,9 @@ private function register_ability(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'diff_id' => array( 'type' => 'string' ), - 'diff' => array( 'type' => 'object' ), + 'success' => array( 'type' => 'boolean' ), + 'diff_id' => array( 'type' => 'string' ), + 'diff' => array( 'type' => 'object' ), ), ), 'execute_callback' => array( self::class, 'execute' ), @@ -191,30 +191,30 @@ public static function execute( array $input ): array { $block_content = "\n\n\n

" . wp_kses_post( $content ) . "

\n"; $insertion_point = ''; - $block_index = count( $current_blocks ); + $block_index = count( $current_blocks ); - switch ( $position ) { - case 'beginning': - $new_content = $block_content . "\n\n" . $current_content; - $insertion_point = 'at the beginning of the post'; - $block_index = 0; - break; + switch ( $position ) { + case 'beginning': + $new_content = $block_content . "\n\n" . $current_content; + $insertion_point = 'at the beginning of the post'; + $block_index = 0; + break; - case 'end': - $new_content = $current_content . $block_content; - $insertion_point = 'at the end of the post'; - $block_index = count( $current_blocks ); - break; + case 'end': + $new_content = $current_content . $block_content; + $insertion_point = 'at the end of the post'; + $block_index = count( $current_blocks ); + break; case 'after_paragraph': $result = self::insert_after_paragraph( $current_content, $block_content, $target_paragraph_text ); if ( ! $result['success'] ) { return $result; - } + } $new_content = $result['content']; $insertion_point = $result['insertion_point']; $block_index = (int) ( $result['block_index'] ?? count( $current_blocks ) ); - break; + break; default: return array( @@ -241,12 +241,12 @@ public static function execute( array $input ): array { } return array( - 'success' => true, - 'post_id' => $post_id, - 'post_url' => get_permalink( $post_id ), - 'position' => $position, - 'insertion_point'=> $insertion_point, - 'new_content' => $new_content, + 'success' => true, + 'post_id' => $post_id, + 'post_url' => get_permalink( $post_id ), + 'position' => $position, + 'insertion_point' => $insertion_point, + 'new_content' => $new_content, ); } @@ -268,14 +268,14 @@ public static function execute( array $input ): array { ), ), 'editor' => array( - 'toolCallId' => $input['_original_call_id'] ?? '', - 'editType' => 'content', - 'searchPattern' => '', - 'caseSensitive' => false, - 'isPreview' => true, - 'previewBlockContent'=> $block_content, + 'toolCallId' => $input['_original_call_id'] ?? '', + 'editType' => 'content', + 'searchPattern' => '', + 'caseSensitive' => false, + 'isPreview' => true, + 'previewBlockContent' => $block_content, 'originalBlockContent' => '', - 'originalBlockType' => 'core/paragraph', + 'originalBlockType' => 'core/paragraph', ), ) ); diff --git a/inc/Abilities/Content/ReplacePostBlocksAbility.php b/inc/Abilities/Content/ReplacePostBlocksAbility.php index 1e0fb75cd..0631703ab 100644 --- a/inc/Abilities/Content/ReplacePostBlocksAbility.php +++ b/inc/Abilities/Content/ReplacePostBlocksAbility.php @@ -46,27 +46,27 @@ private function registerAbility(): void { 'description' => __( 'Post ID to edit', 'data-machine' ), ), 'replacements' => array( - 'type' => 'array', - 'description' => __( 'Array of block replacement operations', 'data-machine' ), - 'items' => array( - 'type' => 'object', - 'required' => array( 'block_index', 'new_content' ), - 'properties' => array( - 'block_index' => array( - 'type' => 'integer', - 'description' => __( 'Zero-based block index to replace', 'data-machine' ), - ), - 'new_content' => array( - 'type' => 'string', - 'description' => __( 'New innerHTML for the block', 'data-machine' ), + 'type' => 'array', + 'description' => __( 'Array of block replacement operations', 'data-machine' ), + 'items' => array( + 'type' => 'object', + 'required' => array( 'block_index', 'new_content' ), + 'properties' => array( + 'block_index' => array( + 'type' => 'integer', + 'description' => __( 'Zero-based block index to replace', 'data-machine' ), + ), + 'new_content' => array( + 'type' => 'string', + 'description' => __( 'New innerHTML for the block', 'data-machine' ), + ), ), ), ), - ), - 'preview' => array( - 'type' => 'boolean', - 'description' => __( 'When true, return diff preview without applying changes', 'data-machine' ), - ), + 'preview' => array( + 'type' => 'boolean', + 'description' => __( 'When true, return diff preview without applying changes', 'data-machine' ), + ), ), ), 'output_schema' => array( diff --git a/inc/Abilities/Email/EmailAbilities.php b/inc/Abilities/Email/EmailAbilities.php index 3cb952d89..281fcac6b 100644 --- a/inc/Abilities/Email/EmailAbilities.php +++ b/inc/Abilities/Email/EmailAbilities.php @@ -20,1271 +20,4 @@ class EmailAbilities { private static bool $registered = false; - - public function __construct() { - if ( ! class_exists( 'WP_Ability' ) ) { - return; - } - - if ( self::$registered ) { - return; - } - - $this->registerAbilities(); - self::$registered = true; - } - - private function registerAbilities(): void { - $register_callback = function () { - // Reply to an email. - wp_register_ability( - 'datamachine/email-reply', - array( - 'label' => __( 'Reply to Email', 'data-machine' ), - 'description' => __( 'Send a reply to an email, maintaining thread headers', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'to', 'subject', 'body', 'in_reply_to' ), - 'properties' => array( - 'to' => array( - 'type' => 'string', - 'description' => __( 'Recipient email address', 'data-machine' ), - ), - 'subject' => array( - 'type' => 'string', - 'description' => __( 'Reply subject (typically Re: original subject)', 'data-machine' ), - ), - 'body' => array( - 'type' => 'string', - 'description' => __( 'Reply body content', 'data-machine' ), - ), - 'in_reply_to' => array( - 'type' => 'string', - 'description' => __( 'Message-ID of the email being replied to', 'data-machine' ), - ), - 'references' => array( - 'type' => 'string', - 'default' => '', - 'description' => __( 'References header chain for threading', 'data-machine' ), - ), - 'cc' => array( - 'type' => 'string', - 'default' => '', - ), - 'content_type' => array( - 'type' => 'string', - 'default' => 'text/html', - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - 'logs' => array( 'type' => 'array' ), - ), - ), - 'execute_callback' => array( $this, 'executeReply' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Delete an email via IMAP. - wp_register_ability( - 'datamachine/email-delete', - array( - 'label' => __( 'Delete Email', 'data-machine' ), - 'description' => __( 'Delete (expunge) an email by UID from the IMAP server', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'uid' ), - 'properties' => array( - 'uid' => array( - 'type' => 'integer', - 'description' => __( 'Message UID to delete', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeDelete' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Move an email to a different folder. - wp_register_ability( - 'datamachine/email-move', - array( - 'label' => __( 'Move Email', 'data-machine' ), - 'description' => __( 'Move an email to a different IMAP folder', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'uid', 'destination' ), - 'properties' => array( - 'uid' => array( - 'type' => 'integer', - 'description' => __( 'Message UID to move', 'data-machine' ), - ), - 'destination' => array( - 'type' => 'string', - 'description' => __( 'Target folder (e.g., Archive, Trash, [Gmail]/All Mail)', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeMove' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Flag/unflag an email. - wp_register_ability( - 'datamachine/email-flag', - array( - 'label' => __( 'Flag Email', 'data-machine' ), - 'description' => __( 'Set or clear IMAP flags on an email (Seen, Flagged, etc.)', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'uid', 'flag' ), - 'properties' => array( - 'uid' => array( - 'type' => 'integer', - 'description' => __( 'Message UID', 'data-machine' ), - ), - 'flag' => array( - 'type' => 'string', - 'description' => __( 'IMAP flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), - ), - 'action' => array( - 'type' => 'string', - 'default' => 'set', - 'description' => __( 'set or clear the flag', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeFlag' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Batch move: search → move all matches. - wp_register_ability( - 'datamachine/email-batch-move', - array( - 'label' => __( 'Batch Move Emails', 'data-machine' ), - 'description' => __( 'Move all emails matching a search to a destination folder', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'search', 'destination' ), - 'properties' => array( - 'search' => array( - 'type' => 'string', - 'description' => __( 'IMAP search criteria (e.g., FROM "github.com")', 'data-machine' ), - ), - 'destination' => array( - 'type' => 'string', - 'description' => __( 'Target folder (e.g., [Gmail]/GitHub, Archive)', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - 'max' => array( - 'type' => 'integer', - 'default' => 500, - 'description' => __( 'Maximum messages to move (safety limit)', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'moved_count' => array( 'type' => 'integer' ), - 'total_matches' => array( 'type' => 'integer' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeBatchMove' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Batch flag: search → flag/unflag all matches. - wp_register_ability( - 'datamachine/email-batch-flag', - array( - 'label' => __( 'Batch Flag Emails', 'data-machine' ), - 'description' => __( 'Set or clear a flag on all emails matching a search', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'search', 'flag' ), - 'properties' => array( - 'search' => array( - 'type' => 'string', - 'description' => __( 'IMAP search criteria', 'data-machine' ), - ), - 'flag' => array( - 'type' => 'string', - 'description' => __( 'Flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), - ), - 'action' => array( - 'type' => 'string', - 'default' => 'set', - 'description' => __( 'set or clear', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - 'max' => array( - 'type' => 'integer', - 'default' => 500, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'flagged_count' => array( 'type' => 'integer' ), - 'total_matches' => array( 'type' => 'integer' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeBatchFlag' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Batch delete: search → delete all matches. - wp_register_ability( - 'datamachine/email-batch-delete', - array( - 'label' => __( 'Batch Delete Emails', 'data-machine' ), - 'description' => __( 'Delete all emails matching a search', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'search' ), - 'properties' => array( - 'search' => array( - 'type' => 'string', - 'description' => __( 'IMAP search criteria', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - 'max' => array( - 'type' => 'integer', - 'default' => 100, - 'description' => __( 'Maximum messages to delete (safety limit, lower default)', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'deleted_count' => array( 'type' => 'integer' ), - 'total_matches' => array( 'type' => 'integer' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeBatchDelete' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Unsubscribe from a mailing list. - wp_register_ability( - 'datamachine/email-unsubscribe', - array( - 'label' => __( 'Unsubscribe from Email', 'data-machine' ), - 'description' => __( 'Unsubscribe from a mailing list using List-Unsubscribe headers', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'uid' ), - 'properties' => array( - 'uid' => array( - 'type' => 'integer', - 'description' => __( 'Message UID to unsubscribe from', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'method' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeUnsubscribe' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Batch unsubscribe from all matching senders. - wp_register_ability( - 'datamachine/email-batch-unsubscribe', - array( - 'label' => __( 'Batch Unsubscribe', 'data-machine' ), - 'description' => __( 'Unsubscribe from all mailing lists matching a search', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'search' ), - 'properties' => array( - 'search' => array( - 'type' => 'string', - 'description' => __( 'IMAP search criteria', 'data-machine' ), - ), - 'folder' => array( - 'type' => 'string', - 'default' => 'INBOX', - ), - 'max' => array( - 'type' => 'integer', - 'default' => 20, - 'description' => __( 'Max unique senders to unsubscribe from (deduped by sender)', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'results' => array( 'type' => 'array' ), - 'unsubscribed' => array( 'type' => 'integer' ), - 'failed' => array( 'type' => 'integer' ), - 'no_header' => array( 'type' => 'integer' ), - ), - ), - 'execute_callback' => array( $this, 'executeBatchUnsubscribe' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - // Test IMAP connection. - wp_register_ability( - 'datamachine/email-test-connection', - array( - 'label' => __( 'Test Email Connection', 'data-machine' ), - 'description' => __( 'Test IMAP connection with stored credentials', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => new \stdClass(), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'mailbox_info' => array( 'type' => 'object' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeTestConnection' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - }; - - if ( doing_action( 'wp_abilities_api_init' ) ) { - $register_callback(); - } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { - add_action( 'wp_abilities_api_init', $register_callback ); - } - } - - public function checkPermission(): bool { - return PermissionHelper::can_manage(); - } - - /** - * Reply to an email with threading headers. - */ - public function executeReply( array $input ): array { - $headers = array(); - - $content_type = $input['content_type'] ?? 'text/html'; - $headers[] = "Content-Type: {$content_type}; charset=UTF-8"; - - // Threading headers. - if ( ! empty( $input['in_reply_to'] ) ) { - $headers[] = 'In-Reply-To: ' . $input['in_reply_to']; - } - - $references = $input['references'] ?? ''; - if ( ! empty( $input['in_reply_to'] ) ) { - $references = trim( $references . ' ' . $input['in_reply_to'] ); - } - if ( ! empty( $references ) ) { - $headers[] = 'References: ' . $references; - } - - if ( ! empty( $input['cc'] ) ) { - $cc_list = array_map( 'trim', explode( ',', $input['cc'] ) ); - foreach ( $cc_list as $cc ) { - if ( is_email( $cc ) ) { - $headers[] = 'Cc: ' . $cc; - } - } - } - - $to = array_map( 'trim', explode( ',', $input['to'] ) ); - $to = array_filter( $to, 'is_email' ); - - if ( empty( $to ) ) { - return array( - 'success' => false, - 'error' => 'No valid recipient address', - ); - } - - $sent = wp_mail( $to, $input['subject'], $input['body'], $headers ); - - if ( $sent ) { - return array( - 'success' => true, - 'message' => 'Reply sent to ' . implode( ', ', $to ), - 'logs' => array(), - ); - } - - global $phpmailer; - $error = 'wp_mail() returned false'; - if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { - $error = $phpmailer->ErrorInfo ?: $error; - } - - return array( - 'success' => false, - 'error' => $error, - ); - } - - /** - * Delete an email from the IMAP server. - */ - public function executeDelete( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $uid = (int) $input['uid']; - imap_delete( $connection, (string) $uid, FT_UID ); - imap_expunge( $connection ); - imap_close( $connection ); - - return array( - 'success' => true, - 'message' => sprintf( 'Message UID %d deleted', $uid ), - ); - } - - /** - * Move an email to a different folder. - */ - public function executeMove( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $uid = (int) $input['uid']; - $destination = $input['destination']; - - $moved = imap_mail_move( $connection, (string) $uid, $destination, CP_UID ); - if ( ! $moved ) { - $error = imap_last_error(); - imap_close( $connection ); - return array( - 'success' => false, - 'error' => 'Move failed: ' . $error, - ); - } - - imap_expunge( $connection ); - imap_close( $connection ); - - return array( - 'success' => true, - 'message' => sprintf( 'Message UID %d moved to %s', $uid, $destination ), - ); - } - - /** - * Set or clear a flag on an email. - */ - public function executeFlag( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $uid = (int) $input['uid']; - $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); - - $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); - if ( ! in_array( $flag, $valid_flags, true ) ) { - imap_close( $connection ); - return array( - 'success' => false, - 'error' => 'Invalid flag. Valid flags: Seen, Flagged, Answered, Deleted, Draft', - ); - } - - $action = $input['action'] ?? 'set'; - if ( 'clear' === $action ) { - $result = imap_clearflag_full( $connection, (string) $uid, $flag, ST_UID ); - } else { - $result = imap_setflag_full( $connection, (string) $uid, $flag, ST_UID ); - } - - imap_close( $connection ); - - if ( ! $result ) { - return array( - 'success' => false, - 'error' => 'Failed to ' . $action . ' flag ' . $flag, - ); - } - - return array( - 'success' => true, - 'message' => sprintf( 'Flag %s %s on UID %d', $flag, $action === 'clear' ? 'cleared' : 'set', $uid ), - ); - } - - /** - * Test the IMAP connection with stored credentials. - */ - public function executeTestConnection( array $input ): array { - if ( ! function_exists( 'imap_open' ) ) { - return array( - 'success' => false, - 'error' => 'PHP IMAP extension is not installed', - ); - } - - $auth = $this->getAuthProvider(); - if ( ! $auth || ! $auth->is_authenticated() ) { - return array( - 'success' => false, - 'error' => 'IMAP credentials not configured', - ); - } - - $mailbox = $this->buildMailboxString( - $auth->getHost(), - $auth->getPort(), - $auth->getEncryption(), - 'INBOX' - ); - - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); - - if ( false === $connection ) { - return array( - 'success' => false, - 'error' => 'Connection failed: ' . imap_last_error(), - ); - } - - $check = imap_check( $connection ); - $info = array( - 'mailbox' => $check->Mailbox ?? '', - 'messages' => $check->Nmsgs ?? 0, - 'recent' => $check->Recent ?? 0, - 'connected' => true, - ); - - // List available folders. - $folders = imap_list( $connection, $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ), '*' ); - $folder_list = array(); - if ( is_array( $folders ) ) { - $prefix = $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ); - foreach ( $folders as $folder ) { - $folder_list[] = str_replace( $prefix, '', imap_utf7_decode( $folder ) ); - } - } - $info['folders'] = $folder_list; - - imap_close( $connection ); - - return array( - 'success' => true, - 'message' => sprintf( 'Connected to %s — %d messages in INBOX', $auth->getHost(), $info['messages'] ), - 'mailbox_info' => $info, - ); - } - - /** - * Unsubscribe from a mailing list using List-Unsubscribe headers. - * - * Priority order: - * 1. List-Unsubscribe-Post + URL → HTTP POST (RFC 8058 one-click) - * 2. URL without Post header → HTTP POST attempt, fall back to GET - * 3. mailto: → send email via wp_mail() - */ - public function executeUnsubscribe( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $uid = (int) $input['uid']; - - // Fetch raw headers. - $raw_headers = imap_fetchheader( $connection, $uid, FT_UID ); - if ( empty( $raw_headers ) ) { - imap_close( $connection ); - return array( - 'success' => false, - 'error' => 'Could not fetch message headers', - ); - } - - $parsed = $this->parseUnsubscribeHeaders( $raw_headers ); - imap_close( $connection ); - - if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { - return array( - 'success' => false, - 'error' => 'No List-Unsubscribe header found in this message', - ); - } - - // Try One-Click POST first (RFC 8058). - if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { - $url = $parsed['urls'][0]; - $result = $this->executeOneClickUnsubscribe( $url ); - if ( $result['success'] ) { - return $result; - } - // Fall through to other methods if POST failed. - } - - // Try URL GET/POST. - if ( ! empty( $parsed['urls'] ) ) { - foreach ( $parsed['urls'] as $url ) { - $result = $this->executeUrlUnsubscribe( $url ); - if ( $result['success'] ) { - return $result; - } - } - } - - // Try mailto. - if ( ! empty( $parsed['mailto'] ) ) { - $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); - if ( $result['success'] ) { - return $result; - } - } - - return array( - 'success' => false, - 'error' => 'All unsubscribe methods failed', - ); - } - - /** - * Batch unsubscribe: search → unsubscribe from unique senders. - * - * Deduplicates by sender — if you have 100 emails from linkedin.com, - * it only unsubscribes once using the most recent message's headers. - */ - public function executeBatchUnsubscribe( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $search = $input['search']; - $max = (int) ( $input['max'] ?? 20 ); - - $uids = imap_search( $connection, $search, SE_UID ); - if ( false === $uids || empty( $uids ) ) { - imap_close( $connection ); - return array( - 'success' => true, - 'message' => 'No messages matching search criteria', - 'results' => array(), - 'unsubscribed' => 0, - 'failed' => 0, - 'no_header' => 0, - ); - } - - // Most recent first — we want the newest unsubscribe link per sender. - $uids = array_reverse( $uids ); - - // Deduplicate by sender — one unsubscribe per unique From address. - $seen_senders = array(); - $to_process = array(); - - foreach ( $uids as $uid ) { - if ( count( $to_process ) >= $max ) { - break; - } - - $msgno = imap_msgno( $connection, $uid ); - if ( 0 === $msgno ) { - continue; - } - - $header = imap_headerinfo( $connection, $msgno ); - if ( false === $header || empty( $header->from ) ) { - continue; - } - - $from = $header->from[0]; - $sender = $from->mailbox . '@' . $from->host; - - if ( isset( $seen_senders[ $sender ] ) ) { - continue; - } - - $seen_senders[ $sender ] = true; - - // Fetch unsubscribe headers. - $raw = imap_fetchheader( $connection, $uid, FT_UID ); - $parsed = $this->parseUnsubscribeHeaders( $raw ); - - $to_process[] = array( - 'uid' => $uid, - 'sender' => $sender, - 'parsed' => $parsed, - ); - } - - imap_close( $connection ); - - // Now execute unsubscribes (connection closed — these are HTTP/mailto). - $results = array(); - $unsubscribed = 0; - $failed = 0; - $no_header = 0; - - foreach ( $to_process as $item ) { - $parsed = $item['parsed']; - - if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { - $results[] = array( - 'sender' => $item['sender'], - 'success' => false, - 'reason' => 'no List-Unsubscribe header', - ); - ++$no_header; - continue; - } - - $result = null; - - // Try One-Click POST first. - if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { - $result = $this->executeOneClickUnsubscribe( $parsed['urls'][0] ); - } - - // Fall back to URL. - if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['urls'] ) ) { - $result = $this->executeUrlUnsubscribe( $parsed['urls'][0] ); - } - - // Fall back to mailto. - if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['mailto'] ) ) { - $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); - } - - if ( $result && $result['success'] ) { - $results[] = array( - 'sender' => $item['sender'], - 'success' => true, - 'method' => $result['method'] ?? 'unknown', - ); - ++$unsubscribed; - } else { - $results[] = array( - 'sender' => $item['sender'], - 'success' => false, - 'reason' => $result['error'] ?? 'all methods failed', - ); - ++$failed; - } - } - - return array( - 'success' => true, - 'message' => sprintf( - 'Processed %d senders: %d unsubscribed, %d failed, %d had no header', - count( $to_process ), - $unsubscribed, - $failed, - $no_header - ), - 'results' => $results, - 'unsubscribed' => $unsubscribed, - 'failed' => $failed, - 'no_header' => $no_header, - ); - } - - /** - * Parse List-Unsubscribe and List-Unsubscribe-Post headers. - * - * @param string $raw_headers Raw email headers. - * @return array Parsed data with urls, mailto, has_one_click. - */ - private function parseUnsubscribeHeaders( string $raw_headers ): array { - $unsub_header = ''; - $post_header = ''; - $collecting = ''; - - foreach ( explode( "\n", $raw_headers ) as $line ) { - // Continuation line. - if ( $collecting && preg_match( '/^\s/', $line ) ) { - if ( 'unsub' === $collecting ) { - $unsub_header .= ' ' . trim( $line ); - } - if ( 'post' === $collecting ) { - $post_header .= ' ' . trim( $line ); - } - continue; - } - $collecting = ''; - - if ( stripos( $line, 'List-Unsubscribe:' ) === 0 ) { - $unsub_header = trim( substr( $line, 17 ) ); - $collecting = 'unsub'; - } - if ( stripos( $line, 'List-Unsubscribe-Post:' ) === 0 ) { - $post_header = trim( substr( $line, 22 ) ); - $collecting = 'post'; - } - } - - // Decode MIME-encoded headers. - if ( ! empty( $unsub_header ) ) { - $unsub_header = imap_utf8( $unsub_header ); - } - - // Extract URLs and mailto from angle brackets. - $urls = array(); - $mailto = ''; - - if ( preg_match_all( '/<([^>]+)>/', $unsub_header, $matches ) ) { - foreach ( $matches[1] as $value ) { - if ( strpos( $value, 'mailto:' ) === 0 ) { - $mailto = $value; - } elseif ( filter_var( $value, FILTER_VALIDATE_URL ) ) { - $urls[] = $value; - } - } - } - - $has_one_click = stripos( $post_header, 'List-Unsubscribe=One-Click' ) !== false; - - return array( - 'urls' => $urls, - 'mailto' => $mailto, - 'has_one_click' => $has_one_click, - 'raw' => $unsub_header, - ); - } - - /** - * RFC 8058 One-Click unsubscribe via HTTP POST. - */ - private function executeOneClickUnsubscribe( string $url ): array { - $response = wp_remote_post( $url, array( - 'body' => 'List-Unsubscribe=One-Click', - 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), - 'timeout' => 15, - ) ); - - if ( is_wp_error( $response ) ) { - return array( - 'success' => false, - 'error' => 'POST failed: ' . $response->get_error_message(), - ); - } - - $code = wp_remote_retrieve_response_code( $response ); - - // 2xx = success. Some return 200, others 204. - if ( $code >= 200 && $code < 300 ) { - return array( - 'success' => true, - 'message' => 'Unsubscribed via One-Click POST (HTTP ' . $code . ')', - 'method' => 'one-click-post', - ); - } - - return array( - 'success' => false, - 'error' => 'One-Click POST returned HTTP ' . $code, - ); - } - - /** - * Unsubscribe via URL (GET request). - */ - private function executeUrlUnsubscribe( string $url ): array { - $response = wp_remote_get( $url, array( - 'timeout' => 15, - 'sslverify' => true, - ) ); - - if ( is_wp_error( $response ) ) { - return array( - 'success' => false, - 'error' => 'GET failed: ' . $response->get_error_message(), - ); - } - - $code = wp_remote_retrieve_response_code( $response ); - - if ( $code >= 200 && $code < 400 ) { - return array( - 'success' => true, - 'message' => 'Unsubscribe request sent (HTTP ' . $code . ')', - 'method' => 'url-get', - ); - } - - return array( - 'success' => false, - 'error' => 'URL request returned HTTP ' . $code, - ); - } - - /** - * Unsubscribe via mailto: — send an email. - */ - private function executeMailtoUnsubscribe( string $mailto_uri ): array { - // Parse mailto:address?subject=... - $parts = wp_parse_url( $mailto_uri ); - $address = str_replace( 'mailto:', '', $parts['path'] ?? '' ); - - if ( empty( $address ) || ! is_email( $address ) ) { - return array( - 'success' => false, - 'error' => 'Invalid mailto address: ' . $mailto_uri, - ); - } - - $subject = ''; - if ( ! empty( $parts['query'] ) ) { - parse_str( $parts['query'], $query ); - $subject = $query['subject'] ?? 'unsubscribe'; - } - if ( empty( $subject ) ) { - $subject = 'unsubscribe'; - } - - $sent = wp_mail( $address, $subject, 'unsubscribe' ); - - if ( $sent ) { - return array( - 'success' => true, - 'message' => 'Unsubscribe email sent to ' . $address, - 'method' => 'mailto', - ); - } - - return array( - 'success' => false, - 'error' => 'Failed to send unsubscribe email', - ); - } - - /** - * Batch move: search → move all matches to destination. - */ - public function executeBatchMove( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $search = $input['search']; - $destination = $input['destination']; - $max = (int) ( $input['max'] ?? 500 ); - - $uids = imap_search( $connection, $search, SE_UID ); - if ( false === $uids || empty( $uids ) ) { - imap_close( $connection ); - return array( - 'success' => true, - 'message' => 'No messages matching search criteria', - 'moved_count' => 0, - 'total_matches' => 0, - ); - } - - $total = count( $uids ); - $to_move = array_slice( $uids, 0, $max ); - $moved = 0; - - // Use comma-separated UID range for batch operation (much faster than per-message). - $uid_set = implode( ',', $to_move ); - $result = imap_mail_move( $connection, $uid_set, $destination, CP_UID ); - - if ( $result ) { - $moved = count( $to_move ); - imap_expunge( $connection ); - } - - imap_close( $connection ); - - $message = sprintf( 'Moved %d messages to %s', $moved, $destination ); - if ( $total > $max ) { - $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); - } - - return array( - 'success' => true, - 'message' => $message, - 'moved_count' => $moved, - 'total_matches' => $total, - ); - } - - /** - * Batch flag: search → set/clear flag on all matches. - */ - public function executeBatchFlag( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $search = $input['search']; - $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); - $action = $input['action'] ?? 'set'; - $max = (int) ( $input['max'] ?? 500 ); - - $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); - if ( ! in_array( $flag, $valid_flags, true ) ) { - imap_close( $connection ); - return array( - 'success' => false, - 'error' => 'Invalid flag. Valid: Seen, Flagged, Answered, Deleted, Draft', - ); - } - - $uids = imap_search( $connection, $search, SE_UID ); - if ( false === $uids || empty( $uids ) ) { - imap_close( $connection ); - return array( - 'success' => true, - 'message' => 'No messages matching search criteria', - 'flagged_count' => 0, - 'total_matches' => 0, - ); - } - - $total = count( $uids ); - $to_flag = array_slice( $uids, 0, $max ); - $uid_set = implode( ',', $to_flag ); - - if ( 'clear' === $action ) { - imap_clearflag_full( $connection, $uid_set, $flag, ST_UID ); - } else { - imap_setflag_full( $connection, $uid_set, $flag, ST_UID ); - } - - imap_close( $connection ); - - $verb = 'clear' === $action ? 'cleared' : 'set'; - $message = sprintf( '%s %s on %d messages', ucfirst( $verb ), $flag, count( $to_flag ) ); - if ( $total > $max ) { - $message .= sprintf( ' (%d more remain)', $total - $max ); - } - - return array( - 'success' => true, - 'message' => $message, - 'flagged_count' => count( $to_flag ), - 'total_matches' => $total, - ); - } - - /** - * Batch delete: search → delete all matches. - */ - public function executeBatchDelete( array $input ): array { - $connection = $this->connect( $input['folder'] ?? 'INBOX' ); - if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { - return $connection; - } - - $search = $input['search']; - $max = (int) ( $input['max'] ?? 100 ); - - $uids = imap_search( $connection, $search, SE_UID ); - if ( false === $uids || empty( $uids ) ) { - imap_close( $connection ); - return array( - 'success' => true, - 'message' => 'No messages matching search criteria', - 'deleted_count' => 0, - 'total_matches' => 0, - ); - } - - $total = count( $uids ); - $to_delete = array_slice( $uids, 0, $max ); - $uid_set = implode( ',', $to_delete ); - - imap_delete( $connection, $uid_set, FT_UID ); - imap_expunge( $connection ); - imap_close( $connection ); - - $message = sprintf( 'Deleted %d messages', count( $to_delete ) ); - if ( $total > $max ) { - $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); - } - - return array( - 'success' => true, - 'message' => $message, - 'deleted_count' => count( $to_delete ), - 'total_matches' => $total, - ); - } - - /** - * Open an IMAP connection using stored credentials. - * - * @param string $folder Mail folder. - * @return resource|array IMAP connection or error array. - */ - private function connect( string $folder = 'INBOX' ) { - if ( ! function_exists( 'imap_open' ) ) { - return array( - 'success' => false, - 'error' => 'PHP IMAP extension is not installed', - ); - } - - $auth = $this->getAuthProvider(); - if ( ! $auth || ! $auth->is_authenticated() ) { - return array( - 'success' => false, - 'error' => 'IMAP credentials not configured', - ); - } - - $mailbox = $this->buildMailboxString( - $auth->getHost(), - $auth->getPort(), - $auth->getEncryption(), - $folder - ); - - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); - - if ( false === $connection ) { - return array( - 'success' => false, - 'error' => 'IMAP connection failed: ' . imap_last_error(), - ); - } - - return $connection; - } - - /** - * Get the IMAP auth provider. - * - * @return \DataMachine\Core\Steps\Fetch\Handlers\Email\EmailAuth|null - */ - private function getAuthProvider(): ?object { - $providers = apply_filters( 'datamachine_auth_providers', array() ); - return $providers['email_imap'] ?? null; - } - - /** - * Build IMAP mailbox connection string. - */ - private function buildMailboxString( string $host, int $port, string $encryption, string $folder ): string { - $flags = match ( $encryption ) { - 'ssl' => '/imap/ssl/validate-cert', - 'tls' => '/imap/tls/validate-cert', - default => '/imap/notls', - }; - - return sprintf( '{%s:%d%s}%s', $host, $port, $flags, $folder ); - } } diff --git a/inc/Abilities/Email/EmailAbilities/connect.php b/inc/Abilities/Email/EmailAbilities/connect.php new file mode 100644 index 000000000..551a7ffdc --- /dev/null +++ b/inc/Abilities/Email/EmailAbilities/connect.php @@ -0,0 +1,381 @@ +//! connect — extracted from EmailAbilities.php. + + + /** + * Delete an email from the IMAP server. + */ + public function executeDelete( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + imap_delete( $connection, (string) $uid, FT_UID ); + imap_expunge( $connection ); + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Message UID %d deleted', $uid ), + ); + } + + /** + * Move an email to a different folder. + */ + public function executeMove( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + $destination = $input['destination']; + + $moved = imap_mail_move( $connection, (string) $uid, $destination, CP_UID ); + if ( ! $moved ) { + $error = imap_last_error(); + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Move failed: ' . $error, + ); + } + + imap_expunge( $connection ); + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Message UID %d moved to %s', $uid, $destination ), + ); + } + + /** + * Set or clear a flag on an email. + */ + public function executeFlag( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); + + $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); + if ( ! in_array( $flag, $valid_flags, true ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Invalid flag. Valid flags: Seen, Flagged, Answered, Deleted, Draft', + ); + } + + $action = $input['action'] ?? 'set'; + if ( 'clear' === $action ) { + $result = imap_clearflag_full( $connection, (string) $uid, $flag, ST_UID ); + } else { + $result = imap_setflag_full( $connection, (string) $uid, $flag, ST_UID ); + } + + imap_close( $connection ); + + if ( ! $result ) { + return array( + 'success' => false, + 'error' => 'Failed to ' . $action . ' flag ' . $flag, + ); + } + + return array( + 'success' => true, + 'message' => sprintf( 'Flag %s %s on UID %d', $flag, $action === 'clear' ? 'cleared' : 'set', $uid ), + ); + } + + /** + * Test the IMAP connection with stored credentials. + */ + public function executeTestConnection( array $input ): array { + if ( ! function_exists( 'imap_open' ) ) { + return array( + 'success' => false, + 'error' => 'PHP IMAP extension is not installed', + ); + } + + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + return array( + 'success' => false, + 'error' => 'IMAP credentials not configured', + ); + } + + $mailbox = $this->buildMailboxString( + $auth->getHost(), + $auth->getPort(), + $auth->getEncryption(), + 'INBOX' + ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); + + if ( false === $connection ) { + return array( + 'success' => false, + 'error' => 'Connection failed: ' . imap_last_error(), + ); + } + + $check = imap_check( $connection ); + $info = array( + 'mailbox' => $check->Mailbox ?? '', + 'messages' => $check->Nmsgs ?? 0, + 'recent' => $check->Recent ?? 0, + 'connected' => true, + ); + + // List available folders. + $folders = imap_list( $connection, $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ), '*' ); + $folder_list = array(); + if ( is_array( $folders ) ) { + $prefix = $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ); + foreach ( $folders as $folder ) { + $folder_list[] = str_replace( $prefix, '', imap_utf7_decode( $folder ) ); + } + } + $info['folders'] = $folder_list; + + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Connected to %s — %d messages in INBOX', $auth->getHost(), $info['messages'] ), + 'mailbox_info' => $info, + ); + } + + /** + * Batch move: search → move all matches to destination. + */ + public function executeBatchMove( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $destination = $input['destination']; + $max = (int) ( $input['max'] ?? 500 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'moved_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_move = array_slice( $uids, 0, $max ); + $moved = 0; + + // Use comma-separated UID range for batch operation (much faster than per-message). + $uid_set = implode( ',', $to_move ); + $result = imap_mail_move( $connection, $uid_set, $destination, CP_UID ); + + if ( $result ) { + $moved = count( $to_move ); + imap_expunge( $connection ); + } + + imap_close( $connection ); + + $message = sprintf( 'Moved %d messages to %s', $moved, $destination ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'moved_count' => $moved, + 'total_matches' => $total, + ); + } + + /** + * Batch flag: search → set/clear flag on all matches. + */ + public function executeBatchFlag( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); + $action = $input['action'] ?? 'set'; + $max = (int) ( $input['max'] ?? 500 ); + + $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); + if ( ! in_array( $flag, $valid_flags, true ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Invalid flag. Valid: Seen, Flagged, Answered, Deleted, Draft', + ); + } + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'flagged_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_flag = array_slice( $uids, 0, $max ); + $uid_set = implode( ',', $to_flag ); + + if ( 'clear' === $action ) { + imap_clearflag_full( $connection, $uid_set, $flag, ST_UID ); + } else { + imap_setflag_full( $connection, $uid_set, $flag, ST_UID ); + } + + imap_close( $connection ); + + $verb = 'clear' === $action ? 'cleared' : 'set'; + $message = sprintf( '%s %s on %d messages', ucfirst( $verb ), $flag, count( $to_flag ) ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'flagged_count' => count( $to_flag ), + 'total_matches' => $total, + ); + } + + /** + * Batch delete: search → delete all matches. + */ + public function executeBatchDelete( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $max = (int) ( $input['max'] ?? 100 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'deleted_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_delete = array_slice( $uids, 0, $max ); + $uid_set = implode( ',', $to_delete ); + + imap_delete( $connection, $uid_set, FT_UID ); + imap_expunge( $connection ); + imap_close( $connection ); + + $message = sprintf( 'Deleted %d messages', count( $to_delete ) ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'deleted_count' => count( $to_delete ), + 'total_matches' => $total, + ); + } + + /** + * Open an IMAP connection using stored credentials. + * + * @param string $folder Mail folder. + * @return resource|array IMAP connection or error array. + */ + private function connect( string $folder = 'INBOX' ) { + if ( ! function_exists( 'imap_open' ) ) { + return array( + 'success' => false, + 'error' => 'PHP IMAP extension is not installed', + ); + } + + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + return array( + 'success' => false, + 'error' => 'IMAP credentials not configured', + ); + } + + $mailbox = $this->buildMailboxString( + $auth->getHost(), + $auth->getPort(), + $auth->getEncryption(), + $folder + ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); + + if ( false === $connection ) { + return array( + 'success' => false, + 'error' => 'IMAP connection failed: ' . imap_last_error(), + ); + } + + return $connection; + } + + /** + * Get the IMAP auth provider. + * + * @return \DataMachine\Core\Steps\Fetch\Handlers\Email\EmailAuth|null + */ + private function getAuthProvider(): ?object { + $providers = apply_filters( 'datamachine_auth_providers', array() ); + return $providers['email_imap'] ?? null; + } + + /** + * Build IMAP mailbox connection string. + */ + private function buildMailboxString( string $host, int $port, string $encryption, string $folder ): string { + $flags = match ( $encryption ) { + 'ssl' => '/imap/ssl/validate-cert', + 'tls' => '/imap/tls/validate-cert', + default => '/imap/notls', + }; + + return sprintf( '{%s:%d%s}%s', $host, $port, $flags, $folder ); + } diff --git a/inc/Abilities/Email/EmailAbilities/helpers.php b/inc/Abilities/Email/EmailAbilities/helpers.php new file mode 100644 index 000000000..2a82744fc --- /dev/null +++ b/inc/Abilities/Email/EmailAbilities/helpers.php @@ -0,0 +1,890 @@ +//! helpers — extracted from EmailAbilities.php. + + + public function __construct() { + if ( ! class_exists( 'WP_Ability' ) ) { + return; + } + + if ( self::$registered ) { + return; + } + + $this->registerAbilities(); + self::$registered = true; + } + + private function registerAbilities(): void { + $register_callback = function () { + // Reply to an email. + wp_register_ability( + 'datamachine/email-reply', + array( + 'label' => __( 'Reply to Email', 'data-machine' ), + 'description' => __( 'Send a reply to an email, maintaining thread headers', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'to', 'subject', 'body', 'in_reply_to' ), + 'properties' => array( + 'to' => array( + 'type' => 'string', + 'description' => __( 'Recipient email address', 'data-machine' ), + ), + 'subject' => array( + 'type' => 'string', + 'description' => __( 'Reply subject (typically Re: original subject)', 'data-machine' ), + ), + 'body' => array( + 'type' => 'string', + 'description' => __( 'Reply body content', 'data-machine' ), + ), + 'in_reply_to' => array( + 'type' => 'string', + 'description' => __( 'Message-ID of the email being replied to', 'data-machine' ), + ), + 'references' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'References header chain for threading', 'data-machine' ), + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'executeReply' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Delete an email via IMAP. + wp_register_ability( + 'datamachine/email-delete', + array( + 'label' => __( 'Delete Email', 'data-machine' ), + 'description' => __( 'Delete (expunge) an email by UID from the IMAP server', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to delete', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeDelete' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Move an email to a different folder. + wp_register_ability( + 'datamachine/email-move', + array( + 'label' => __( 'Move Email', 'data-machine' ), + 'description' => __( 'Move an email to a different IMAP folder', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid', 'destination' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to move', 'data-machine' ), + ), + 'destination' => array( + 'type' => 'string', + 'description' => __( 'Target folder (e.g., Archive, Trash, [Gmail]/All Mail)', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeMove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Flag/unflag an email. + wp_register_ability( + 'datamachine/email-flag', + array( + 'label' => __( 'Flag Email', 'data-machine' ), + 'description' => __( 'Set or clear IMAP flags on an email (Seen, Flagged, etc.)', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid', 'flag' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID', 'data-machine' ), + ), + 'flag' => array( + 'type' => 'string', + 'description' => __( 'IMAP flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + 'description' => __( 'set or clear the flag', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeFlag' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch move: search → move all matches. + wp_register_ability( + 'datamachine/email-batch-move', + array( + 'label' => __( 'Batch Move Emails', 'data-machine' ), + 'description' => __( 'Move all emails matching a search to a destination folder', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search', 'destination' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria (e.g., FROM "github.com")', 'data-machine' ), + ), + 'destination' => array( + 'type' => 'string', + 'description' => __( 'Target folder (e.g., [Gmail]/GitHub, Archive)', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + 'description' => __( 'Maximum messages to move (safety limit)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'moved_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchMove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch flag: search → flag/unflag all matches. + wp_register_ability( + 'datamachine/email-batch-flag', + array( + 'label' => __( 'Batch Flag Emails', 'data-machine' ), + 'description' => __( 'Set or clear a flag on all emails matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search', 'flag' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'flag' => array( + 'type' => 'string', + 'description' => __( 'Flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + 'description' => __( 'set or clear', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'flagged_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchFlag' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch delete: search → delete all matches. + wp_register_ability( + 'datamachine/email-batch-delete', + array( + 'label' => __( 'Batch Delete Emails', 'data-machine' ), + 'description' => __( 'Delete all emails matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 100, + 'description' => __( 'Maximum messages to delete (safety limit, lower default)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'deleted_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchDelete' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Unsubscribe from a mailing list. + wp_register_ability( + 'datamachine/email-unsubscribe', + array( + 'label' => __( 'Unsubscribe from Email', 'data-machine' ), + 'description' => __( 'Unsubscribe from a mailing list using List-Unsubscribe headers', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to unsubscribe from', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'method' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeUnsubscribe' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch unsubscribe from all matching senders. + wp_register_ability( + 'datamachine/email-batch-unsubscribe', + array( + 'label' => __( 'Batch Unsubscribe', 'data-machine' ), + 'description' => __( 'Unsubscribe from all mailing lists matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 20, + 'description' => __( 'Max unique senders to unsubscribe from (deduped by sender)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'results' => array( 'type' => 'array' ), + 'unsubscribed' => array( 'type' => 'integer' ), + 'failed' => array( 'type' => 'integer' ), + 'no_header' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchUnsubscribe' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Test IMAP connection. + wp_register_ability( + 'datamachine/email-test-connection', + array( + 'label' => __( 'Test Email Connection', 'data-machine' ), + 'description' => __( 'Test IMAP connection with stored credentials', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'mailbox_info' => array( 'type' => 'object' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeTestConnection' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Reply to an email with threading headers. + */ + public function executeReply( array $input ): array { + $headers = array(); + + $content_type = $input['content_type'] ?? 'text/html'; + $headers[] = "Content-Type: {$content_type}; charset=UTF-8"; + + // Threading headers. + if ( ! empty( $input['in_reply_to'] ) ) { + $headers[] = 'In-Reply-To: ' . $input['in_reply_to']; + } + + $references = $input['references'] ?? ''; + if ( ! empty( $input['in_reply_to'] ) ) { + $references = trim( $references . ' ' . $input['in_reply_to'] ); + } + if ( ! empty( $references ) ) { + $headers[] = 'References: ' . $references; + } + + if ( ! empty( $input['cc'] ) ) { + $cc_list = array_map( 'trim', explode( ',', $input['cc'] ) ); + foreach ( $cc_list as $cc ) { + if ( is_email( $cc ) ) { + $headers[] = 'Cc: ' . $cc; + } + } + } + + $to = array_map( 'trim', explode( ',', $input['to'] ) ); + $to = array_filter( $to, 'is_email' ); + + if ( empty( $to ) ) { + return array( + 'success' => false, + 'error' => 'No valid recipient address', + ); + } + + $sent = wp_mail( $to, $input['subject'], $input['body'], $headers ); + + if ( $sent ) { + return array( + 'success' => true, + 'message' => 'Reply sent to ' . implode( ', ', $to ), + 'logs' => array(), + ); + } + + global $phpmailer; + $error = 'wp_mail() returned false'; + if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { + $error = $phpmailer->ErrorInfo ?: $error; + } + + return array( + 'success' => false, + 'error' => $error, + ); + } + + /** + * Unsubscribe from a mailing list using List-Unsubscribe headers. + * + * Priority order: + * 1. List-Unsubscribe-Post + URL → HTTP POST (RFC 8058 one-click) + * 2. URL without Post header → HTTP POST attempt, fall back to GET + * 3. mailto: → send email via wp_mail() + */ + public function executeUnsubscribe( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + + // Fetch raw headers. + $raw_headers = imap_fetchheader( $connection, $uid, FT_UID ); + if ( empty( $raw_headers ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Could not fetch message headers', + ); + } + + $parsed = $this->parseUnsubscribeHeaders( $raw_headers ); + imap_close( $connection ); + + if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { + return array( + 'success' => false, + 'error' => 'No List-Unsubscribe header found in this message', + ); + } + + // Try One-Click POST first (RFC 8058). + if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { + $url = $parsed['urls'][0]; + $result = $this->executeOneClickUnsubscribe( $url ); + if ( $result['success'] ) { + return $result; + } + // Fall through to other methods if POST failed. + } + + // Try URL GET/POST. + if ( ! empty( $parsed['urls'] ) ) { + foreach ( $parsed['urls'] as $url ) { + $result = $this->executeUrlUnsubscribe( $url ); + if ( $result['success'] ) { + return $result; + } + } + } + + // Try mailto. + if ( ! empty( $parsed['mailto'] ) ) { + $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); + if ( $result['success'] ) { + return $result; + } + } + + return array( + 'success' => false, + 'error' => 'All unsubscribe methods failed', + ); + } + + /** + * Batch unsubscribe: search → unsubscribe from unique senders. + * + * Deduplicates by sender — if you have 100 emails from linkedin.com, + * it only unsubscribes once using the most recent message's headers. + */ + public function executeBatchUnsubscribe( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $max = (int) ( $input['max'] ?? 20 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'results' => array(), + 'unsubscribed' => 0, + 'failed' => 0, + 'no_header' => 0, + ); + } + + // Most recent first — we want the newest unsubscribe link per sender. + $uids = array_reverse( $uids ); + + // Deduplicate by sender — one unsubscribe per unique From address. + $seen_senders = array(); + $to_process = array(); + + foreach ( $uids as $uid ) { + if ( count( $to_process ) >= $max ) { + break; + } + + $msgno = imap_msgno( $connection, $uid ); + if ( 0 === $msgno ) { + continue; + } + + $header = imap_headerinfo( $connection, $msgno ); + if ( false === $header || empty( $header->from ) ) { + continue; + } + + $from = $header->from[0]; + $sender = $from->mailbox . '@' . $from->host; + + if ( isset( $seen_senders[ $sender ] ) ) { + continue; + } + + $seen_senders[ $sender ] = true; + + // Fetch unsubscribe headers. + $raw = imap_fetchheader( $connection, $uid, FT_UID ); + $parsed = $this->parseUnsubscribeHeaders( $raw ); + + $to_process[] = array( + 'uid' => $uid, + 'sender' => $sender, + 'parsed' => $parsed, + ); + } + + imap_close( $connection ); + + // Now execute unsubscribes (connection closed — these are HTTP/mailto). + $results = array(); + $unsubscribed = 0; + $failed = 0; + $no_header = 0; + + foreach ( $to_process as $item ) { + $parsed = $item['parsed']; + + if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { + $results[] = array( + 'sender' => $item['sender'], + 'success' => false, + 'reason' => 'no List-Unsubscribe header', + ); + ++$no_header; + continue; + } + + $result = null; + + // Try One-Click POST first. + if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { + $result = $this->executeOneClickUnsubscribe( $parsed['urls'][0] ); + } + + // Fall back to URL. + if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['urls'] ) ) { + $result = $this->executeUrlUnsubscribe( $parsed['urls'][0] ); + } + + // Fall back to mailto. + if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['mailto'] ) ) { + $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); + } + + if ( $result && $result['success'] ) { + $results[] = array( + 'sender' => $item['sender'], + 'success' => true, + 'method' => $result['method'] ?? 'unknown', + ); + ++$unsubscribed; + } else { + $results[] = array( + 'sender' => $item['sender'], + 'success' => false, + 'reason' => $result['error'] ?? 'all methods failed', + ); + ++$failed; + } + } + + return array( + 'success' => true, + 'message' => sprintf( + 'Processed %d senders: %d unsubscribed, %d failed, %d had no header', + count( $to_process ), + $unsubscribed, + $failed, + $no_header + ), + 'results' => $results, + 'unsubscribed' => $unsubscribed, + 'failed' => $failed, + 'no_header' => $no_header, + ); + } + + /** + * Parse List-Unsubscribe and List-Unsubscribe-Post headers. + * + * @param string $raw_headers Raw email headers. + * @return array Parsed data with urls, mailto, has_one_click. + */ + private function parseUnsubscribeHeaders( string $raw_headers ): array { + $unsub_header = ''; + $post_header = ''; + $collecting = ''; + + foreach ( explode( "\n", $raw_headers ) as $line ) { + // Continuation line. + if ( $collecting && preg_match( '/^\s/', $line ) ) { + if ( 'unsub' === $collecting ) { + $unsub_header .= ' ' . trim( $line ); + } + if ( 'post' === $collecting ) { + $post_header .= ' ' . trim( $line ); + } + continue; + } + $collecting = ''; + + if ( stripos( $line, 'List-Unsubscribe:' ) === 0 ) { + $unsub_header = trim( substr( $line, 17 ) ); + $collecting = 'unsub'; + } + if ( stripos( $line, 'List-Unsubscribe-Post:' ) === 0 ) { + $post_header = trim( substr( $line, 22 ) ); + $collecting = 'post'; + } + } + + // Decode MIME-encoded headers. + if ( ! empty( $unsub_header ) ) { + $unsub_header = imap_utf8( $unsub_header ); + } + + // Extract URLs and mailto from angle brackets. + $urls = array(); + $mailto = ''; + + if ( preg_match_all( '/<([^>]+)>/', $unsub_header, $matches ) ) { + foreach ( $matches[1] as $value ) { + if ( strpos( $value, 'mailto:' ) === 0 ) { + $mailto = $value; + } elseif ( filter_var( $value, FILTER_VALIDATE_URL ) ) { + $urls[] = $value; + } + } + } + + $has_one_click = stripos( $post_header, 'List-Unsubscribe=One-Click' ) !== false; + + return array( + 'urls' => $urls, + 'mailto' => $mailto, + 'has_one_click' => $has_one_click, + 'raw' => $unsub_header, + ); + } + + /** + * RFC 8058 One-Click unsubscribe via HTTP POST. + */ + private function executeOneClickUnsubscribe( string $url ): array { + $response = wp_remote_post( $url, array( + 'body' => 'List-Unsubscribe=One-Click', + 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'timeout' => 15, + ) ); + + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'error' => 'POST failed: ' . $response->get_error_message(), + ); + } + + $code = wp_remote_retrieve_response_code( $response ); + + // 2xx = success. Some return 200, others 204. + if ( $code >= 200 && $code < 300 ) { + return array( + 'success' => true, + 'message' => 'Unsubscribed via One-Click POST (HTTP ' . $code . ')', + 'method' => 'one-click-post', + ); + } + + return array( + 'success' => false, + 'error' => 'One-Click POST returned HTTP ' . $code, + ); + } + + /** + * Unsubscribe via URL (GET request). + */ + private function executeUrlUnsubscribe( string $url ): array { + $response = wp_remote_get( $url, array( + 'timeout' => 15, + 'sslverify' => true, + ) ); + + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'error' => 'GET failed: ' . $response->get_error_message(), + ); + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( $code >= 200 && $code < 400 ) { + return array( + 'success' => true, + 'message' => 'Unsubscribe request sent (HTTP ' . $code . ')', + 'method' => 'url-get', + ); + } + + return array( + 'success' => false, + 'error' => 'URL request returned HTTP ' . $code, + ); + } + + /** + * Unsubscribe via mailto: — send an email. + */ + private function executeMailtoUnsubscribe( string $mailto_uri ): array { + // Parse mailto:address?subject=... + $parts = wp_parse_url( $mailto_uri ); + $address = str_replace( 'mailto:', '', $parts['path'] ?? '' ); + + if ( empty( $address ) || ! is_email( $address ) ) { + return array( + 'success' => false, + 'error' => 'Invalid mailto address: ' . $mailto_uri, + ); + } + + $subject = ''; + if ( ! empty( $parts['query'] ) ) { + parse_str( $parts['query'], $query ); + $subject = $query['subject'] ?? 'unsubscribe'; + } + if ( empty( $subject ) ) { + $subject = 'unsubscribe'; + } + + $sent = wp_mail( $address, $subject, 'unsubscribe' ); + + if ( $sent ) { + return array( + 'success' => true, + 'message' => 'Unsubscribe email sent to ' . $address, + 'method' => 'mailto', + ); + } + + return array( + 'success' => false, + 'error' => 'Failed to send unsubscribe email', + ); + } diff --git a/inc/Abilities/Engine/ExecuteStepAbility.php b/inc/Abilities/Engine/ExecuteStepAbility.php index 37767fd0d..b1112e28c 100644 --- a/inc/Abilities/Engine/ExecuteStepAbility.php +++ b/inc/Abilities/Engine/ExecuteStepAbility.php @@ -681,7 +681,7 @@ private function markCompletedItemProcessed( int $job_id ): void { 'Deferred mark-as-processed on pipeline completion', array( 'job_id' => $job_id, - 'item_identifier' => $item_identifier, + 'item_identifier' => $item_identifier, 'source_type' => $source_type, 'fetch_flow_step_id' => $fetch_flow_step_id, ) diff --git a/inc/Abilities/Engine/PipelineBatchScheduler.php b/inc/Abilities/Engine/PipelineBatchScheduler.php index 34103a103..eafe2ec40 100644 --- a/inc/Abilities/Engine/PipelineBatchScheduler.php +++ b/inc/Abilities/Engine/PipelineBatchScheduler.php @@ -318,8 +318,8 @@ private function createChildJob( // its last step, ExecuteStepAbility reads these to mark the item as // processed. Previously set by FetchHandler::onItemProcessed() on the // parent, but now the fetch step no longer marks items eagerly. - $item_identifier = $single_packet['metadata']['item_identifier'] ?? null; - $source_type = $single_packet['metadata']['source_type'] ?? null; + $item_identifier = $single_packet['metadata']['item_identifier'] ?? null; + $source_type = $single_packet['metadata']['source_type'] ?? null; if ( ! empty( $item_identifier ) ) { $child_engine['item_identifier'] = $item_identifier; } diff --git a/inc/Abilities/EngineAbilities.php b/inc/Abilities/EngineAbilities.php index 5bd39c997..db004fc82 100644 --- a/inc/Abilities/EngineAbilities.php +++ b/inc/Abilities/EngineAbilities.php @@ -29,6 +29,7 @@ class EngineAbilities { private ScheduleFlowAbility $schedule_flow; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/Fetch/FetchEmailAbility.php b/inc/Abilities/Fetch/FetchEmailAbility.php index 800e90b23..d3d0fdc05 100644 --- a/inc/Abilities/Fetch/FetchEmailAbility.php +++ b/inc/Abilities/Fetch/FetchEmailAbility.php @@ -19,6 +19,7 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -118,8 +119,8 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'data' => array( + 'success' => array( 'type' => 'boolean' ), + 'data' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array' ), @@ -129,8 +130,8 @@ private function registerAbilities(): void { 'has_more' => array( 'type' => 'boolean' ), ), ), - 'error' => array( 'type' => 'string' ), - 'logs' => array( 'type' => 'array' ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), ), ), 'execute_callback' => array( $this, 'execute' ), @@ -225,7 +226,7 @@ public function execute( array $input ): array { 'offset' => 0, 'has_more' => false, ), - 'logs' => $logs, + 'logs' => $logs, ); } @@ -243,7 +244,7 @@ public function execute( array $input ): array { 'offset' => 0, 'has_more' => false, ), - 'logs' => $logs, + 'logs' => $logs, ); } @@ -312,7 +313,7 @@ public function execute( array $input ): array { 'offset' => $offset, 'has_more' => $has_more, ), - 'logs' => $logs, + 'logs' => $logs, ); } @@ -365,7 +366,7 @@ private function fetchHeaders( $connection, int $uid ): ?array { 'metadata' => array( 'uid' => $uid, 'message_id' => $message_id, - 'item_identifier' => $message_id, + 'item_identifier' => $message_id, 'from' => $from_email, 'from_name' => $from_name, 'to' => $to_address, @@ -439,7 +440,7 @@ private function fetchMessage( $connection, int $uid, array $config ): ?array { 'metadata' => array( 'uid' => $uid, 'message_id' => $message_id, - 'item_identifier' => $message_id, + 'item_identifier' => $message_id, 'from' => $from_email, 'from_name' => $from_name, 'to' => $to_address, @@ -536,7 +537,7 @@ private function fetchBody( $connection, int $uid ): string { private function decodeBody( string $body, int $encoding ): string { switch ( $encoding ) { case 3: // BASE64. - return base64_decode( $body, true ) ?: $body; + return base64_decode( $body, true ) ? base64_decode( $body, true ) : $body; case 4: // QUOTED-PRINTABLE. return quoted_printable_decode( $body ); case 1: // 8BIT. diff --git a/inc/Abilities/Fetch/FetchFilesAbility.php b/inc/Abilities/Fetch/FetchFilesAbility.php index d947f4cd5..f78cf4981 100644 --- a/inc/Abilities/Fetch/FetchFilesAbility.php +++ b/inc/Abilities/Fetch/FetchFilesAbility.php @@ -12,6 +12,7 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -172,12 +173,12 @@ public function execute( array $input ): array { ); $metadata = array( - 'source_type' => 'files', - 'item_identifier' => $file_identifier, - 'original_id' => $file_identifier, - 'original_title' => $file['original_name'], - 'original_date_gmt' => $file['uploaded_at'] ?? gmdate( 'Y-m-d H:i:s' ), - 'source_url' => '', + 'source_type' => 'files', + 'item_identifier' => $file_identifier, + 'original_id' => $file_identifier, + 'original_title' => $file['original_name'], + 'original_date_gmt' => $file['uploaded_at'] ?? gmdate( 'Y-m-d H:i:s' ), + 'source_url' => '', ); if ( $is_image ) { diff --git a/inc/Abilities/Fetch/FetchRssAbility.php b/inc/Abilities/Fetch/FetchRssAbility.php index 13c51f895..dec80cb62 100644 --- a/inc/Abilities/Fetch/FetchRssAbility.php +++ b/inc/Abilities/Fetch/FetchRssAbility.php @@ -11,6 +11,8 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Fetch\Traits\HasApplyKeywordSearch; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -254,15 +256,15 @@ function ( $error ) { $enclosure_url = $this->extractItemEnclosure( $item ); $metadata = array( - 'source_type' => 'rss', - 'item_identifier' => $guid, - 'original_id' => $guid, - 'original_title' => $title, - 'original_date_gmt' => $pub_date ? gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $pub_date ) ) : null, - 'author' => $author, - 'categories' => $categories, - 'guid' => $guid, - 'source_url' => $link ? $link : '', + 'source_type' => 'rss', + 'item_identifier' => $guid, + 'original_id' => $guid, + 'original_title' => $title, + 'original_date_gmt' => $pub_date ? gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $pub_date ) ) : null, + 'author' => $author, + 'categories' => $categories, + 'guid' => $guid, + 'source_url' => $link ? $link : '', ); $file_info = null; diff --git a/inc/Abilities/Fetch/FetchWordPressApiAbility.php b/inc/Abilities/Fetch/FetchWordPressApiAbility.php index 4feae750f..f1dc25d0a 100644 --- a/inc/Abilities/Fetch/FetchWordPressApiAbility.php +++ b/inc/Abilities/Fetch/FetchWordPressApiAbility.php @@ -12,6 +12,10 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Fetch\Traits\HasApplyKeywordSearch; +use DataMachine\Abilities\Fetch\FetchRssAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; +use DataMachine\Abilities\Fetch\FetchRssAbility; defined( 'ABSPATH' ) || exit; @@ -280,13 +284,13 @@ public function execute( array $input ): array { 'title' => $title, 'content' => wp_strip_all_tags( $content ), 'metadata' => array( - 'source_type' => 'rest_api', - 'item_identifier' => $unique_id, - 'original_id' => $item_id, - 'original_title' => $title, - 'original_date_gmt' => $item_date, - 'site_name' => $site_name, - 'source_url' => $source_link, + 'source_type' => 'rest_api', + 'item_identifier' => $unique_id, + 'original_id' => $item_id, + 'original_title' => $title, + 'original_date_gmt' => $item_date, + 'site_name' => $site_name, + 'source_url' => $source_link, ), ); diff --git a/inc/Abilities/Fetch/FetchWordPressMediaAbility.php b/inc/Abilities/Fetch/FetchWordPressMediaAbility.php index bc430fddd..922cfdd5a 100644 --- a/inc/Abilities/Fetch/FetchWordPressMediaAbility.php +++ b/inc/Abilities/Fetch/FetchWordPressMediaAbility.php @@ -11,6 +11,8 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Fetch\Traits\HasApplyKeywordSearch; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -262,18 +264,18 @@ public function execute( array $input ): array { 'title' => $content_data['title'] ?? '', 'content' => $content_data['content'] ?? '', 'metadata' => array( - 'source_type' => 'wordpress_media', - 'item_identifier' => $post->ID, - 'original_id' => $post->ID, - 'parent_post_id' => $post->post_parent, - 'original_title' => $title, - 'original_date_gmt' => $post->post_date_gmt, - 'mime_type' => $file_type, - 'file_size' => $file_size, - 'site_name' => $site_name, - 'source_url' => $source_url, - 'image_file_path' => strpos( $file_type, 'video/' ) !== 0 ? $file_path : '', - '_engine_data' => $engine_data, + 'source_type' => 'wordpress_media', + 'item_identifier' => $post->ID, + 'original_id' => $post->ID, + 'parent_post_id' => $post->post_parent, + 'original_title' => $title, + 'original_date_gmt' => $post->post_date_gmt, + 'mime_type' => $file_type, + 'file_size' => $file_size, + 'site_name' => $site_name, + 'source_url' => $source_url, + 'image_file_path' => strpos( $file_type, 'video/' ) !== 0 ? $file_path : '', + '_engine_data' => $engine_data, ), 'file_info' => $file_info, ); diff --git a/inc/Abilities/Fetch/GetWordPressPostAbility.php b/inc/Abilities/Fetch/GetWordPressPostAbility.php index 85f4474a9..268d9b143 100644 --- a/inc/Abilities/Fetch/GetWordPressPostAbility.php +++ b/inc/Abilities/Fetch/GetWordPressPostAbility.php @@ -11,6 +11,7 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Fetch/QueryWordPressPostsAbility.php b/inc/Abilities/Fetch/QueryWordPressPostsAbility.php index 5e2cb6deb..e8a8a14a1 100644 --- a/inc/Abilities/Fetch/QueryWordPressPostsAbility.php +++ b/inc/Abilities/Fetch/QueryWordPressPostsAbility.php @@ -11,6 +11,8 @@ namespace DataMachine\Abilities\Fetch; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Fetch\Traits\HasApplyKeywordSearch; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -273,17 +275,17 @@ public function execute( array $input ): array { 'title' => $title, 'content' => $content, 'metadata' => array( - 'source_type' => 'wordpress_local', - 'item_identifier' => $post_id, - 'original_id' => $post_id, - 'original_title' => $title, - 'original_date_gmt' => $post->post_date_gmt, - 'post_type' => $post->post_type, - 'post_status' => $post->post_status, - 'site_name' => $site_name, - 'permalink' => get_permalink( $post_id ) ?? '', - 'excerpt' => $post->post_excerpt, - 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ), + 'source_type' => 'wordpress_local', + 'item_identifier' => $post_id, + 'original_id' => $post_id, + 'original_title' => $title, + 'original_date_gmt' => $post->post_date_gmt, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'site_name' => $site_name, + 'permalink' => get_permalink( $post_id ) ?? '', + 'excerpt' => $post->post_excerpt, + 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ), ), ); diff --git a/inc/Abilities/File/AgentFileAbilities.php b/inc/Abilities/File/AgentFileAbilities.php index 135dc5c20..ac3511323 100644 --- a/inc/Abilities/File/AgentFileAbilities.php +++ b/inc/Abilities/File/AgentFileAbilities.php @@ -21,6 +21,8 @@ use DataMachine\Core\FilesRepository\DirectoryManager; use DataMachine\Core\FilesRepository\FilesystemHelper; use DataMachine\Engine\AI\MemoryFileRegistry; +use DataMachine\Abilities\Traits\HasCheckPermission; +use DataMachine\Abilities\File\FlowFileAbilities; defined( 'ABSPATH' ) || exit; @@ -348,23 +350,23 @@ public function executeListAgentFiles( array $input ): array { 'agent_id' => (int) ( $input['agent_id'] ?? 0 ), 'user_id' => $user_id, ); - $contexts_dir = $dm->get_contexts_directory( $agent_context ); + $contexts_dir = $dm->get_contexts_directory( $agent_context ); if ( is_dir( $contexts_dir ) ) { foreach ( glob( trailingslashit( $contexts_dir ) . '*.md' ) as $filepath ) { $filename = basename( $filepath ); $slug = pathinfo( $filename, PATHINFO_FILENAME ); $files[] = array( - 'filename' => $filename, - 'size' => filesize( $filepath ), - 'modified' => gmdate( 'c', filemtime( $filepath ) ), - 'type' => 'context', - 'layer' => 'context', - 'protected' => false, - 'editable' => true, - 'registered' => false, - 'label' => ucfirst( $slug ) . ' Context', - 'description' => "Context-scoped instructions loaded when execution context is '{$slug}'.", + 'filename' => $filename, + 'size' => filesize( $filepath ), + 'modified' => gmdate( 'c', filemtime( $filepath ) ), + 'type' => 'context', + 'layer' => 'context', + 'protected' => false, + 'editable' => true, + 'registered' => false, + 'label' => ucfirst( $slug ) . ' Context', + 'description' => "Context-scoped instructions loaded when execution context is '{$slug}'.", 'context_slug' => $slug, ); } diff --git a/inc/Abilities/File/FlowFileAbilities.php b/inc/Abilities/File/FlowFileAbilities.php index 9f48c4082..a082c782c 100644 --- a/inc/Abilities/File/FlowFileAbilities.php +++ b/inc/Abilities/File/FlowFileAbilities.php @@ -15,6 +15,7 @@ use DataMachine\Core\Database\Flows\Flows; use DataMachine\Core\FilesRepository\FileCleanup; use DataMachine\Core\FilesRepository\FileStorage; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Flow/FlowHelpers.php b/inc/Abilities/Flow/FlowHelpers.php index b06fa871a..47116f10d 100644 --- a/inc/Abilities/Flow/FlowHelpers.php +++ b/inc/Abilities/Flow/FlowHelpers.php @@ -19,6 +19,7 @@ use DataMachine\Core\Database\Flows\Flows; use DataMachine\Core\Database\Jobs\Jobs; use DataMachine\Core\Database\Pipelines\Pipelines; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Flow/PauseFlowAbility.php b/inc/Abilities/Flow/PauseFlowAbility.php index 577ad7149..aeac6d8ba 100644 --- a/inc/Abilities/Flow/PauseFlowAbility.php +++ b/inc/Abilities/Flow/PauseFlowAbility.php @@ -116,8 +116,8 @@ public function execute( array $input ): array { $details = array(); foreach ( $flows as $flow ) { - $fid = (int) $flow['flow_id']; - $scheduling = $flow['scheduling_config'] ?? array(); + $fid = (int) $flow['flow_id']; + $scheduling = $flow['scheduling_config'] ?? array(); // Already paused — skip. if ( isset( $scheduling['enabled'] ) && false === $scheduling['enabled'] ) { diff --git a/inc/Abilities/Flow/QueueAbility.php b/inc/Abilities/Flow/QueueAbility.php index bab09fee6..5d66b7fd3 100644 --- a/inc/Abilities/Flow/QueueAbility.php +++ b/inc/Abilities/Flow/QueueAbility.php @@ -19,1182 +19,4 @@ class QueueAbility { use FlowHelpers; - - public function __construct() { - $this->initDatabases(); - - if ( ! class_exists( 'WP_Ability' ) ) { - return; - } - - $this->registerAbilities(); - } - - /** - * Register all queue-related abilities. - */ - private function registerAbilities(): void { - $register_callback = function () { - $this->registerQueueAdd(); - $this->registerQueueList(); - $this->registerQueueClear(); - $this->registerQueueRemove(); - $this->registerQueueUpdate(); - $this->registerQueueMove(); - $this->registerQueueSettings(); - }; - - if ( doing_action( 'wp_abilities_api_init' ) ) { - $register_callback(); - } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { - add_action( 'wp_abilities_api_init', $register_callback ); - } - } - - /** - * Register queue-add ability. - */ - private function registerQueueAdd(): void { - wp_register_ability( - 'datamachine/queue-add', - array( - 'label' => __( 'Add to Queue', 'data-machine' ), - 'description' => __( 'Add a prompt to the flow queue.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id', 'prompt' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID to add prompt to', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID to add prompt to', 'data-machine' ), - ), - 'prompt' => array( - 'type' => 'string', - 'description' => __( 'Prompt text to queue', 'data-machine' ), - ), - 'skip_validation' => array( - 'type' => 'boolean', - 'description' => __( 'Skip duplicate validation (default: false). Use only when intentionally re-adding a known prompt.', 'data-machine' ), - ), - 'context' => array( - 'type' => 'object', - 'description' => __( 'Domain-specific context for duplicate detection strategies (e.g., venue, startDate for events).', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'queue_length' => array( 'type' => 'integer' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - 'reason' => array( 'type' => 'string' ), - 'match' => array( 'type' => 'object' ), - 'source' => array( 'type' => 'string' ), - 'strategy' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueAdd' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue-list ability. - */ - private function registerQueueList(): void { - wp_register_ability( - 'datamachine/queue-list', - array( - 'label' => __( 'List Queue', 'data-machine' ), - 'description' => __( 'List all prompts in the flow queue.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID to list queue for', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID to list queue for', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'queue' => array( 'type' => 'array' ), - 'count' => array( 'type' => 'integer' ), - 'queue_enabled' => array( 'type' => 'boolean' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueList' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue-clear ability. - */ - private function registerQueueClear(): void { - wp_register_ability( - 'datamachine/queue-clear', - array( - 'label' => __( 'Clear Queue', 'data-machine' ), - 'description' => __( 'Clear all prompts from the flow queue.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID to clear queue for', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID to clear queue for', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'cleared_count' => array( 'type' => 'integer' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueClear' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue-remove ability. - */ - private function registerQueueRemove(): void { - wp_register_ability( - 'datamachine/queue-remove', - array( - 'label' => __( 'Remove from Queue', 'data-machine' ), - 'description' => __( 'Remove a specific prompt from the queue by index.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id', 'index' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID', 'data-machine' ), - ), - 'index' => array( - 'type' => 'integer', - 'description' => __( 'Queue index to remove (0-based)', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'removed_prompt' => array( 'type' => 'string' ), - 'queue_length' => array( 'type' => 'integer' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueRemove' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue-update ability. - */ - private function registerQueueUpdate(): void { - wp_register_ability( - 'datamachine/queue-update', - array( - 'label' => __( 'Update Queue Item', 'data-machine' ), - 'description' => __( 'Update a prompt at a specific index in the flow queue.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id', 'index', 'prompt' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID', 'data-machine' ), - ), - 'index' => array( - 'type' => 'integer', - 'description' => __( 'Queue index to update (0-based)', 'data-machine' ), - ), - 'prompt' => array( - 'type' => 'string', - 'description' => __( 'New prompt text', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'index' => array( 'type' => 'integer' ), - 'queue_length' => array( 'type' => 'integer' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueUpdate' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue-move ability. - */ - private function registerQueueMove(): void { - wp_register_ability( - 'datamachine/queue-move', - array( - 'label' => __( 'Move Queue Item', 'data-machine' ), - 'description' => __( 'Move a prompt from one position to another in the queue.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id', 'from_index', 'to_index' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID', 'data-machine' ), - ), - 'from_index' => array( - 'type' => 'integer', - 'description' => __( 'Current index of item to move (0-based)', 'data-machine' ), - ), - 'to_index' => array( - 'type' => 'integer', - 'description' => __( 'Target index to move item to (0-based)', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'from_index' => array( 'type' => 'integer' ), - 'to_index' => array( 'type' => 'integer' ), - 'queue_length' => array( 'type' => 'integer' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueMove' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Register queue settings ability. - */ - private function registerQueueSettings(): void { - wp_register_ability( - 'datamachine/queue-settings', - array( - 'label' => __( 'Update Queue Settings', 'data-machine' ), - 'description' => __( 'Update queue settings for a flow step.', 'data-machine' ), - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'flow_id', 'flow_step_id', 'queue_enabled' ), - 'properties' => array( - 'flow_id' => array( - 'type' => 'integer', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - 'flow_step_id' => array( - 'type' => 'string', - 'description' => __( 'Flow step ID', 'data-machine' ), - ), - 'queue_enabled' => array( - 'type' => 'boolean', - 'description' => __( 'Whether queue pop is enabled for this step', 'data-machine' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_step_id' => array( 'type' => 'string' ), - 'queue_enabled' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( $this, 'executeQueueSettings' ), - 'permission_callback' => array( $this, 'checkPermission' ), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - } - - /** - * Add a prompt to the flow queue. - * - * @param array $input Input with flow_id and prompt. - * @return array Result. - */ - public function executeQueueAdd( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - $prompt = $input['prompt'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - if ( empty( $prompt ) || ! is_string( $prompt ) ) { - return array( - 'success' => false, - 'error' => 'prompt is required and must be a non-empty string', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - $prompt = sanitize_textarea_field( wp_unslash( $prompt ) ); - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $step_config = $validation['step_config']; - $prompt_queue = $step_config['prompt_queue']; - - // Duplicate validation (unless explicitly skipped). - $skip_validation = ! empty( $input['skip_validation'] ); - if ( ! $skip_validation ) { - // Resolve post_type from the flow's publish step handler config. - // Without this, the validator defaults to 'post' and misses duplicates - // for custom post types (quizzes, recipes, events, etc.). - $post_type = $input['post_type'] ?? $this->resolvePublishPostType( $flow_config ); - - $dedup = new DuplicateCheckAbility(); - $result = $dedup->executeCheckDuplicate( array( - 'title' => $prompt, - 'post_type' => $post_type, - 'scope' => 'both', - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'context' => $input['context'] ?? array(), - ) ); - - if ( 'duplicate' === $result['verdict'] ) { - return array( - 'success' => false, - 'error' => 'duplicate_rejected', - 'reason' => $result['reason'] ?? '', - 'match' => $result['match'] ?? array(), - 'source' => $result['source'] ?? 'unknown', - 'strategy' => $result['strategy'] ?? '', - 'flow_id' => $flow_id, - 'message' => sprintf( 'Rejected: "%s" is a duplicate. %s', $prompt, $result['reason'] ?? '' ), - ); - } - } - - $prompt_queue[] = array( - 'prompt' => $prompt, - 'added_at' => gmdate( 'c' ), - ); - - $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to update flow queue', - ); - } - - do_action( - 'datamachine_log', - 'info', - 'Prompt added to queue', - array( - 'flow_id' => $flow_id, - 'queue_length' => count( $prompt_queue ), - ) - ); - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'queue_length' => count( $prompt_queue ), - 'message' => sprintf( 'Prompt added to queue. Queue now has %d item(s).', count( $prompt_queue ) ), - ); - } - - /** - * List all prompts in the flow queue. - * - * @param array $input Input with flow_id. - * @return array Result with queue items. - */ - public function executeQueueList( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $step_config = $validation['step_config']; - $prompt_queue = $step_config['prompt_queue']; - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'queue' => $prompt_queue, - 'count' => count( $prompt_queue ), - 'queue_enabled' => $step_config['queue_enabled'], - ); - } - - /** - * Clear all prompts from the flow queue. - * - * @param array $input Input with flow_id. - * @return array Result. - */ - public function executeQueueClear( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $step_config = $validation['step_config']; - $cleared_count = count( $step_config['prompt_queue'] ); - - $flow_config[ $flow_step_id ]['prompt_queue'] = array(); - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to clear queue', - ); - } - - do_action( - 'datamachine_log', - 'info', - 'Queue cleared', - array( - 'flow_id' => $flow_id, - 'cleared_count' => $cleared_count, - ) - ); - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'cleared_count' => $cleared_count, - 'message' => sprintf( 'Cleared %d prompt(s) from queue.', $cleared_count ), - ); - } - - /** - * Remove a specific prompt from the queue by index. - * - * @param array $input Input with flow_id and index. - * @return array Result. - */ - public function executeQueueRemove( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - $index = $input['index'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - if ( ! is_numeric( $index ) || (int) $index < 0 ) { - return array( - 'success' => false, - 'error' => 'index is required and must be a non-negative integer', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - $index = (int) $index; - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $step_config = $validation['step_config']; - $prompt_queue = $step_config['prompt_queue']; - - if ( $index >= count( $prompt_queue ) ) { - return array( - 'success' => false, - 'error' => sprintf( 'Index %d is out of range. Queue has %d item(s).', $index, count( $prompt_queue ) ), - ); - } - - $removed_item = $prompt_queue[ $index ]; - $removed_prompt = $removed_item['prompt'] ?? ''; - - array_splice( $prompt_queue, $index, 1 ); - - $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to remove prompt from queue', - ); - } - - do_action( - 'datamachine_log', - 'info', - 'Prompt removed from queue', - array( - 'flow_id' => $flow_id, - 'index' => $index, - 'queue_length' => count( $prompt_queue ), - ) - ); - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'removed_prompt' => $removed_prompt, - 'queue_length' => count( $prompt_queue ), - 'message' => sprintf( 'Removed prompt at index %d. Queue now has %d item(s).', $index, count( $prompt_queue ) ), - ); - } - - /** - * Update a prompt at a specific index in the queue. - * - * If the index is 0 and the queue is empty, creates a new item. - * - * @param array $input Input with flow_id, index, and prompt. - * @return array Result. - */ - public function executeQueueUpdate( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - $index = $input['index'] ?? null; - $prompt = $input['prompt'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - if ( ! is_numeric( $index ) || (int) $index < 0 ) { - return array( - 'success' => false, - 'error' => 'index is required and must be a non-negative integer', - ); - } - - if ( ! is_string( $prompt ) ) { - return array( - 'success' => false, - 'error' => 'prompt is required and must be a string', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - $index = (int) $index; - $prompt = sanitize_textarea_field( wp_unslash( $prompt ) ); - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $step_config = $validation['step_config']; - $prompt_queue = $step_config['prompt_queue']; - - // Special case: if index is 0 and queue is empty, create a new item - if ( 0 === $index && empty( $prompt_queue ) ) { - // If prompt is empty, don't create anything - if ( '' === $prompt ) { - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'index' => $index, - 'queue_length' => 0, - 'message' => 'No changes made (empty prompt, empty queue).', - ); - } - - $prompt_queue[] = array( - 'prompt' => $prompt, - 'added_at' => gmdate( 'c' ), - ); - } elseif ( $index >= count( $prompt_queue ) ) { - return array( - 'success' => false, - 'error' => sprintf( 'Index %d is out of range. Queue has %d item(s).', $index, count( $prompt_queue ) ), - ); - } else { - // Update existing item, preserving added_at - $prompt_queue[ $index ]['prompt'] = $prompt; - } - - $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to update queue', - ); - } - - do_action( - 'datamachine_log', - 'info', - 'Queue item updated', - array( - 'flow_id' => $flow_id, - 'index' => $index, - 'queue_length' => count( $prompt_queue ), - ) - ); - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'index' => $index, - 'queue_length' => count( $prompt_queue ), - 'message' => sprintf( 'Updated prompt at index %d. Queue has %d item(s).', $index, count( $prompt_queue ) ), - ); - } - - /** - * Move a prompt from one position to another in the queue. - * - * @param array $input Input with flow_id, flow_step_id, from_index, and to_index. - * @return array Result. - */ - public function executeQueueMove( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - $from_index = $input['from_index'] ?? null; - $to_index = $input['to_index'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - if ( ! is_numeric( $from_index ) || (int) $from_index < 0 ) { - return array( - 'success' => false, - 'error' => 'from_index is required and must be a non-negative integer', - ); - } - - if ( ! is_numeric( $to_index ) || (int) $to_index < 0 ) { - return array( - 'success' => false, - 'error' => 'to_index is required and must be a non-negative integer', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - $from_index = (int) $from_index; - $to_index = (int) $to_index; - - if ( $from_index === $to_index ) { - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'from_index' => $from_index, - 'to_index' => $to_index, - 'queue_length' => 0, - 'message' => 'No move needed (same position).', - ); - } - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $step_config = $validation['step_config']; - $prompt_queue = $step_config['prompt_queue']; - $queue_length = count( $prompt_queue ); - - if ( $from_index >= $queue_length ) { - return array( - 'success' => false, - 'error' => sprintf( 'from_index %d is out of range. Queue has %d item(s).', $from_index, $queue_length ), - ); - } - - if ( $to_index >= $queue_length ) { - return array( - 'success' => false, - 'error' => sprintf( 'to_index %d is out of range. Queue has %d item(s).', $to_index, $queue_length ), - ); - } - - // Extract the item and reinsert at new position - $item = $prompt_queue[ $from_index ]; - array_splice( $prompt_queue, $from_index, 1 ); - array_splice( $prompt_queue, $to_index, 0, array( $item ) ); - - $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to move queue item', - ); - } - - do_action( - 'datamachine_log', - 'info', - 'Queue item moved', - array( - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'from_index' => $from_index, - 'to_index' => $to_index, - 'queue_length' => $queue_length, - ) - ); - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'from_index' => $from_index, - 'to_index' => $to_index, - 'queue_length' => $queue_length, - 'message' => sprintf( 'Moved item from index %d to %d.', $from_index, $to_index ), - ); - } - - /** - * Update queue settings for a flow step. - * - * @param array $input Input with flow_id, flow_step_id, and queue_enabled. - * @return array Result. - */ - public function executeQueueSettings( array $input ): array { - $flow_id = $input['flow_id'] ?? null; - $flow_step_id = $input['flow_step_id'] ?? null; - $queue_enabled = $input['queue_enabled'] ?? null; - - if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { - return array( - 'success' => false, - 'error' => 'flow_id is required and must be a positive integer', - ); - } - - if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { - return array( - 'success' => false, - 'error' => 'flow_step_id is required and must be a string', - ); - } - - if ( ! is_bool( $queue_enabled ) ) { - return array( - 'success' => false, - 'error' => 'queue_enabled is required and must be a boolean', - ); - } - - $flow_id = (int) $flow_id; - $flow_step_id = sanitize_text_field( $flow_step_id ); - - $flow = $this->db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return array( - 'success' => false, - 'error' => 'Flow not found', - ); - } - - $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); - if ( ! $validation['success'] ) { - return $validation; - } - - $flow_config = $validation['flow_config']; - $flow_config[ $flow_step_id ]['queue_enabled'] = $queue_enabled; - - $success = $this->db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - if ( ! $success ) { - return array( - 'success' => false, - 'error' => 'Failed to update queue settings', - ); - } - - return array( - 'success' => true, - 'flow_id' => $flow_id, - 'flow_step_id' => $flow_step_id, - 'queue_enabled' => $queue_enabled, - 'message' => 'Queue settings updated successfully', - ); - } - - /** - * Pop the first prompt from the queue (for engine use). - * - * @param int $flow_id Flow ID. - * @param DB_Flows $db_flows Database instance (avoids creating new instance each call). - * @return array|null The popped queue item or null if empty. - */ - public static function popFromQueue( int $flow_id, string $flow_step_id, ?DB_Flows $db_flows = null ): ?array { - if ( null === $db_flows ) { - $db_flows = new DB_Flows(); - } - - $flow = $db_flows->get_flow( $flow_id ); - if ( ! $flow ) { - return null; - } - - $flow_config = $flow['flow_config'] ?? array(); - if ( ! isset( $flow_config[ $flow_step_id ] ) ) { - return null; - } - - $step_config = $flow_config[ $flow_step_id ]; - $prompt_queue = $step_config['prompt_queue'] ?? array(); - - if ( empty( $prompt_queue ) ) { - return null; - } - - $popped_item = array_shift( $prompt_queue ); - - $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; - - $db_flows->update_flow( - $flow_id, - array( 'flow_config' => $flow_config ) - ); - - do_action( - 'datamachine_log', - 'info', - 'Prompt popped from queue', - array( - 'flow_id' => $flow_id, - 'remaining_count' => count( $prompt_queue ), - ) - ); - - return $popped_item; - } - - /** - * Resolve the post_type from the flow's publish step handler config. - * - * Scans all steps in the flow config for a publish step and extracts - * the post_type from its handler config. Falls back to 'post' if no - * publish step is found or no post_type is configured. - * - * @param array $flow_config The flow configuration array keyed by flow_step_id. - * @return string The resolved post type. - */ - private function resolvePublishPostType( array $flow_config ): string { - foreach ( $flow_config as $step_config ) { - if ( ! is_array( $step_config ) ) { - continue; - } - if ( ( $step_config['step_type'] ?? '' ) !== 'publish' ) { - continue; - } - $handler_configs = $step_config['handler_configs'] ?? array(); - foreach ( $handler_configs as $handler_config ) { - if ( ! empty( $handler_config['post_type'] ) ) { - return sanitize_text_field( $handler_config['post_type'] ); - } - } - } - return 'post'; - } - - /** - * Normalize flow step queue config for queue operations. - * - * @param array $flow Flow record. - * @param string $flow_step_id Flow step ID. - * @return array Result with flow_config and step_config or error. - */ - private function getStepConfigForQueue( array $flow, string $flow_step_id ): array { - $flow_id = (int) ( $flow['flow_id'] ?? 0 ); - $parts = apply_filters( 'datamachine_split_flow_step_id', null, $flow_step_id ); - if ( ! $parts || empty( $parts['flow_id'] ) ) { - return array( - 'success' => false, - 'error' => 'Invalid flow_step_id format', - ); - } - if ( (int) $parts['flow_id'] !== $flow_id ) { - return array( - 'success' => false, - 'error' => 'flow_step_id does not belong to this flow', - ); - } - - $flow_config = $flow['flow_config'] ?? array(); - if ( ! isset( $flow_config[ $flow_step_id ] ) ) { - return array( - 'success' => false, - 'error' => 'Flow step not found in flow config', - ); - } - - $step_config = $flow_config[ $flow_step_id ]; - if ( ! isset( $step_config['prompt_queue'] ) || ! is_array( $step_config['prompt_queue'] ) ) { - $step_config['prompt_queue'] = array(); - } - if ( ! isset( $step_config['queue_enabled'] ) || ! is_bool( $step_config['queue_enabled'] ) ) { - $step_config['queue_enabled'] = false; - } - $flow_config[ $flow_step_id ] = $step_config; - - return array( - 'success' => true, - 'flow_config' => $flow_config, - 'step_config' => $step_config, - ); - } } diff --git a/inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php b/inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php new file mode 100644 index 000000000..3bda96e05 --- /dev/null +++ b/inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php @@ -0,0 +1,1098 @@ +//! getStepConfigForQueue — extracted from QueueAbility.php. + + + /** + * Register queue-add ability. + */ + private function registerQueueAdd(): void { + wp_register_ability( + 'datamachine/queue-add', + array( + 'label' => __( 'Add to Queue', 'data-machine' ), + 'description' => __( 'Add a prompt to the flow queue.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id', 'prompt' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID to add prompt to', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID to add prompt to', 'data-machine' ), + ), + 'prompt' => array( + 'type' => 'string', + 'description' => __( 'Prompt text to queue', 'data-machine' ), + ), + 'skip_validation' => array( + 'type' => 'boolean', + 'description' => __( 'Skip duplicate validation (default: false). Use only when intentionally re-adding a known prompt.', 'data-machine' ), + ), + 'context' => array( + 'type' => 'object', + 'description' => __( 'Domain-specific context for duplicate detection strategies (e.g., venue, startDate for events).', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'queue_length' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + 'reason' => array( 'type' => 'string' ), + 'match' => array( 'type' => 'object' ), + 'source' => array( 'type' => 'string' ), + 'strategy' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueAdd' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue-list ability. + */ + private function registerQueueList(): void { + wp_register_ability( + 'datamachine/queue-list', + array( + 'label' => __( 'List Queue', 'data-machine' ), + 'description' => __( 'List all prompts in the flow queue.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID to list queue for', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID to list queue for', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'queue' => array( 'type' => 'array' ), + 'count' => array( 'type' => 'integer' ), + 'queue_enabled' => array( 'type' => 'boolean' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueList' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue-clear ability. + */ + private function registerQueueClear(): void { + wp_register_ability( + 'datamachine/queue-clear', + array( + 'label' => __( 'Clear Queue', 'data-machine' ), + 'description' => __( 'Clear all prompts from the flow queue.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID to clear queue for', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID to clear queue for', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'cleared_count' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueClear' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue-remove ability. + */ + private function registerQueueRemove(): void { + wp_register_ability( + 'datamachine/queue-remove', + array( + 'label' => __( 'Remove from Queue', 'data-machine' ), + 'description' => __( 'Remove a specific prompt from the queue by index.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id', 'index' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID', 'data-machine' ), + ), + 'index' => array( + 'type' => 'integer', + 'description' => __( 'Queue index to remove (0-based)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'removed_prompt' => array( 'type' => 'string' ), + 'queue_length' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueRemove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue-update ability. + */ + private function registerQueueUpdate(): void { + wp_register_ability( + 'datamachine/queue-update', + array( + 'label' => __( 'Update Queue Item', 'data-machine' ), + 'description' => __( 'Update a prompt at a specific index in the flow queue.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id', 'index', 'prompt' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID', 'data-machine' ), + ), + 'index' => array( + 'type' => 'integer', + 'description' => __( 'Queue index to update (0-based)', 'data-machine' ), + ), + 'prompt' => array( + 'type' => 'string', + 'description' => __( 'New prompt text', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'index' => array( 'type' => 'integer' ), + 'queue_length' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueUpdate' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue-move ability. + */ + private function registerQueueMove(): void { + wp_register_ability( + 'datamachine/queue-move', + array( + 'label' => __( 'Move Queue Item', 'data-machine' ), + 'description' => __( 'Move a prompt from one position to another in the queue.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id', 'from_index', 'to_index' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID', 'data-machine' ), + ), + 'from_index' => array( + 'type' => 'integer', + 'description' => __( 'Current index of item to move (0-based)', 'data-machine' ), + ), + 'to_index' => array( + 'type' => 'integer', + 'description' => __( 'Target index to move item to (0-based)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'from_index' => array( 'type' => 'integer' ), + 'to_index' => array( 'type' => 'integer' ), + 'queue_length' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueMove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Register queue settings ability. + */ + private function registerQueueSettings(): void { + wp_register_ability( + 'datamachine/queue-settings', + array( + 'label' => __( 'Update Queue Settings', 'data-machine' ), + 'description' => __( 'Update queue settings for a flow step.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'flow_id', 'flow_step_id', 'queue_enabled' ), + 'properties' => array( + 'flow_id' => array( + 'type' => 'integer', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + 'flow_step_id' => array( + 'type' => 'string', + 'description' => __( 'Flow step ID', 'data-machine' ), + ), + 'queue_enabled' => array( + 'type' => 'boolean', + 'description' => __( 'Whether queue pop is enabled for this step', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'flow_id' => array( 'type' => 'integer' ), + 'flow_step_id' => array( 'type' => 'string' ), + 'queue_enabled' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeQueueSettings' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Add a prompt to the flow queue. + * + * @param array $input Input with flow_id and prompt. + * @return array Result. + */ + public function executeQueueAdd( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + $prompt = $input['prompt'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + if ( empty( $prompt ) || ! is_string( $prompt ) ) { + return array( + 'success' => false, + 'error' => 'prompt is required and must be a non-empty string', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + $prompt = sanitize_textarea_field( wp_unslash( $prompt ) ); + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $step_config = $validation['step_config']; + $prompt_queue = $step_config['prompt_queue']; + + // Duplicate validation (unless explicitly skipped). + $skip_validation = ! empty( $input['skip_validation'] ); + if ( ! $skip_validation ) { + // Resolve post_type from the flow's publish step handler config. + // Without this, the validator defaults to 'post' and misses duplicates + // for custom post types (quizzes, recipes, events, etc.). + $post_type = $input['post_type'] ?? $this->resolvePublishPostType( $flow_config ); + + $dedup = new DuplicateCheckAbility(); + $result = $dedup->executeCheckDuplicate( array( + 'title' => $prompt, + 'post_type' => $post_type, + 'scope' => 'both', + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'context' => $input['context'] ?? array(), + ) ); + + if ( 'duplicate' === $result['verdict'] ) { + return array( + 'success' => false, + 'error' => 'duplicate_rejected', + 'reason' => $result['reason'] ?? '', + 'match' => $result['match'] ?? array(), + 'source' => $result['source'] ?? 'unknown', + 'strategy' => $result['strategy'] ?? '', + 'flow_id' => $flow_id, + 'message' => sprintf( 'Rejected: "%s" is a duplicate. %s', $prompt, $result['reason'] ?? '' ), + ); + } + } + + $prompt_queue[] = array( + 'prompt' => $prompt, + 'added_at' => gmdate( 'c' ), + ); + + $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to update flow queue', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Prompt added to queue', + array( + 'flow_id' => $flow_id, + 'queue_length' => count( $prompt_queue ), + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'queue_length' => count( $prompt_queue ), + 'message' => sprintf( 'Prompt added to queue. Queue now has %d item(s).', count( $prompt_queue ) ), + ); + } + + /** + * List all prompts in the flow queue. + * + * @param array $input Input with flow_id. + * @return array Result with queue items. + */ + public function executeQueueList( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $step_config = $validation['step_config']; + $prompt_queue = $step_config['prompt_queue']; + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'queue' => $prompt_queue, + 'count' => count( $prompt_queue ), + 'queue_enabled' => $step_config['queue_enabled'], + ); + } + + /** + * Clear all prompts from the flow queue. + * + * @param array $input Input with flow_id. + * @return array Result. + */ + public function executeQueueClear( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $step_config = $validation['step_config']; + $cleared_count = count( $step_config['prompt_queue'] ); + + $flow_config[ $flow_step_id ]['prompt_queue'] = array(); + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to clear queue', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Queue cleared', + array( + 'flow_id' => $flow_id, + 'cleared_count' => $cleared_count, + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'cleared_count' => $cleared_count, + 'message' => sprintf( 'Cleared %d prompt(s) from queue.', $cleared_count ), + ); + } + + /** + * Remove a specific prompt from the queue by index. + * + * @param array $input Input with flow_id and index. + * @return array Result. + */ + public function executeQueueRemove( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + $index = $input['index'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + if ( ! is_numeric( $index ) || (int) $index < 0 ) { + return array( + 'success' => false, + 'error' => 'index is required and must be a non-negative integer', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + $index = (int) $index; + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $step_config = $validation['step_config']; + $prompt_queue = $step_config['prompt_queue']; + + if ( $index >= count( $prompt_queue ) ) { + return array( + 'success' => false, + 'error' => sprintf( 'Index %d is out of range. Queue has %d item(s).', $index, count( $prompt_queue ) ), + ); + } + + $removed_item = $prompt_queue[ $index ]; + $removed_prompt = $removed_item['prompt'] ?? ''; + + array_splice( $prompt_queue, $index, 1 ); + + $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to remove prompt from queue', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Prompt removed from queue', + array( + 'flow_id' => $flow_id, + 'index' => $index, + 'queue_length' => count( $prompt_queue ), + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'removed_prompt' => $removed_prompt, + 'queue_length' => count( $prompt_queue ), + 'message' => sprintf( 'Removed prompt at index %d. Queue now has %d item(s).', $index, count( $prompt_queue ) ), + ); + } + + /** + * Update a prompt at a specific index in the queue. + * + * If the index is 0 and the queue is empty, creates a new item. + * + * @param array $input Input with flow_id, index, and prompt. + * @return array Result. + */ + public function executeQueueUpdate( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + $index = $input['index'] ?? null; + $prompt = $input['prompt'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + if ( ! is_numeric( $index ) || (int) $index < 0 ) { + return array( + 'success' => false, + 'error' => 'index is required and must be a non-negative integer', + ); + } + + if ( ! is_string( $prompt ) ) { + return array( + 'success' => false, + 'error' => 'prompt is required and must be a string', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + $index = (int) $index; + $prompt = sanitize_textarea_field( wp_unslash( $prompt ) ); + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $step_config = $validation['step_config']; + $prompt_queue = $step_config['prompt_queue']; + + // Special case: if index is 0 and queue is empty, create a new item + if ( 0 === $index && empty( $prompt_queue ) ) { + // If prompt is empty, don't create anything + if ( '' === $prompt ) { + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'index' => $index, + 'queue_length' => 0, + 'message' => 'No changes made (empty prompt, empty queue).', + ); + } + + $prompt_queue[] = array( + 'prompt' => $prompt, + 'added_at' => gmdate( 'c' ), + ); + } elseif ( $index >= count( $prompt_queue ) ) { + return array( + 'success' => false, + 'error' => sprintf( 'Index %d is out of range. Queue has %d item(s).', $index, count( $prompt_queue ) ), + ); + } else { + // Update existing item, preserving added_at + $prompt_queue[ $index ]['prompt'] = $prompt; + } + + $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to update queue', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Queue item updated', + array( + 'flow_id' => $flow_id, + 'index' => $index, + 'queue_length' => count( $prompt_queue ), + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'index' => $index, + 'queue_length' => count( $prompt_queue ), + 'message' => sprintf( 'Updated prompt at index %d. Queue has %d item(s).', $index, count( $prompt_queue ) ), + ); + } + + /** + * Move a prompt from one position to another in the queue. + * + * @param array $input Input with flow_id, flow_step_id, from_index, and to_index. + * @return array Result. + */ + public function executeQueueMove( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + $from_index = $input['from_index'] ?? null; + $to_index = $input['to_index'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + if ( ! is_numeric( $from_index ) || (int) $from_index < 0 ) { + return array( + 'success' => false, + 'error' => 'from_index is required and must be a non-negative integer', + ); + } + + if ( ! is_numeric( $to_index ) || (int) $to_index < 0 ) { + return array( + 'success' => false, + 'error' => 'to_index is required and must be a non-negative integer', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + $from_index = (int) $from_index; + $to_index = (int) $to_index; + + if ( $from_index === $to_index ) { + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'from_index' => $from_index, + 'to_index' => $to_index, + 'queue_length' => 0, + 'message' => 'No move needed (same position).', + ); + } + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $step_config = $validation['step_config']; + $prompt_queue = $step_config['prompt_queue']; + $queue_length = count( $prompt_queue ); + + if ( $from_index >= $queue_length ) { + return array( + 'success' => false, + 'error' => sprintf( 'from_index %d is out of range. Queue has %d item(s).', $from_index, $queue_length ), + ); + } + + if ( $to_index >= $queue_length ) { + return array( + 'success' => false, + 'error' => sprintf( 'to_index %d is out of range. Queue has %d item(s).', $to_index, $queue_length ), + ); + } + + // Extract the item and reinsert at new position + $item = $prompt_queue[ $from_index ]; + array_splice( $prompt_queue, $from_index, 1 ); + array_splice( $prompt_queue, $to_index, 0, array( $item ) ); + + $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to move queue item', + ); + } + + do_action( + 'datamachine_log', + 'info', + 'Queue item moved', + array( + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'from_index' => $from_index, + 'to_index' => $to_index, + 'queue_length' => $queue_length, + ) + ); + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'from_index' => $from_index, + 'to_index' => $to_index, + 'queue_length' => $queue_length, + 'message' => sprintf( 'Moved item from index %d to %d.', $from_index, $to_index ), + ); + } + + /** + * Update queue settings for a flow step. + * + * @param array $input Input with flow_id, flow_step_id, and queue_enabled. + * @return array Result. + */ + public function executeQueueSettings( array $input ): array { + $flow_id = $input['flow_id'] ?? null; + $flow_step_id = $input['flow_step_id'] ?? null; + $queue_enabled = $input['queue_enabled'] ?? null; + + if ( ! is_numeric( $flow_id ) || (int) $flow_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'flow_id is required and must be a positive integer', + ); + } + + if ( empty( $flow_step_id ) || ! is_string( $flow_step_id ) ) { + return array( + 'success' => false, + 'error' => 'flow_step_id is required and must be a string', + ); + } + + if ( ! is_bool( $queue_enabled ) ) { + return array( + 'success' => false, + 'error' => 'queue_enabled is required and must be a boolean', + ); + } + + $flow_id = (int) $flow_id; + $flow_step_id = sanitize_text_field( $flow_step_id ); + + $flow = $this->db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return array( + 'success' => false, + 'error' => 'Flow not found', + ); + } + + $validation = $this->getStepConfigForQueue( $flow, $flow_step_id ); + if ( ! $validation['success'] ) { + return $validation; + } + + $flow_config = $validation['flow_config']; + $flow_config[ $flow_step_id ]['queue_enabled'] = $queue_enabled; + + $success = $this->db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + if ( ! $success ) { + return array( + 'success' => false, + 'error' => 'Failed to update queue settings', + ); + } + + return array( + 'success' => true, + 'flow_id' => $flow_id, + 'flow_step_id' => $flow_step_id, + 'queue_enabled' => $queue_enabled, + 'message' => 'Queue settings updated successfully', + ); + } + + /** + * Resolve the post_type from the flow's publish step handler config. + * + * Scans all steps in the flow config for a publish step and extracts + * the post_type from its handler config. Falls back to 'post' if no + * publish step is found or no post_type is configured. + * + * @param array $flow_config The flow configuration array keyed by flow_step_id. + * @return string The resolved post type. + */ + private function resolvePublishPostType( array $flow_config ): string { + foreach ( $flow_config as $step_config ) { + if ( ! is_array( $step_config ) ) { + continue; + } + if ( ( $step_config['step_type'] ?? '' ) !== 'publish' ) { + continue; + } + $handler_configs = $step_config['handler_configs'] ?? array(); + foreach ( $handler_configs as $handler_config ) { + if ( ! empty( $handler_config['post_type'] ) ) { + return sanitize_text_field( $handler_config['post_type'] ); + } + } + } + return 'post'; + } + + /** + * Normalize flow step queue config for queue operations. + * + * @param array $flow Flow record. + * @param string $flow_step_id Flow step ID. + * @return array Result with flow_config and step_config or error. + */ + private function getStepConfigForQueue( array $flow, string $flow_step_id ): array { + $flow_id = (int) ( $flow['flow_id'] ?? 0 ); + $parts = apply_filters( 'datamachine_split_flow_step_id', null, $flow_step_id ); + if ( ! $parts || empty( $parts['flow_id'] ) ) { + return array( + 'success' => false, + 'error' => 'Invalid flow_step_id format', + ); + } + if ( (int) $parts['flow_id'] !== $flow_id ) { + return array( + 'success' => false, + 'error' => 'flow_step_id does not belong to this flow', + ); + } + + $flow_config = $flow['flow_config'] ?? array(); + if ( ! isset( $flow_config[ $flow_step_id ] ) ) { + return array( + 'success' => false, + 'error' => 'Flow step not found in flow config', + ); + } + + $step_config = $flow_config[ $flow_step_id ]; + if ( ! isset( $step_config['prompt_queue'] ) || ! is_array( $step_config['prompt_queue'] ) ) { + $step_config['prompt_queue'] = array(); + } + if ( ! isset( $step_config['queue_enabled'] ) || ! is_bool( $step_config['queue_enabled'] ) ) { + $step_config['queue_enabled'] = false; + } + $flow_config[ $flow_step_id ] = $step_config; + + return array( + 'success' => true, + 'flow_config' => $flow_config, + 'step_config' => $step_config, + ); + } diff --git a/inc/Abilities/Flow/QueueAbility/helpers.php b/inc/Abilities/Flow/QueueAbility/helpers.php new file mode 100644 index 000000000..382b68d06 --- /dev/null +++ b/inc/Abilities/Flow/QueueAbility/helpers.php @@ -0,0 +1,84 @@ +//! helpers — extracted from QueueAbility.php. + + + public function __construct() { + $this->initDatabases(); + + if ( ! class_exists( 'WP_Ability' ) ) { + return; + } + + $this->registerAbilities(); + } + + /** + * Register all queue-related abilities. + */ + private function registerAbilities(): void { + $register_callback = function () { + $this->registerQueueAdd(); + $this->registerQueueList(); + $this->registerQueueClear(); + $this->registerQueueRemove(); + $this->registerQueueUpdate(); + $this->registerQueueMove(); + $this->registerQueueSettings(); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Pop the first prompt from the queue (for engine use). + * + * @param int $flow_id Flow ID. + * @param DB_Flows $db_flows Database instance (avoids creating new instance each call). + * @return array|null The popped queue item or null if empty. + */ + public static function popFromQueue( int $flow_id, string $flow_step_id, ?DB_Flows $db_flows = null ): ?array { + if ( null === $db_flows ) { + $db_flows = new DB_Flows(); + } + + $flow = $db_flows->get_flow( $flow_id ); + if ( ! $flow ) { + return null; + } + + $flow_config = $flow['flow_config'] ?? array(); + if ( ! isset( $flow_config[ $flow_step_id ] ) ) { + return null; + } + + $step_config = $flow_config[ $flow_step_id ]; + $prompt_queue = $step_config['prompt_queue'] ?? array(); + + if ( empty( $prompt_queue ) ) { + return null; + } + + $popped_item = array_shift( $prompt_queue ); + + $flow_config[ $flow_step_id ]['prompt_queue'] = $prompt_queue; + + $db_flows->update_flow( + $flow_id, + array( 'flow_config' => $flow_config ) + ); + + do_action( + 'datamachine_log', + 'info', + 'Prompt popped from queue', + array( + 'flow_id' => $flow_id, + 'remaining_count' => count( $prompt_queue ), + ) + ); + + return $popped_item; + } diff --git a/inc/Abilities/Flow/ResumeFlowAbility.php b/inc/Abilities/Flow/ResumeFlowAbility.php index b04cfd72a..9905b4cb8 100644 --- a/inc/Abilities/Flow/ResumeFlowAbility.php +++ b/inc/Abilities/Flow/ResumeFlowAbility.php @@ -23,6 +23,7 @@ class ResumeFlowAbility { use FlowHelpers; + use DataMachine\Abilities\Flow\PauseFlowAbility; public function __construct() { $this->initDatabases(); diff --git a/inc/Abilities/FlowAbilities.php b/inc/Abilities/FlowAbilities.php index 87fc7df4e..cb68da1a3 100644 --- a/inc/Abilities/FlowAbilities.php +++ b/inc/Abilities/FlowAbilities.php @@ -22,6 +22,7 @@ use DataMachine\Abilities\Flow\ResumeFlowAbility; use DataMachine\Abilities\Flow\QueueAbility; use DataMachine\Abilities\Flow\WebhookTriggerAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -40,6 +41,7 @@ class FlowAbilities { private WebhookTriggerAbility $webhook_trigger; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/FlowStep/FlowStepHelpers.php b/inc/Abilities/FlowStep/FlowStepHelpers.php index 8fe218c07..1327241a5 100644 --- a/inc/Abilities/FlowStep/FlowStepHelpers.php +++ b/inc/Abilities/FlowStep/FlowStepHelpers.php @@ -17,6 +17,7 @@ use DataMachine\Core\Database\Flows\Flows; use DataMachine\Core\Database\Pipelines\Pipelines; use DataMachine\Core\Steps\FlowStepConfig; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/FlowStepAbilities.php b/inc/Abilities/FlowStepAbilities.php index 6b6a3e487..6794bcaf8 100644 --- a/inc/Abilities/FlowStepAbilities.php +++ b/inc/Abilities/FlowStepAbilities.php @@ -17,6 +17,7 @@ use DataMachine\Abilities\FlowStep\UpdateFlowStepAbility; use DataMachine\Abilities\FlowStep\ConfigureFlowStepsAbility; use DataMachine\Abilities\FlowStep\ValidateFlowStepsConfigAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -30,6 +31,7 @@ class FlowStepAbilities { private ValidateFlowStepsConfigAbility $validate_flow_steps_config; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/Handler/TestHandlerAbility.php b/inc/Abilities/Handler/TestHandlerAbility.php index 97b9a588f..a9e2e7254 100644 --- a/inc/Abilities/Handler/TestHandlerAbility.php +++ b/inc/Abilities/Handler/TestHandlerAbility.php @@ -68,15 +68,15 @@ private function registerAbility(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'handler_slug' => array( 'type' => 'string' ), - 'handler_label' => array( 'type' => 'string' ), - 'config_used' => array( 'type' => 'object' ), - 'packets' => array( 'type' => 'array' ), - 'packet_count' => array( 'type' => 'integer' ), - 'warnings' => array( 'type' => 'array' ), - 'execution_time_ms' => array( 'type' => 'number' ), - 'error' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'handler_slug' => array( 'type' => 'string' ), + 'handler_label' => array( 'type' => 'string' ), + 'config_used' => array( 'type' => 'object' ), + 'packets' => array( 'type' => 'array' ), + 'packet_count' => array( 'type' => 'integer' ), + 'warnings' => array( 'type' => 'array' ), + 'execution_time_ms' => array( 'type' => 'number' ), + 'error' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( $this, 'execute' ), diff --git a/inc/Abilities/HandlerAbilities.php b/inc/Abilities/HandlerAbilities.php index 14e895b97..0ecf53784 100644 --- a/inc/Abilities/HandlerAbilities.php +++ b/inc/Abilities/HandlerAbilities.php @@ -12,6 +12,7 @@ namespace DataMachine\Abilities; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/InternalLinkingAbilities.php b/inc/Abilities/InternalLinkingAbilities.php index 11d819440..00b378cc2 100644 --- a/inc/Abilities/InternalLinkingAbilities.php +++ b/inc/Abilities/InternalLinkingAbilities.php @@ -39,459 +39,6 @@ class InternalLinkingAbilities { private static bool $registered = false; - public function __construct() { - if ( ! class_exists( 'WP_Ability' ) ) { - return; - } - - if ( self::$registered ) { - return; - } - - $this->registerAbilities(); - self::$registered = true; - } - - private function registerAbilities(): void { - $register_callback = function () { - wp_register_ability( - 'datamachine/internal-linking', - array( - 'label' => 'Internal Linking', - 'description' => 'Queue system agent insertion of semantic internal links into posts', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'post_ids' => array( - 'type' => 'array', - 'items' => array( 'type' => 'integer' ), - 'description' => 'Post IDs to process', - ), - 'category' => array( - 'type' => 'string', - 'description' => 'Category slug to process all posts from', - ), - 'links_per_post' => array( - 'type' => 'integer', - 'description' => 'Maximum internal links to insert per post', - 'default' => 3, - ), - 'dry_run' => array( - 'type' => 'boolean', - 'description' => 'Preview which posts would be queued without processing', - 'default' => false, - ), - 'force' => array( - 'type' => 'boolean', - 'description' => 'Force re-processing even if already linked', - 'default' => false, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'queued_count' => array( 'type' => 'integer' ), - 'post_ids' => array( - 'type' => 'array', - 'items' => array( 'type' => 'integer' ), - ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( self::class, 'queueInternalLinking' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => false ), - ) - ); - - wp_register_ability( - 'datamachine/diagnose-internal-links', - array( - 'label' => 'Diagnose Internal Links', - 'description' => 'Report internal link coverage across published posts', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array(), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'total_posts' => array( 'type' => 'integer' ), - 'posts_with_links' => array( 'type' => 'integer' ), - 'posts_without_links' => array( 'type' => 'integer' ), - 'avg_links_per_post' => array( 'type' => 'number' ), - 'by_category' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - ), - ), - ), - 'execute_callback' => array( self::class, 'diagnoseInternalLinks' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - wp_register_ability( - 'datamachine/audit-internal-links', - array( - 'label' => 'Audit Internal Links', - 'description' => 'Scan post content for internal links, build a link graph, and cache results. Does NOT check for broken links — use datamachine/check-broken-links for that.', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'post_type' => array( - 'type' => 'string', - 'description' => 'Post type to audit. Default: post.', - 'default' => 'post', - ), - 'category' => array( - 'type' => 'string', - 'description' => 'Category slug to limit audit scope.', - ), - 'post_ids' => array( - 'type' => 'array', - 'items' => array( 'type' => 'integer' ), - 'description' => 'Specific post IDs to audit.', - ), - 'force' => array( - 'type' => 'boolean', - 'description' => 'Force rebuild even if cached graph exists.', - 'default' => false, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'total_scanned' => array( 'type' => 'integer' ), - 'total_links' => array( 'type' => 'integer' ), - 'orphaned_count' => array( 'type' => 'integer' ), - 'avg_outbound' => array( 'type' => 'number' ), - 'avg_inbound' => array( 'type' => 'number' ), - 'orphaned_posts' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - ), - 'top_linked' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - ), - 'cached' => array( 'type' => 'boolean' ), - ), - ), - 'execute_callback' => array( self::class, 'auditInternalLinks' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - wp_register_ability( - 'datamachine/get-orphaned-posts', - array( - 'label' => 'Get Orphaned Posts', - 'description' => 'Return posts with zero inbound internal links from the cached link graph. Runs audit automatically if no cache exists.', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'post_type' => array( - 'type' => 'string', - 'description' => 'Post type to check. Default: post.', - 'default' => 'post', - ), - 'limit' => array( - 'type' => 'integer', - 'description' => 'Maximum orphaned posts to return. Default: 50.', - 'default' => 50, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'orphaned_count' => array( 'type' => 'integer' ), - 'total_scanned' => array( 'type' => 'integer' ), - 'orphaned_posts' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - ), - 'from_cache' => array( 'type' => 'boolean' ), - ), - ), - 'execute_callback' => array( self::class, 'getOrphanedPosts' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - wp_register_ability( - 'datamachine/check-broken-links', - array( - 'label' => 'Check Broken Links', - 'description' => 'HTTP HEAD check links from the cached link graph to find broken URLs. Supports internal, external, or all links via scope. External checks include per-domain rate limiting and HEAD→GET fallback.', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'post_type' => array( - 'type' => 'string', - 'description' => 'Post type scope. Default: post.', - 'default' => 'post', - ), - 'scope' => array( - 'type' => 'string', - 'description' => 'Link scope: internal, external, or all. Default: internal.', - 'enum' => array( 'internal', 'external', 'all' ), - 'default' => 'internal', - ), - 'limit' => array( - 'type' => 'integer', - 'description' => 'Maximum unique URLs to check. Default: 200.', - 'default' => 200, - ), - 'timeout' => array( - 'type' => 'integer', - 'description' => 'HTTP timeout per request in seconds. Default: 5.', - 'default' => 5, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'scope' => array( 'type' => 'string' ), - 'urls_checked' => array( 'type' => 'integer' ), - 'broken_count' => array( 'type' => 'integer' ), - 'broken_links' => array( - 'type' => 'array', - 'items' => array( 'type' => 'object' ), - ), - 'from_cache' => array( 'type' => 'boolean' ), - ), - ), - 'execute_callback' => array( self::class, 'checkBrokenLinks' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - wp_register_ability( - 'datamachine/link-opportunities', - array( - 'label' => 'Link Opportunities', - 'description' => 'Rank internal linking opportunities by combining GSC traffic data with the link graph. High-traffic pages with few inbound links score highest.', - 'category' => 'datamachine', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'limit' => array( - 'type' => 'integer', - 'description' => 'Number of results to return. Default: 20.', - 'default' => 20, - ), - 'category' => array( - 'type' => 'string', - 'description' => 'Category slug to filter by.', - ), - 'min_clicks' => array( - 'type' => 'integer', - 'description' => 'Minimum GSC clicks to include a page. Default: 5.', - 'default' => 5, - ), - 'days' => array( - 'type' => 'integer', - 'description' => 'GSC lookback period in days. Default: 28.', - 'default' => 28, - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'pages_with_traffic' => array( 'type' => 'integer' ), - 'opportunities' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'score' => array( 'type' => 'number' ), - 'clicks' => array( 'type' => 'number' ), - 'impressions' => array( 'type' => 'number' ), - 'position' => array( 'type' => 'number' ), - 'inbound_links' => array( 'type' => 'integer' ), - 'outbound_links' => array( 'type' => 'integer' ), - 'post_id' => array( 'type' => 'integer' ), - 'slug' => array( 'type' => 'string' ), - ), - ), - ), - ), - ), - 'execute_callback' => array( self::class, 'getLinkOpportunities' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => true ), - ) - ); - - - }; - - if ( doing_action( 'wp_abilities_api_init' ) ) { - $register_callback(); - } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { - add_action( 'wp_abilities_api_init', $register_callback ); - } - } - - /** - * Queue internal linking for posts. - * - * @param array $input Ability input. - * @return array Ability response. - */ - public static function queueInternalLinking( array $input ): array { - $post_ids = array_map( 'absint', $input['post_ids'] ?? array() ); - $category = sanitize_text_field( $input['category'] ?? '' ); - $links_per_post = absint( $input['links_per_post'] ?? 3 ); - $dry_run = ! empty( $input['dry_run'] ); - $force = ! empty( $input['force'] ); - - $user_id = get_current_user_id(); - $agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0; - $system_defaults = PluginSettings::resolveModelForAgentContext( $agent_id, 'system' ); - $provider = $system_defaults['provider']; - $model = $system_defaults['model']; - - if ( empty( $provider ) || empty( $model ) ) { - return array( - 'success' => false, - 'queued_count' => 0, - 'post_ids' => array(), - 'message' => 'No default AI provider/model configured.', - 'error' => 'Configure default_provider and default_model in Data Machine settings.', - ); - } - - // Resolve category to post IDs. - if ( ! empty( $category ) ) { - $term = get_term_by( 'slug', $category, 'category' ); - if ( ! $term ) { - return array( - 'success' => false, - 'queued_count' => 0, - 'post_ids' => array(), - 'message' => "Category '{$category}' not found.", - 'error' => 'Invalid category slug', - ); - } - - $cat_posts = get_posts( - array( - 'post_type' => 'post', - 'post_status' => 'publish', - 'category' => $term->term_id, - 'fields' => 'ids', - 'numberposts' => -1, - ) - ); - - $post_ids = array_merge( $post_ids, $cat_posts ); - } - - $post_ids = array_values( array_unique( array_filter( $post_ids ) ) ); - - if ( empty( $post_ids ) ) { - return array( - 'success' => false, - 'queued_count' => 0, - 'post_ids' => array(), - 'message' => 'No post IDs provided or resolved.', - 'error' => 'Missing required parameter: post_ids or category', - ); - } - - if ( $dry_run ) { - return array( - 'success' => true, - 'queued_count' => count( $post_ids ), - 'post_ids' => $post_ids, - 'message' => sprintf( 'Dry run: %d post(s) would be queued for internal linking.', count( $post_ids ) ), - ); - } - - // Filter to eligible posts. - $eligible = array(); - foreach ( $post_ids as $pid ) { - $post = get_post( $pid ); - if ( $post && 'publish' === $post->post_status ) { - $eligible[] = $pid; - } - } - - if ( empty( $eligible ) ) { - return array( - 'success' => true, - 'queued_count' => 0, - 'post_ids' => array(), - 'message' => 'No eligible published posts found.', - ); - } - - // Build per-item params for batch scheduling. - $item_params = array(); - foreach ( $eligible as $pid ) { - $item_params[] = array( - 'post_id' => $pid, - 'links_per_post' => $links_per_post, - 'force' => $force, - 'source' => 'ability', - ); - } - - $batch = TaskScheduler::scheduleBatch( - 'internal_linking', - $item_params, - array( - 'user_id' => $user_id, - 'agent_id' => $agent_id, - ) - ); - - if ( false === $batch ) { - return array( - 'success' => false, - 'queued_count' => 0, - 'post_ids' => array(), - 'message' => 'Failed to schedule batch.', - 'error' => 'Task batch scheduling failed.', - ); - } - - return array( - 'success' => true, - 'queued_count' => count( $eligible ), - 'post_ids' => $eligible, - 'batch_id' => $batch['batch_id'] ?? null, - 'message' => sprintf( - 'Internal linking batch scheduled for %d post(s) (chunks of %d).', - count( $eligible ), - $batch['chunk_size'] ?? TaskScheduler::BATCH_CHUNK_SIZE - ), - ); - } - /** * Diagnose internal link coverage across published posts. * @@ -852,60 +399,6 @@ public static function checkBrokenLinks( array $input = array() ): array { ); } - /** - * Check a single URL's HTTP status. - * - * Uses HEAD first for efficiency. For external URLs, falls back to GET - * with a range header when HEAD returns 405 or 403 (some servers block HEAD). - * - * @since 0.42.0 - * - * @param string $url URL to check. - * @param int $timeout Request timeout in seconds. - * @param bool $is_external Whether this is an external URL (enables GET fallback). - * @return int HTTP status code (0 for connection failures/timeouts). - */ - private static function checkUrlStatus( string $url, int $timeout, bool $is_external ): int { - $response = wp_remote_head( - $url, - array( - 'timeout' => $timeout, - 'redirection' => 3, - 'user-agent' => 'DataMachine/LinkChecker (WordPress; +' . home_url() . ')', - ) - ); - - if ( is_wp_error( $response ) ) { - return 0; - } - - $status = wp_remote_retrieve_response_code( $response ); - - // Some external servers block HEAD requests — fall back to GET. - if ( $is_external && ( 405 === $status || 403 === $status ) ) { - $get_response = wp_remote_get( - $url, - array( - 'timeout' => $timeout, - 'redirection' => 3, - 'headers' => array( 'Range' => 'bytes=0-0' ), - 'user-agent' => 'DataMachine/LinkChecker (WordPress; +' . home_url() . ')', - ) - ); - - if ( ! is_wp_error( $get_response ) ) { - $get_status = wp_remote_retrieve_response_code( $get_response ); - // 206 (Partial Content) means the server supports Range and the URL is alive. - if ( 206 === $get_status || ( $get_status >= 200 && $get_status < 400 ) ) { - return $get_status; - } - return $get_status; - } - } - - return $status ? $status : 0; - } - /** * Get ranked internal linking opportunities by combining GSC traffic with link graph. * @@ -1018,7 +511,7 @@ public static function getLinkOpportunities( array $input = array() ): array { 'error' => "Category '{$category}' not found.", ); } - $cat_posts = get_posts( + $cat_posts = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', @@ -1059,8 +552,8 @@ public static function getLinkOpportunities( array $input = array() ): array { } $traffic_by_post[ $post_id ]['clicks'] += (int) $clicks; - $traffic_by_post[ $post_id ]['impressions'] += (int) ( $row['impressions'] ?? 0 ); - $traffic_by_post[ $post_id ]['position'] += (float) ( $row['position'] ?? 0 ); + $traffic_by_post[ $post_id ]['impressions'] += (int) ( $row['impressions'] ?? 0 ); + $traffic_by_post[ $post_id ]['position'] += (float) ( $row['position'] ?? 0 ); ++$traffic_by_post[ $post_id ]['row_count']; } @@ -1123,343 +616,6 @@ function ( $a, $b ) { ); } - /** - * Build the internal link graph by scanning post content. - * - * Shared logic used by audit, get-orphaned-posts, and check-broken-links. - * Returns the full graph data structure suitable for caching. - * - * @since 0.32.0 - * - * @param string $post_type Post type to scan. - * @param string $category Category slug to filter by. - * @param array $specific_ids Specific post IDs to scan. - * @return array Graph data structure. - */ - private static function buildLinkGraph( string $post_type, string $category, array $specific_ids ): array { - global $wpdb; - - $home_url = home_url(); - $home_host = wp_parse_url( $home_url, PHP_URL_HOST ); - - // Build the query for posts to scan. - if ( ! empty( $specific_ids ) ) { - $id_placeholders = implode( ',', array_fill( 0, count( $specific_ids ), '%d' ) ); - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $posts = $wpdb->get_results( - $wpdb->prepare( - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - "SELECT ID, post_title, post_content FROM {$wpdb->posts} - WHERE ID IN ($id_placeholders) AND post_status = %s", - array_merge( $specific_ids, array( 'publish' ) ) - ) - ); - // phpcs:enable WordPress.DB.PreparedSQL - } elseif ( ! empty( $category ) ) { - $term = get_term_by( 'slug', $category, 'category' ); - if ( ! $term ) { - return array( - 'success' => false, - 'error' => "Category '{$category}' not found.", - ); - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $posts = $wpdb->get_results( - $wpdb->prepare( - "SELECT p.ID, p.post_title, p.post_content - FROM {$wpdb->posts} p - INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id - INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id - WHERE p.post_type = %s AND p.post_status = %s - AND tt.taxonomy = %s AND tt.term_id = %d", - $post_type, - 'publish', - 'category', - $term->term_id - ) - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $posts = $wpdb->get_results( - $wpdb->prepare( - "SELECT ID, post_title, post_content FROM {$wpdb->posts} - WHERE post_type = %s AND post_status = %s", - $post_type, - 'publish' - ) - ); - } - - if ( empty( $posts ) ) { - return array( - 'success' => true, - 'post_type' => $post_type, - 'total_scanned' => 0, - 'total_links' => 0, - 'orphaned_count' => 0, - 'avg_outbound' => 0, - 'avg_inbound' => 0, - 'orphaned_posts' => array(), - 'top_linked' => array(), - '_all_links' => array(), - '_id_to_title' => array(), - ); - } - - // Build a lookup of all scanned post URLs -> IDs. - $url_to_id = array(); - $id_to_url = array(); - $id_to_title = array(); - - foreach ( $posts as $post ) { - $permalink = get_permalink( $post->ID ); - if ( $permalink ) { - $url_to_id[ untrailingslashit( $permalink ) ] = $post->ID; - $url_to_id[ trailingslashit( $permalink ) ] = $post->ID; - $id_to_url[ $post->ID ] = $permalink; - } - $id_to_title[ $post->ID ] = $post->post_title; - } - - // Scan each post's content for internal links. - $outbound = array(); // post_id => array of target post_ids. - $inbound = array(); // post_id => count of inbound links. - $all_links = array(); // all discovered internal link entries. - $total_links = 0; - - // Initialize inbound counts. - foreach ( $posts as $post ) { - $inbound[ $post->ID ] = 0; - $outbound[ $post->ID ] = array(); - } - - $all_external_links = array(); - - foreach ( $posts as $post ) { - $content = $post->post_content; - if ( empty( $content ) ) { - continue; - } - - // Internal links. - $links = self::extractInternalLinks( $content, $home_host ); - - foreach ( $links as $link_url ) { - ++$total_links; - $normalized = untrailingslashit( $link_url ); - - // Resolve to a post ID if possible. - $target_id = $url_to_id[ $normalized ] ?? $url_to_id[ trailingslashit( $link_url ) ] ?? null; - - if ( null === $target_id ) { - // Try url_to_postid as fallback for non-standard URLs. - $target_id = url_to_postid( $link_url ); - if ( 0 === $target_id ) { - $target_id = null; - } - } - - if ( null !== $target_id && $target_id !== $post->ID ) { - $outbound[ $post->ID ][] = $target_id; - - if ( isset( $inbound[ $target_id ] ) ) { - ++$inbound[ $target_id ]; - } - } - - $all_links[] = array( - 'source_id' => $post->ID, - 'target_url' => $link_url, - 'target_id' => $target_id, - 'resolved' => null !== $target_id, - ); - } - - // External links. - $external = self::extractExternalLinks( $content, $home_host ); - foreach ( $external as $ext_link ) { - $all_external_links[] = array( - 'source_id' => $post->ID, - 'target_url' => $ext_link['url'], - 'anchor_text' => $ext_link['anchor_text'], - 'domain' => $ext_link['domain'], - ); - } - } - - // Identify orphaned posts (zero inbound links from other scanned posts). - $orphaned = array(); - foreach ( $inbound as $post_id => $count ) { - if ( 0 === $count ) { - $orphaned[] = array( - 'post_id' => $post_id, - 'title' => $id_to_title[ $post_id ] ?? '', - 'permalink' => $id_to_url[ $post_id ] ?? '', - 'outbound' => count( $outbound[ $post_id ] ?? array() ), - ); - } - } - - // Top linked posts (most inbound). - arsort( $inbound ); - $top_linked = array(); - $top_count = 0; - foreach ( $inbound as $post_id => $count ) { - if ( 0 === $count || $top_count >= 20 ) { - break; - } - $top_linked[] = array( - 'post_id' => $post_id, - 'title' => $id_to_title[ $post_id ] ?? '', - 'permalink' => $id_to_url[ $post_id ] ?? '', - 'inbound' => $count, - 'outbound' => count( $outbound[ $post_id ] ?? array() ), - ); - ++$top_count; - } - - $total_scanned = count( $posts ); - $outbound_total = array_sum( array_map( 'count', $outbound ) ); - $inbound_total = array_sum( $inbound ); - - return array( - 'success' => true, - 'post_type' => $post_type, - 'total_scanned' => $total_scanned, - 'total_links' => $total_links, - 'orphaned_count' => count( $orphaned ), - 'avg_outbound' => $total_scanned > 0 ? round( $outbound_total / $total_scanned, 2 ) : 0, - 'avg_inbound' => $total_scanned > 0 ? round( $inbound_total / $total_scanned, 2 ) : 0, - 'orphaned_posts' => $orphaned, - 'top_linked' => $top_linked, - // Internal data for broken link checker (not exposed in REST). - '_all_links' => $all_links, - '_all_external_links' => $all_external_links, - '_id_to_title' => $id_to_title, - ); - } - - /** - * Extract internal link URLs from HTML content. - * - * Uses regex to find all tags where the href points to - * the same host as the site. Ignores anchors, mailto, tel, and external links. - * - * @since 0.32.0 - * - * @param string $html HTML content to parse. - * @param string $home_host Site hostname for comparison. - * @return array Array of internal link URLs. - */ - private static function extractInternalLinks( string $html, string $home_host ): array { - $links = array(); - - // Match all href attributes in anchor tags. - if ( ! preg_match_all( '/]*href=["\']([^"\'#]+)["\'][^>]*>/i', $html, $matches ) ) { - return $links; - } - - foreach ( $matches[1] as $url ) { - // Skip non-http URLs. - if ( preg_match( '/^(mailto:|tel:|javascript:|data:)/i', $url ) ) { - continue; - } - - // Handle relative URLs. - if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) { - $url = home_url( $url ); - } - - // Parse and check host. - $parsed = wp_parse_url( $url ); - $host = $parsed['host'] ?? ''; - - if ( empty( $host ) || strcasecmp( $host, $home_host ) !== 0 ) { - continue; - } - - // Strip query string and fragment for normalization. - $clean_url = $parsed['scheme'] . '://' . $parsed['host']; - if ( ! empty( $parsed['path'] ) ) { - $clean_url .= $parsed['path']; - } - - $links[] = $clean_url; - } - - return array_unique( $links ); - } - - /** - * Extract external link URLs and anchor text from HTML content. - * - * Inverse of extractInternalLinks — keeps links pointing to hosts - * other than the site. Returns both URL and anchor text for reporting. - * - * @since 0.42.0 - * - * @param string $html HTML content to parse. - * @param string $home_host Site hostname for comparison. - * @return array Array of arrays with 'url' and 'anchor_text' keys. - */ - private static function extractExternalLinks( string $html, string $home_host ): array { - $links = array(); - - // Match href AND capture the full tag + inner text for anchor extraction. - if ( ! preg_match_all( '/]*href=["\']([^"\'#]+)["\'][^>]*>(.*?)<\/a>/is', $html, $matches, PREG_SET_ORDER ) ) { - return $links; - } - - $seen = array(); - - foreach ( $matches as $match ) { - $url = $match[1]; - $anchor_text = wp_strip_all_tags( $match[2] ); - - // Skip non-http URLs. - if ( preg_match( '/^(mailto:|tel:|javascript:|data:)/i', $url ) ) { - continue; - } - - // Skip relative URLs (they're internal). - if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) { - continue; - } - - // Parse and check host — keep only external. - $parsed = wp_parse_url( $url ); - $host = $parsed['host'] ?? ''; - - if ( empty( $host ) || strcasecmp( $host, $home_host ) === 0 ) { - continue; - } - - // Normalize URL (keep query string for external — different pages). - $clean_url = ( $parsed['scheme'] ?? 'https' ) . '://' . $parsed['host']; - if ( ! empty( $parsed['path'] ) ) { - $clean_url .= $parsed['path']; - } - if ( ! empty( $parsed['query'] ) ) { - $clean_url .= '?' . $parsed['query']; - } - - // Deduplicate by URL within a single post. - if ( isset( $seen[ $clean_url ] ) ) { - continue; - } - $seen[ $clean_url ] = true; - - $links[] = array( - 'url' => $clean_url, - 'anchor_text' => trim( $anchor_text ), - 'domain' => $host, - ); - } - - return $links; - } - // Removed: injectCategoryLinks, extractTitleKeywords, scoreRelatedPosts, // buildRelatedReadingBlock — replaced by InternalLinkingTask (v0.42.0). // Use `datamachine links crosslink` for AI-powered natural link insertion. diff --git a/inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php b/inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php new file mode 100644 index 000000000..3f687d01d --- /dev/null +++ b/inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php @@ -0,0 +1,339 @@ +//! extractInternalLinks — extracted from InternalLinkingAbilities.php. + + + /** + * Build the internal link graph by scanning post content. + * + * Shared logic used by audit, get-orphaned-posts, and check-broken-links. + * Returns the full graph data structure suitable for caching. + * + * @since 0.32.0 + * + * @param string $post_type Post type to scan. + * @param string $category Category slug to filter by. + * @param array $specific_ids Specific post IDs to scan. + * @return array Graph data structure. + */ + private static function buildLinkGraph( string $post_type, string $category, array $specific_ids ): array { + global $wpdb; + + $home_url = home_url(); + $home_host = wp_parse_url( $home_url, PHP_URL_HOST ); + + // Build the query for posts to scan. + if ( ! empty( $specific_ids ) ) { + $id_placeholders = implode( ',', array_fill( 0, count( $specific_ids ), '%d' ) ); + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $posts = $wpdb->get_results( + $wpdb->prepare( + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + "SELECT ID, post_title, post_content FROM {$wpdb->posts} + WHERE ID IN ($id_placeholders) AND post_status = %s", + array_merge( $specific_ids, array( 'publish' ) ) + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL + } elseif ( ! empty( $category ) ) { + $term = get_term_by( 'slug', $category, 'category' ); + if ( ! $term ) { + return array( + 'success' => false, + 'error' => "Category '{$category}' not found.", + ); + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $posts = $wpdb->get_results( + $wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_content + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE p.post_type = %s AND p.post_status = %s + AND tt.taxonomy = %s AND tt.term_id = %d", + $post_type, + 'publish', + 'category', + $term->term_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $posts = $wpdb->get_results( + $wpdb->prepare( + "SELECT ID, post_title, post_content FROM {$wpdb->posts} + WHERE post_type = %s AND post_status = %s", + $post_type, + 'publish' + ) + ); + } + + if ( empty( $posts ) ) { + return array( + 'success' => true, + 'post_type' => $post_type, + 'total_scanned' => 0, + 'total_links' => 0, + 'orphaned_count' => 0, + 'avg_outbound' => 0, + 'avg_inbound' => 0, + 'orphaned_posts' => array(), + 'top_linked' => array(), + '_all_links' => array(), + '_id_to_title' => array(), + ); + } + + // Build a lookup of all scanned post URLs -> IDs. + $url_to_id = array(); + $id_to_url = array(); + $id_to_title = array(); + + foreach ( $posts as $post ) { + $permalink = get_permalink( $post->ID ); + if ( $permalink ) { + $url_to_id[ untrailingslashit( $permalink ) ] = $post->ID; + $url_to_id[ trailingslashit( $permalink ) ] = $post->ID; + $id_to_url[ $post->ID ] = $permalink; + } + $id_to_title[ $post->ID ] = $post->post_title; + } + + // Scan each post's content for internal links. + $outbound = array(); // post_id => array of target post_ids. + $inbound = array(); // post_id => count of inbound links. + $all_links = array(); // all discovered internal link entries. + $total_links = 0; + + // Initialize inbound counts. + foreach ( $posts as $post ) { + $inbound[ $post->ID ] = 0; + $outbound[ $post->ID ] = array(); + } + + $all_external_links = array(); + + foreach ( $posts as $post ) { + $content = $post->post_content; + if ( empty( $content ) ) { + continue; + } + + // Internal links. + $links = self::extractInternalLinks( $content, $home_host ); + + foreach ( $links as $link_url ) { + ++$total_links; + $normalized = untrailingslashit( $link_url ); + + // Resolve to a post ID if possible. + $target_id = $url_to_id[ $normalized ] ?? $url_to_id[ trailingslashit( $link_url ) ] ?? null; + + if ( null === $target_id ) { + // Try url_to_postid as fallback for non-standard URLs. + $target_id = url_to_postid( $link_url ); + if ( 0 === $target_id ) { + $target_id = null; + } + } + + if ( null !== $target_id && $target_id !== $post->ID ) { + $outbound[ $post->ID ][] = $target_id; + + if ( isset( $inbound[ $target_id ] ) ) { + ++$inbound[ $target_id ]; + } + } + + $all_links[] = array( + 'source_id' => $post->ID, + 'target_url' => $link_url, + 'target_id' => $target_id, + 'resolved' => null !== $target_id, + ); + } + + // External links. + $external = self::extractExternalLinks( $content, $home_host ); + foreach ( $external as $ext_link ) { + $all_external_links[] = array( + 'source_id' => $post->ID, + 'target_url' => $ext_link['url'], + 'anchor_text' => $ext_link['anchor_text'], + 'domain' => $ext_link['domain'], + ); + } + } + + // Identify orphaned posts (zero inbound links from other scanned posts). + $orphaned = array(); + foreach ( $inbound as $post_id => $count ) { + if ( 0 === $count ) { + $orphaned[] = array( + 'post_id' => $post_id, + 'title' => $id_to_title[ $post_id ] ?? '', + 'permalink' => $id_to_url[ $post_id ] ?? '', + 'outbound' => count( $outbound[ $post_id ] ?? array() ), + ); + } + } + + // Top linked posts (most inbound). + arsort( $inbound ); + $top_linked = array(); + $top_count = 0; + foreach ( $inbound as $post_id => $count ) { + if ( 0 === $count || $top_count >= 20 ) { + break; + } + $top_linked[] = array( + 'post_id' => $post_id, + 'title' => $id_to_title[ $post_id ] ?? '', + 'permalink' => $id_to_url[ $post_id ] ?? '', + 'inbound' => $count, + 'outbound' => count( $outbound[ $post_id ] ?? array() ), + ); + ++$top_count; + } + + $total_scanned = count( $posts ); + $outbound_total = array_sum( array_map( 'count', $outbound ) ); + $inbound_total = array_sum( $inbound ); + + return array( + 'success' => true, + 'post_type' => $post_type, + 'total_scanned' => $total_scanned, + 'total_links' => $total_links, + 'orphaned_count' => count( $orphaned ), + 'avg_outbound' => $total_scanned > 0 ? round( $outbound_total / $total_scanned, 2 ) : 0, + 'avg_inbound' => $total_scanned > 0 ? round( $inbound_total / $total_scanned, 2 ) : 0, + 'orphaned_posts' => $orphaned, + 'top_linked' => $top_linked, + // Internal data for broken link checker (not exposed in REST). + '_all_links' => $all_links, + '_all_external_links' => $all_external_links, + '_id_to_title' => $id_to_title, + ); + } + + /** + * Extract internal link URLs from HTML content. + * + * Uses regex to find all tags where the href points to + * the same host as the site. Ignores anchors, mailto, tel, and external links. + * + * @since 0.32.0 + * + * @param string $html HTML content to parse. + * @param string $home_host Site hostname for comparison. + * @return array Array of internal link URLs. + */ + private static function extractInternalLinks( string $html, string $home_host ): array { + $links = array(); + + // Match all href attributes in anchor tags. + if ( ! preg_match_all( '/]*href=["\']([^"\'#]+)["\'][^>]*>/i', $html, $matches ) ) { + return $links; + } + + foreach ( $matches[1] as $url ) { + // Skip non-http URLs. + if ( preg_match( '/^(mailto:|tel:|javascript:|data:)/i', $url ) ) { + continue; + } + + // Handle relative URLs. + if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) { + $url = home_url( $url ); + } + + // Parse and check host. + $parsed = wp_parse_url( $url ); + $host = $parsed['host'] ?? ''; + + if ( empty( $host ) || strcasecmp( $host, $home_host ) !== 0 ) { + continue; + } + + // Strip query string and fragment for normalization. + $clean_url = $parsed['scheme'] . '://' . $parsed['host']; + if ( ! empty( $parsed['path'] ) ) { + $clean_url .= $parsed['path']; + } + + $links[] = $clean_url; + } + + return array_unique( $links ); + } + + /** + * Extract external link URLs and anchor text from HTML content. + * + * Inverse of extractInternalLinks — keeps links pointing to hosts + * other than the site. Returns both URL and anchor text for reporting. + * + * @since 0.42.0 + * + * @param string $html HTML content to parse. + * @param string $home_host Site hostname for comparison. + * @return array Array of arrays with 'url' and 'anchor_text' keys. + */ + private static function extractExternalLinks( string $html, string $home_host ): array { + $links = array(); + + // Match href AND capture the full tag + inner text for anchor extraction. + if ( ! preg_match_all( '/]*href=["\']([^"\'#]+)["\'][^>]*>(.*?)<\/a>/is', $html, $matches, PREG_SET_ORDER ) ) { + return $links; + } + + $seen = array(); + + foreach ( $matches as $match ) { + $url = $match[1]; + $anchor_text = wp_strip_all_tags( $match[2] ); + + // Skip non-http URLs. + if ( preg_match( '/^(mailto:|tel:|javascript:|data:)/i', $url ) ) { + continue; + } + + // Skip relative URLs (they're internal). + if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) { + continue; + } + + // Parse and check host — keep only external. + $parsed = wp_parse_url( $url ); + $host = $parsed['host'] ?? ''; + + if ( empty( $host ) || strcasecmp( $host, $home_host ) === 0 ) { + continue; + } + + // Normalize URL (keep query string for external — different pages). + $clean_url = ( $parsed['scheme'] ?? 'https' ) . '://' . $parsed['host']; + if ( ! empty( $parsed['path'] ) ) { + $clean_url .= $parsed['path']; + } + if ( ! empty( $parsed['query'] ) ) { + $clean_url .= '?' . $parsed['query']; + } + + // Deduplicate by URL within a single post. + if ( isset( $seen[ $clean_url ] ) ) { + continue; + } + $seen[ $clean_url ] = true; + + $links[] = array( + 'url' => $clean_url, + 'anchor_text' => trim( $anchor_text ), + 'domain' => $host, + ); + } + + return $links; + } diff --git a/inc/Abilities/InternalLinkingAbilities/registerAbilities.php b/inc/Abilities/InternalLinkingAbilities/registerAbilities.php new file mode 100644 index 000000000..77b15bc54 --- /dev/null +++ b/inc/Abilities/InternalLinkingAbilities/registerAbilities.php @@ -0,0 +1,509 @@ +//! registerAbilities — extracted from InternalLinkingAbilities.php. + + + public function __construct() { + if ( ! class_exists( 'WP_Ability' ) ) { + return; + } + + if ( self::$registered ) { + return; + } + + $this->registerAbilities(); + self::$registered = true; + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/internal-linking', + array( + 'label' => 'Internal Linking', + 'description' => 'Queue system agent insertion of semantic internal links into posts', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_ids' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => 'Post IDs to process', + ), + 'category' => array( + 'type' => 'string', + 'description' => 'Category slug to process all posts from', + ), + 'links_per_post' => array( + 'type' => 'integer', + 'description' => 'Maximum internal links to insert per post', + 'default' => 3, + ), + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'Preview which posts would be queued without processing', + 'default' => false, + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'Force re-processing even if already linked', + 'default' => false, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'queued_count' => array( 'type' => 'integer' ), + 'post_ids' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'queueInternalLinking' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/diagnose-internal-links', + array( + 'label' => 'Diagnose Internal Links', + 'description' => 'Report internal link coverage across published posts', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'total_posts' => array( 'type' => 'integer' ), + 'posts_with_links' => array( 'type' => 'integer' ), + 'posts_without_links' => array( 'type' => 'integer' ), + 'avg_links_per_post' => array( 'type' => 'number' ), + 'by_category' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + ), + ), + 'execute_callback' => array( self::class, 'diagnoseInternalLinks' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/audit-internal-links', + array( + 'label' => 'Audit Internal Links', + 'description' => 'Scan post content for internal links, build a link graph, and cache results. Does NOT check for broken links — use datamachine/check-broken-links for that.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'description' => 'Post type to audit. Default: post.', + 'default' => 'post', + ), + 'category' => array( + 'type' => 'string', + 'description' => 'Category slug to limit audit scope.', + ), + 'post_ids' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => 'Specific post IDs to audit.', + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'Force rebuild even if cached graph exists.', + 'default' => false, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'total_scanned' => array( 'type' => 'integer' ), + 'total_links' => array( 'type' => 'integer' ), + 'orphaned_count' => array( 'type' => 'integer' ), + 'avg_outbound' => array( 'type' => 'number' ), + 'avg_inbound' => array( 'type' => 'number' ), + 'orphaned_posts' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'top_linked' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'cached' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( self::class, 'auditInternalLinks' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/get-orphaned-posts', + array( + 'label' => 'Get Orphaned Posts', + 'description' => 'Return posts with zero inbound internal links from the cached link graph. Runs audit automatically if no cache exists.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'description' => 'Post type to check. Default: post.', + 'default' => 'post', + ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum orphaned posts to return. Default: 50.', + 'default' => 50, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'orphaned_count' => array( 'type' => 'integer' ), + 'total_scanned' => array( 'type' => 'integer' ), + 'orphaned_posts' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'from_cache' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( self::class, 'getOrphanedPosts' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/check-broken-links', + array( + 'label' => 'Check Broken Links', + 'description' => 'HTTP HEAD check links from the cached link graph to find broken URLs. Supports internal, external, or all links via scope. External checks include per-domain rate limiting and HEAD→GET fallback.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'description' => 'Post type scope. Default: post.', + 'default' => 'post', + ), + 'scope' => array( + 'type' => 'string', + 'description' => 'Link scope: internal, external, or all. Default: internal.', + 'enum' => array( 'internal', 'external', 'all' ), + 'default' => 'internal', + ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum unique URLs to check. Default: 200.', + 'default' => 200, + ), + 'timeout' => array( + 'type' => 'integer', + 'description' => 'HTTP timeout per request in seconds. Default: 5.', + 'default' => 5, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'scope' => array( 'type' => 'string' ), + 'urls_checked' => array( 'type' => 'integer' ), + 'broken_count' => array( 'type' => 'integer' ), + 'broken_links' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'from_cache' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( self::class, 'checkBrokenLinks' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/link-opportunities', + array( + 'label' => 'Link Opportunities', + 'description' => 'Rank internal linking opportunities by combining GSC traffic data with the link graph. High-traffic pages with few inbound links score highest.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'limit' => array( + 'type' => 'integer', + 'description' => 'Number of results to return. Default: 20.', + 'default' => 20, + ), + 'category' => array( + 'type' => 'string', + 'description' => 'Category slug to filter by.', + ), + 'min_clicks' => array( + 'type' => 'integer', + 'description' => 'Minimum GSC clicks to include a page. Default: 5.', + 'default' => 5, + ), + 'days' => array( + 'type' => 'integer', + 'description' => 'GSC lookback period in days. Default: 28.', + 'default' => 28, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'pages_with_traffic' => array( 'type' => 'integer' ), + 'opportunities' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'score' => array( 'type' => 'number' ), + 'clicks' => array( 'type' => 'number' ), + 'impressions' => array( 'type' => 'number' ), + 'position' => array( 'type' => 'number' ), + 'inbound_links' => array( 'type' => 'integer' ), + 'outbound_links' => array( 'type' => 'integer' ), + 'post_id' => array( 'type' => 'integer' ), + 'slug' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( self::class, 'getLinkOpportunities' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Queue internal linking for posts. + * + * @param array $input Ability input. + * @return array Ability response. + */ + public static function queueInternalLinking( array $input ): array { + $post_ids = array_map( 'absint', $input['post_ids'] ?? array() ); + $category = sanitize_text_field( $input['category'] ?? '' ); + $links_per_post = absint( $input['links_per_post'] ?? 3 ); + $dry_run = ! empty( $input['dry_run'] ); + $force = ! empty( $input['force'] ); + + $user_id = get_current_user_id(); + $agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0; + $system_defaults = PluginSettings::resolveModelForAgentContext( $agent_id, 'system' ); + $provider = $system_defaults['provider']; + $model = $system_defaults['model']; + + if ( empty( $provider ) || empty( $model ) ) { + return array( + 'success' => false, + 'queued_count' => 0, + 'post_ids' => array(), + 'message' => 'No default AI provider/model configured.', + 'error' => 'Configure default_provider and default_model in Data Machine settings.', + ); + } + + // Resolve category to post IDs. + if ( ! empty( $category ) ) { + $term = get_term_by( 'slug', $category, 'category' ); + if ( ! $term ) { + return array( + 'success' => false, + 'queued_count' => 0, + 'post_ids' => array(), + 'message' => "Category '{$category}' not found.", + 'error' => 'Invalid category slug', + ); + } + + $cat_posts = get_posts( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'category' => $term->term_id, + 'fields' => 'ids', + 'numberposts' => -1, + ) + ); + + $post_ids = array_merge( $post_ids, $cat_posts ); + } + + $post_ids = array_values( array_unique( array_filter( $post_ids ) ) ); + + if ( empty( $post_ids ) ) { + return array( + 'success' => false, + 'queued_count' => 0, + 'post_ids' => array(), + 'message' => 'No post IDs provided or resolved.', + 'error' => 'Missing required parameter: post_ids or category', + ); + } + + if ( $dry_run ) { + return array( + 'success' => true, + 'queued_count' => count( $post_ids ), + 'post_ids' => $post_ids, + 'message' => sprintf( 'Dry run: %d post(s) would be queued for internal linking.', count( $post_ids ) ), + ); + } + + // Filter to eligible posts. + $eligible = array(); + foreach ( $post_ids as $pid ) { + $post = get_post( $pid ); + if ( $post && 'publish' === $post->post_status ) { + $eligible[] = $pid; + } + } + + if ( empty( $eligible ) ) { + return array( + 'success' => true, + 'queued_count' => 0, + 'post_ids' => array(), + 'message' => 'No eligible published posts found.', + ); + } + + // Build per-item params for batch scheduling. + $item_params = array(); + foreach ( $eligible as $pid ) { + $item_params[] = array( + 'post_id' => $pid, + 'links_per_post' => $links_per_post, + 'force' => $force, + 'source' => 'ability', + ); + } + + $batch = TaskScheduler::scheduleBatch( + 'internal_linking', + $item_params, + array( + 'user_id' => $user_id, + 'agent_id' => $agent_id, + ) + ); + + if ( false === $batch ) { + return array( + 'success' => false, + 'queued_count' => 0, + 'post_ids' => array(), + 'message' => 'Failed to schedule batch.', + 'error' => 'Task batch scheduling failed.', + ); + } + + return array( + 'success' => true, + 'queued_count' => count( $eligible ), + 'post_ids' => $eligible, + 'batch_id' => $batch['batch_id'] ?? null, + 'message' => sprintf( + 'Internal linking batch scheduled for %d post(s) (chunks of %d).', + count( $eligible ), + $batch['chunk_size'] ?? TaskScheduler::BATCH_CHUNK_SIZE + ), + ); + } + + /** + * Check a single URL's HTTP status. + * + * Uses HEAD first for efficiency. For external URLs, falls back to GET + * with a range header when HEAD returns 405 or 403 (some servers block HEAD). + * + * @since 0.42.0 + * + * @param string $url URL to check. + * @param int $timeout Request timeout in seconds. + * @param bool $is_external Whether this is an external URL (enables GET fallback). + * @return int HTTP status code (0 for connection failures/timeouts). + */ + private static function checkUrlStatus( string $url, int $timeout, bool $is_external ): int { + $response = wp_remote_head( + $url, + array( + 'timeout' => $timeout, + 'redirection' => 3, + 'user-agent' => 'DataMachine/LinkChecker (WordPress; +' . home_url() . ')', + ) + ); + + if ( is_wp_error( $response ) ) { + return 0; + } + + $status = wp_remote_retrieve_response_code( $response ); + + // Some external servers block HEAD requests — fall back to GET. + if ( $is_external && ( 405 === $status || 403 === $status ) ) { + $get_response = wp_remote_get( + $url, + array( + 'timeout' => $timeout, + 'redirection' => 3, + 'headers' => array( 'Range' => 'bytes=0-0' ), + 'user-agent' => 'DataMachine/LinkChecker (WordPress; +' . home_url() . ')', + ) + ); + + if ( ! is_wp_error( $get_response ) ) { + $get_status = wp_remote_retrieve_response_code( $get_response ); + // 206 (Partial Content) means the server supports Range and the URL is alive. + if ( 206 === $get_status || ( $get_status >= 200 && $get_status < 400 ) ) { + return $get_status; + } + return $get_status; + } + } + + return $status ? $status : 0; + } diff --git a/inc/Abilities/Job/JobHelpers.php b/inc/Abilities/Job/JobHelpers.php index 0ae90102a..8740273d2 100644 --- a/inc/Abilities/Job/JobHelpers.php +++ b/inc/Abilities/Job/JobHelpers.php @@ -18,6 +18,7 @@ use DataMachine\Core\Database\Jobs\Jobs; use DataMachine\Core\Database\Pipelines\Pipelines; use DataMachine\Core\Database\ProcessedItems\ProcessedItems; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/JobAbilities.php b/inc/Abilities/JobAbilities.php index 0d03b6326..21b62c94d 100644 --- a/inc/Abilities/JobAbilities.php +++ b/inc/Abilities/JobAbilities.php @@ -22,6 +22,7 @@ use DataMachine\Abilities\Job\JobsSummaryAbility; use DataMachine\Abilities\Job\FailJobAbility; use DataMachine\Abilities\Job\RetryJobAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -40,6 +41,7 @@ class JobAbilities { private RetryJobAbility $retry_job; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/Media/ImageGenerationAbilities.php b/inc/Abilities/Media/ImageGenerationAbilities.php index e372a9a45..3062e7789 100644 --- a/inc/Abilities/Media/ImageGenerationAbilities.php +++ b/inc/Abilities/Media/ImageGenerationAbilities.php @@ -20,6 +20,7 @@ use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\RequestBuilder; use DataMachine\Engine\Tasks\TaskScheduler; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Pipeline/PipelineHelpers.php b/inc/Abilities/Pipeline/PipelineHelpers.php index 7add46936..6085b6c00 100644 --- a/inc/Abilities/Pipeline/PipelineHelpers.php +++ b/inc/Abilities/Pipeline/PipelineHelpers.php @@ -18,6 +18,7 @@ use DataMachine\Core\Admin\DateFormatter; use DataMachine\Core\Database\Flows\Flows; use DataMachine\Core\Database\Pipelines\Pipelines; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/PipelineAbilities.php b/inc/Abilities/PipelineAbilities.php index 52a2df19f..6ad80f5be 100644 --- a/inc/Abilities/PipelineAbilities.php +++ b/inc/Abilities/PipelineAbilities.php @@ -19,6 +19,7 @@ use DataMachine\Abilities\Pipeline\DeletePipelineAbility; use DataMachine\Abilities\Pipeline\DuplicatePipelineAbility; use DataMachine\Abilities\Pipeline\ImportExportAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -34,6 +35,7 @@ class PipelineAbilities { private ImportExportAbility $import_export; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Abilities/PipelineStepAbilities.php b/inc/Abilities/PipelineStepAbilities.php index da83a9f2a..6efc50b69 100644 --- a/inc/Abilities/PipelineStepAbilities.php +++ b/inc/Abilities/PipelineStepAbilities.php @@ -16,6 +16,7 @@ use DataMachine\Core\Database\Pipelines\Pipelines; use DataMachine\Core\Database\ProcessedItems\ProcessedItems; use DataMachine\Core\PluginSettings; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/ProcessedItemsAbilities.php b/inc/Abilities/ProcessedItemsAbilities.php index 01c90c9dd..16aad9044 100644 --- a/inc/Abilities/ProcessedItemsAbilities.php +++ b/inc/Abilities/ProcessedItemsAbilities.php @@ -15,6 +15,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\Database\ProcessedItems\ProcessedItems; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Publish/PublishWordPressAbility.php b/inc/Abilities/Publish/PublishWordPressAbility.php index 7c5fe8c6f..8d8bf15ec 100644 --- a/inc/Abilities/Publish/PublishWordPressAbility.php +++ b/inc/Abilities/Publish/PublishWordPressAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\PostTracking; use DataMachine\Core\WordPress\WordPressSettingsResolver; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Publish/SendEmailAbility.php b/inc/Abilities/Publish/SendEmailAbility.php index 86998bfc9..5c92e655a 100644 --- a/inc/Abilities/Publish/SendEmailAbility.php +++ b/inc/Abilities/Publish/SendEmailAbility.php @@ -16,6 +16,7 @@ namespace DataMachine\Abilities\Publish; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -170,8 +171,8 @@ public function execute( array $input ): array { $headers[] = "Content-Type: {$content_type}; charset=UTF-8"; // From. - $from_name = $config['from_name'] ?: get_bloginfo( 'name' ); - $from_email = $config['from_email'] ?: get_option( 'admin_email' ); + $from_name = $config['from_name'] ? $config['from_name'] : get_bloginfo( 'name' ); + $from_email = $config['from_email'] ? $config['from_email'] : get_option( 'admin_email' ); if ( $from_name && $from_email ) { $headers[] = sprintf( 'From: %s <%s>', $from_name, $from_email ); } @@ -262,7 +263,7 @@ public function execute( array $input ): array { global $phpmailer; $error_msg = 'wp_mail() returned false'; if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { - $error_msg = $phpmailer->ErrorInfo ?: $error_msg; + $error_msg = $phpmailer->ErrorInfo ? $phpmailer->ErrorInfo : $error_msg; } $logs[] = array( diff --git a/inc/Abilities/SettingsAbilities.php b/inc/Abilities/SettingsAbilities.php index 06e43e515..b95a23f5b 100644 --- a/inc/Abilities/SettingsAbilities.php +++ b/inc/Abilities/SettingsAbilities.php @@ -14,6 +14,7 @@ use DataMachine\Core\NetworkSettings; use DataMachine\Core\PluginSettings; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/StepTypeAbilities.php b/inc/Abilities/StepTypeAbilities.php index a0778ef55..8979b6575 100644 --- a/inc/Abilities/StepTypeAbilities.php +++ b/inc/Abilities/StepTypeAbilities.php @@ -12,6 +12,8 @@ namespace DataMachine\Abilities; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Abilities\Traits\HasCheckPermission; +use DataMachine\Core\NetworkSettings; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php b/inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php index 1c43ed666..dc1bc4404 100644 --- a/inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php +++ b/inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\TaxonomyHandler; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php b/inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php index ec352f480..7c314d1af 100644 --- a/inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php +++ b/inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\TaxonomyHandler; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php b/inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php index b928f391e..30d512bca 100644 --- a/inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php +++ b/inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\TaxonomyHandler; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/Taxonomy/ResolveTermAbility.php b/inc/Abilities/Taxonomy/ResolveTermAbility.php index 841a5aab1..8a106378c 100644 --- a/inc/Abilities/Taxonomy/ResolveTermAbility.php +++ b/inc/Abilities/Taxonomy/ResolveTermAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\TaxonomyHandler; +use DataMachine\Abilities\Traits\HasCheckPermission; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php b/inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php index c872cab4d..3eb8ccb51 100644 --- a/inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php +++ b/inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\WordPress\TaxonomyHandler; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; diff --git a/inc/Abilities/TaxonomyAbilities.php b/inc/Abilities/TaxonomyAbilities.php index d2da8bf47..35f71d4f0 100644 --- a/inc/Abilities/TaxonomyAbilities.php +++ b/inc/Abilities/TaxonomyAbilities.php @@ -18,6 +18,7 @@ use DataMachine\Abilities\Taxonomy\UpdateTaxonomyTermAbility; use DataMachine\Abilities\Taxonomy\DeleteTaxonomyTermAbility; use DataMachine\Abilities\Taxonomy\ResolveTermAbility; +use DataMachine\Abilities\Traits\HasCheckPermission; defined( 'ABSPATH' ) || exit; @@ -32,6 +33,7 @@ class TaxonomyAbilities { private ResolveTermAbility $resolve_term; public function __construct() { + add_action('wp_abilities_api_init', array( $this, 'abilities_api_init' )); if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } diff --git a/inc/Api/AgentFiles.php b/inc/Api/AgentFiles.php index 01292c931..b5978af8a 100644 --- a/inc/Api/AgentFiles.php +++ b/inc/Api/AgentFiles.php @@ -18,6 +18,7 @@ use WP_Error; use WP_REST_Request; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; @@ -525,7 +526,7 @@ public static function delete_daily_file( WP_REST_Request $request ) { * @return string Full path to the contexts directory. */ private static function resolve_contexts_dir( WP_REST_Request $request ): string { - $dm = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $dm = new \DataMachine\Core\FilesRepository\DirectoryManager(); $user_id = $dm->get_effective_user_id( self::resolve_scoped_user_id( $request ) ); $context = array( 'user_id' => $user_id ); diff --git a/inc/Api/AgentPing.php b/inc/Api/AgentPing.php index 67e863310..b16f4a2b5 100644 --- a/inc/Api/AgentPing.php +++ b/inc/Api/AgentPing.php @@ -14,6 +14,7 @@ use WP_REST_Server; use WP_REST_Request; use WP_REST_Response; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Agents.php b/inc/Api/Agents.php index a44fdabb8..453b09c18 100644 --- a/inc/Api/Agents.php +++ b/inc/Api/Agents.php @@ -19,6 +19,7 @@ use WP_REST_Request; use WP_REST_Server; use WP_Error; +use DataMachine\Api\Email; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Analytics.php b/inc/Api/Analytics.php index 881cca0bb..75ab933a2 100644 --- a/inc/Api/Analytics.php +++ b/inc/Api/Analytics.php @@ -22,6 +22,7 @@ use DataMachine\Abilities\PermissionHelper; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/Auth.php b/inc/Api/Auth.php index b07f99b79..ee3777b33 100644 --- a/inc/Api/Auth.php +++ b/inc/Api/Auth.php @@ -14,6 +14,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Abilities\AuthAbilities; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; @@ -277,8 +278,8 @@ private static function ability_to_response( array $result, string $error_code ) return rest_ensure_response( $result ); } - $error = $result['error'] ?? 'Unknown error'; - $status = 400; + $error = $result['error'] ?? 'Unknown error'; + $status = 400; if ( str_contains( $error, 'not found' ) ) { $status = 404; diff --git a/inc/Api/Chat/Chat.php b/inc/Api/Chat/Chat.php index f6e5e7c5d..ea67807df 100644 --- a/inc/Api/Chat/Chat.php +++ b/inc/Api/Chat/Chat.php @@ -20,6 +20,7 @@ use WP_REST_Server; use WP_REST_Request; use WP_Error; +use DataMachine\Api\Traits\HasRegister; require_once __DIR__ . '/ChatPipelinesDirective.php'; @@ -108,9 +109,9 @@ public static function register_routes() { 'sanitize_callback' => array( self::class, 'sanitize_attachments' ), ), 'client_context' => array( - 'type' => 'object', - 'required' => false, - 'description' => __( 'Client-side context for the AI agent. Arbitrary key-value pairs describing what the user is currently doing (active tab, draft ID, screen, etc). Injected as a system message.', 'data-machine' ), + 'type' => 'object', + 'required' => false, + 'description' => __( 'Client-side context for the AI agent. Arbitrary key-value pairs describing what the user is currently doing (active tab, draft ID, screen, etc). Injected as a system message.', 'data-machine' ), ), ), ) diff --git a/inc/Api/Chat/ChatOrchestrator.php b/inc/Api/Chat/ChatOrchestrator.php index b8a0488bc..1521347f9 100644 --- a/inc/Api/Chat/ChatOrchestrator.php +++ b/inc/Api/Chat/ChatOrchestrator.php @@ -123,7 +123,10 @@ public static function processChat( if ( ! empty( $attachments ) ) { $content = ConversationManager::buildMultiModalContent( $message, $attachments ); - $metadata = array( 'type' => 'multimodal', 'attachments' => $attachments ); + $metadata = array( + 'type' => 'multimodal', + 'attachments' => $attachments, + ); $messages[] = ConversationManager::buildConversationMessage( 'user', $content, $metadata ); } else { $messages[] = ConversationManager::buildConversationMessage( 'user', $message, array( 'type' => 'text' ) ); @@ -610,8 +613,8 @@ public static function executeConversationTurn( $agent_id = datamachine_resolve_or_create_agent_id( $user_id ); } - $resolver = new ToolPolicyResolver(); - $all_tools = $resolver->resolve( array( + $resolver = new ToolPolicyResolver(); + $all_tools = $resolver->resolve( array( 'context' => ToolPolicyResolver::CONTEXT_CHAT, 'agent_id' => $agent_id, ) ); diff --git a/inc/Api/Chat/Tools/AddPipelineStep.php b/inc/Api/Chat/Tools/AddPipelineStep.php index 5e29788a7..0da3c766d 100644 --- a/inc/Api/Chat/Tools/AddPipelineStep.php +++ b/inc/Api/Chat/Tools/AddPipelineStep.php @@ -17,6 +17,7 @@ use DataMachine\Abilities\StepTypeAbilities; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Api\Chat\Tools\CreatePipeline; class AddPipelineStep extends BaseTool { diff --git a/inc/Api/Chat/Tools/CreatePipeline.php b/inc/Api/Chat/Tools/CreatePipeline.php index eb31ae3f8..268ad06dd 100644 --- a/inc/Api/Chat/Tools/CreatePipeline.php +++ b/inc/Api/Chat/Tools/CreatePipeline.php @@ -17,6 +17,7 @@ use DataMachine\Abilities\StepTypeAbilities; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Api\Chat\Tools\CreateFlow; class CreatePipeline extends BaseTool { diff --git a/inc/Api/Email.php b/inc/Api/Email.php index 2b97188da..758d3dbe9 100644 --- a/inc/Api/Email.php +++ b/inc/Api/Email.php @@ -32,16 +32,46 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_send' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'to' => array( 'type' => 'string', 'required' => true ), - 'subject' => array( 'type' => 'string', 'required' => true ), - 'body' => array( 'type' => 'string', 'required' => true ), - 'cc' => array( 'type' => 'string', 'default' => '' ), - 'bcc' => array( 'type' => 'string', 'default' => '' ), - 'from_name' => array( 'type' => 'string', 'default' => '' ), - 'from_email' => array( 'type' => 'string', 'default' => '' ), - 'reply_to' => array( 'type' => 'string', 'default' => '' ), - 'content_type' => array( 'type' => 'string', 'default' => 'text/html' ), - 'attachments' => array( 'type' => 'array', 'default' => array() ), + 'to' => array( + 'type' => 'string', + 'required' => true, + ), + 'subject' => array( + 'type' => 'string', + 'required' => true, + ), + 'body' => array( + 'type' => 'string', + 'required' => true, + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + ), + 'bcc' => array( + 'type' => 'string', + 'default' => '', + ), + 'from_name' => array( + 'type' => 'string', + 'default' => '', + ), + 'from_email' => array( + 'type' => 'string', + 'default' => '', + ), + 'reply_to' => array( + 'type' => 'string', + 'default' => '', + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + ), + 'attachments' => array( + 'type' => 'array', + 'default' => array(), + ), ), ) ); @@ -55,13 +85,34 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_fetch' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), - 'search' => array( 'type' => 'string', 'default' => 'UNSEEN' ), - 'max' => array( 'type' => 'integer', 'default' => 10 ), - 'offset' => array( 'type' => 'integer', 'default' => 0 ), - 'headers_only' => array( 'type' => 'boolean', 'default' => false ), - 'mark_as_read' => array( 'type' => 'boolean', 'default' => false ), - 'download_attachments' => array( 'type' => 'boolean', 'default' => false ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'search' => array( + 'type' => 'string', + 'default' => 'UNSEEN', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 10, + ), + 'offset' => array( + 'type' => 'integer', + 'default' => 0, + ), + 'headers_only' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'mark_as_read' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'download_attachments' => array( + 'type' => 'boolean', + 'default' => false, + ), ), ) ); @@ -75,8 +126,14 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_read' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'uid' => array( 'type' => 'integer', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'uid' => array( + 'type' => 'integer', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), ), ) ); @@ -90,13 +147,34 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_reply' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'to' => array( 'type' => 'string', 'required' => true ), - 'subject' => array( 'type' => 'string', 'required' => true ), - 'body' => array( 'type' => 'string', 'required' => true ), - 'in_reply_to' => array( 'type' => 'string', 'required' => true ), - 'references' => array( 'type' => 'string', 'default' => '' ), - 'cc' => array( 'type' => 'string', 'default' => '' ), - 'content_type' => array( 'type' => 'string', 'default' => 'text/html' ), + 'to' => array( + 'type' => 'string', + 'required' => true, + ), + 'subject' => array( + 'type' => 'string', + 'required' => true, + ), + 'body' => array( + 'type' => 'string', + 'required' => true, + ), + 'in_reply_to' => array( + 'type' => 'string', + 'required' => true, + ), + 'references' => array( + 'type' => 'string', + 'default' => '', + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + ), ), ) ); @@ -110,8 +188,14 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_delete' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'uid' => array( 'type' => 'integer', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'uid' => array( + 'type' => 'integer', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), ), ) ); @@ -125,9 +209,18 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_move' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'uid' => array( 'type' => 'integer', 'required' => true ), - 'destination' => array( 'type' => 'string', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'uid' => array( + 'type' => 'integer', + 'required' => true, + ), + 'destination' => array( + 'type' => 'string', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), ), ) ); @@ -141,10 +234,22 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_flag' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'uid' => array( 'type' => 'integer', 'required' => true ), - 'flag' => array( 'type' => 'string', 'required' => true ), - 'action' => array( 'type' => 'string', 'default' => 'set' ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'uid' => array( + 'type' => 'integer', + 'required' => true, + ), + 'flag' => array( + 'type' => 'string', + 'required' => true, + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), ), ) ); @@ -158,10 +263,22 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_batch_move' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'search' => array( 'type' => 'string', 'required' => true ), - 'destination' => array( 'type' => 'string', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), - 'max' => array( 'type' => 'integer', 'default' => 500 ), + 'search' => array( + 'type' => 'string', + 'required' => true, + ), + 'destination' => array( + 'type' => 'string', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + ), ), ) ); @@ -175,11 +292,26 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_batch_flag' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'search' => array( 'type' => 'string', 'required' => true ), - 'flag' => array( 'type' => 'string', 'required' => true ), - 'action' => array( 'type' => 'string', 'default' => 'set' ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), - 'max' => array( 'type' => 'integer', 'default' => 500 ), + 'search' => array( + 'type' => 'string', + 'required' => true, + ), + 'flag' => array( + 'type' => 'string', + 'required' => true, + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + ), ), ) ); @@ -193,9 +325,18 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_batch_delete' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'search' => array( 'type' => 'string', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), - 'max' => array( 'type' => 'integer', 'default' => 100 ), + 'search' => array( + 'type' => 'string', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 100, + ), ), ) ); @@ -209,8 +350,14 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_unsubscribe' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'uid' => array( 'type' => 'integer', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'uid' => array( + 'type' => 'integer', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), ), ) ); @@ -224,9 +371,18 @@ public static function register_routes(): void { 'callback' => array( self::class, 'handle_batch_unsubscribe' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'search' => array( 'type' => 'string', 'required' => true ), - 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), - 'max' => array( 'type' => 'integer', 'default' => 20 ), + 'search' => array( + 'type' => 'string', + 'required' => true, + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 20, + ), ), ) ); @@ -478,6 +634,7 @@ public static function handle_batch_unsubscribe( \WP_REST_Request $request ): \W } public static function handle_test_connection( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + unset( $request ); $ability = wp_get_ability( 'datamachine/email-test-connection' ); if ( ! $ability ) { return new \WP_Error( 'ability_not_found', 'Email test connection ability not available', array( 'status' => 500 ) ); diff --git a/inc/Api/Execute.php b/inc/Api/Execute.php index cb15fe02e..2a083123d 100644 --- a/inc/Api/Execute.php +++ b/inc/Api/Execute.php @@ -10,6 +10,7 @@ namespace DataMachine\Api; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/FlowFiles.php b/inc/Api/FlowFiles.php index ea4a7d01d..e0cde89c4 100644 --- a/inc/Api/FlowFiles.php +++ b/inc/Api/FlowFiles.php @@ -16,6 +16,7 @@ use WP_Error; use WP_REST_Request; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Flows/FlowQueue.php b/inc/Api/Flows/FlowQueue.php index d1b720542..9d11fa39f 100644 --- a/inc/Api/Flows/FlowQueue.php +++ b/inc/Api/Flows/FlowQueue.php @@ -12,6 +12,7 @@ use DataMachine\Abilities\PermissionHelper; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Flows/FlowScheduling.php b/inc/Api/Flows/FlowScheduling.php index 86aaaebaa..9a0d0841b 100644 --- a/inc/Api/Flows/FlowScheduling.php +++ b/inc/Api/Flows/FlowScheduling.php @@ -470,4 +470,8 @@ private static function schedule_cron( int $flow_id, string $cron_expression, $d return true; } + + public function __construct() { + add_action('rest_api_init', array( $this, 'rest_api_init' )); + } } diff --git a/inc/Api/Flows/FlowSteps.php b/inc/Api/Flows/FlowSteps.php index 9ae91620e..95125903b 100644 --- a/inc/Api/Flows/FlowSteps.php +++ b/inc/Api/Flows/FlowSteps.php @@ -15,6 +15,7 @@ use DataMachine\Abilities\FlowStepAbilities; use DataMachine\Abilities\HandlerAbilities; use DataMachine\Abilities\StepTypeAbilities; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { diff --git a/inc/Api/Flows/Flows.php b/inc/Api/Flows/Flows.php index 0a88bbc6a..06218602b 100644 --- a/inc/Api/Flows/Flows.php +++ b/inc/Api/Flows/Flows.php @@ -12,6 +12,7 @@ use DataMachine\Abilities\PermissionHelper; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; @@ -19,999 +20,4 @@ class Flows { - /** - * Register REST API routes - */ - public static function register() { - add_action( 'rest_api_init', array( self::class, 'register_routes' ) ); - } - - /** - * Register flow CRUD endpoints - */ - public static function register_routes() { - register_rest_route( - 'datamachine/v1', - '/flows', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_create_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'pipeline_id' => array( - 'required' => true, - 'type' => 'integer', - 'description' => __( 'Parent pipeline ID', 'data-machine' ), - 'validate_callback' => function ( $param ) { - return is_numeric( $param ) && $param > 0; - }, - 'sanitize_callback' => function ( $param ) { - return (int) $param; - }, - ), - 'flow_name' => array( - 'required' => false, - 'type' => 'string', - 'default' => 'Flow', - 'description' => __( 'Flow name', 'data-machine' ), - 'sanitize_callback' => function ( $param ) { - return sanitize_text_field( $param ); - }, - ), - 'flow_config' => array( - 'required' => false, - 'type' => 'array', - 'description' => __( 'Flow configuration (handler settings per step)', 'data-machine' ), - ), - 'scheduling_config' => array( - 'required' => false, - 'type' => 'array', - 'description' => __( 'Scheduling configuration', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows', - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'handle_get_flows' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'pipeline_id' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Optional pipeline ID to filter flows', 'data-machine' ), - ), - 'per_page' => array( - 'required' => false, - 'type' => 'integer', - 'default' => 20, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'description' => __( 'Number of flows per page', 'data-machine' ), - ), - 'offset' => array( - 'required' => false, - 'type' => 'integer', - 'default' => 0, - 'minimum' => 0, - 'sanitize_callback' => 'absint', - 'description' => __( 'Offset for pagination', 'data-machine' ), - ), - 'user_id' => array( - 'required' => false, - 'type' => 'integer', - 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), - 'sanitize_callback' => 'absint', - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/(?P\d+)', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'handle_get_single_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID to retrieve', 'data-machine' ), - ), - ), - ), - array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( self::class, 'handle_delete_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID to delete', 'data-machine' ), - ), - ), - ), - array( - 'methods' => 'PATCH', - 'callback' => array( self::class, 'handle_update_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID to update', 'data-machine' ), - ), - 'flow_name' => array( - 'required' => false, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'New flow title', 'data-machine' ), - ), - 'scheduling_config' => array( - 'required' => false, - 'type' => 'object', - 'description' => __( 'Scheduling configuration', 'data-machine' ), - ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/(?P\d+)/pause', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_pause_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID to pause', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/(?P\d+)/resume', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_resume_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID to resume', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/pause', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_bulk_pause' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'pipeline_id' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Pause all flows in this pipeline', 'data-machine' ), - ), - 'agent_id' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Pause all flows for this agent', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/resume', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_bulk_resume' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'pipeline_id' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Resume all flows in this pipeline', 'data-machine' ), - ), - 'agent_id' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Resume all flows for this agent', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/(?P\d+)/duplicate', - array( - 'methods' => 'POST', - 'callback' => array( self::class, 'handle_duplicate_flow' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Source flow ID to duplicate', 'data-machine' ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/(?P\d+)/memory-files', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'handle_get_memory_files' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - ), - ), - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( self::class, 'handle_update_memory_files' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'flow_id' => array( - 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Flow ID', 'data-machine' ), - ), - 'memory_files' => array( - 'required' => true, - 'type' => 'array', - 'description' => __( 'Array of agent memory filenames', 'data-machine' ), - 'items' => array( - 'type' => 'string', - ), - ), - ), - ), - ) - ); - - register_rest_route( - 'datamachine/v1', - '/flows/problems', - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'handle_get_problem_flows' ), - 'permission_callback' => array( self::class, 'check_permission' ), - 'args' => array( - 'threshold' => array( - 'required' => false, - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'description' => __( 'Minimum consecutive failures (defaults to problem_flow_threshold setting)', 'data-machine' ), - ), - ), - ) - ); - } - - /** - * Check if user has permission to manage flows - */ - public static function check_permission( $request ) { - $request; - if ( ! PermissionHelper::can( 'manage_flows' ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to create flows.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - return true; - } - - /** - * Handle flow creation request - */ - public static function handle_create_flow( $request ) { - $ability = wp_get_ability( 'datamachine/create-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array( - 'pipeline_id' => (int) $request->get_param( 'pipeline_id' ), - 'flow_name' => $request->get_param( 'flow_name' ) ?? 'Flow', - 'user_id' => PermissionHelper::acting_user_id(), - ); - - // Carry agent_id from body params or query string (agent interceptor). - $scoped_agent_id = PermissionHelper::resolve_scoped_agent_id( $request ); - if ( null !== $scoped_agent_id ) { - $input['agent_id'] = $scoped_agent_id; - } - - if ( $request->get_param( 'flow_config' ) ) { - $input['flow_config'] = $request->get_param( 'flow_config' ); - } - if ( $request->get_param( 'scheduling_config' ) ) { - $input['scheduling_config'] = $request->get_param( 'scheduling_config' ); - } - - $result = $ability->execute( $input ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'flow_creation_failed', - $result['error'] ?? __( 'Failed to create flow.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( - array( - 'success' => true, - 'data' => $result, - ) - ); - } - - /** - * Handle flow deletion request - */ - public static function handle_delete_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - // Verify ownership before deleting. - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db_flows->get_flow( $flow_id ); - - $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; - if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to delete this flow.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - $ability = wp_get_ability( 'datamachine/delete-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $result = $ability->execute( - array( - 'flow_id' => $flow_id, - ) - ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'flow_deletion_failed', - $result['error'] ?? __( 'Failed to delete flow.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle flow duplication request - */ - public static function handle_duplicate_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - // Verify ownership before duplicating. - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db_flows->get_flow( $flow_id ); - - $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; - if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to duplicate this flow.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - $ability = wp_get_ability( 'datamachine/duplicate-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $result = $ability->execute( - array( - 'source_flow_id' => $flow_id, - 'user_id' => PermissionHelper::acting_user_id(), - ) - ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'flow_duplication_failed', - $result['error'] ?? __( 'Failed to duplicate flow.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle flows retrieval request with pagination support - */ - public static function handle_get_flows( $request ) { - $pipeline_id = $request->get_param( 'pipeline_id' ); - $per_page = $request->get_param( 'per_page' ) ?? 20; - $offset = $request->get_param( 'offset' ) ?? 0; - $scoped_user_id = PermissionHelper::resolve_scoped_user_id( $request ); - $scoped_agent_id = PermissionHelper::resolve_scoped_agent_id( $request ); - - $ability = wp_get_ability( 'datamachine/get-flows' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array( - 'pipeline_id' => $pipeline_id, - 'per_page' => $per_page, - 'offset' => $offset, - ); - if ( null !== $scoped_agent_id ) { - $input['agent_id'] = $scoped_agent_id; - } elseif ( null !== $scoped_user_id ) { - $input['user_id'] = $scoped_user_id; - } - $result = $ability->execute( $input ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - if ( ! $result['success'] ) { - return new \WP_Error( 'ability_error', $result['error'], array( 'status' => 500 ) ); - } - - if ( $pipeline_id ) { - return rest_ensure_response( - array( - 'success' => true, - 'data' => array( - 'pipeline_id' => $pipeline_id, - 'flows' => $result['flows'], - ), - 'total' => $result['total'], - 'per_page' => $result['per_page'], - 'offset' => $result['offset'], - ) - ); - } - - return rest_ensure_response( - array( - 'success' => true, - 'data' => $result['flows'], - 'total' => $result['total'] ?? count( $result['flows'] ), - 'per_page' => $result['per_page'] ?? 20, - 'offset' => $result['offset'] ?? 0, - ) - ); - } - - /** - * Handle single flow retrieval request with scheduling metadata - */ - public static function handle_get_single_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - $ability = wp_get_ability( 'datamachine/get-flows' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $result = $ability->execute( array( 'flow_id' => $flow_id ) ); - - if ( ! $result['success'] || empty( $result['flows'] ) ) { - $status = 400; - if ( false !== strpos( $result['error'] ?? '', 'not found' ) || empty( $result['flows'] ) ) { - $status = 404; - } - - return new \WP_Error( - 'flow_not_found', - $result['error'] ?? __( 'Flow not found.', 'data-machine' ), - array( 'status' => $status ) - ); - } - - return rest_ensure_response( - array( - 'success' => true, - 'data' => $result['flows'][0], - ) - ); - } - - /** - * Handle flow update request (title and/or scheduling) - * - * PATCH /datamachine/v1/flows/{id} - */ - public static function handle_update_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - // Verify ownership before updating. - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db_flows->get_flow( $flow_id ); - - $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; - if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to update this flow.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - $ability = wp_get_ability( 'datamachine/update-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array( - 'flow_id' => $flow_id, - ); - - $flow_name = $request->get_param( 'flow_name' ); - $scheduling_config = $request->get_param( 'scheduling_config' ); - - if ( null !== $flow_name ) { - $input['flow_name'] = $flow_name; - } - if ( null !== $scheduling_config ) { - $input['scheduling_config'] = $scheduling_config; - } - - $result = $ability->execute( $input ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'update_failed', - $result['error'] ?? __( 'Failed to update flow', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - $flow_id = $result['flow_id']; - - $get_ability = wp_get_ability( 'datamachine/get-flows' ); - if ( $get_ability ) { - $flow_result = $get_ability->execute( array( 'flow_id' => $flow_id ) ); - if ( ( $flow_result['success'] ?? false ) && ! empty( $flow_result['flows'] ) ) { - return rest_ensure_response( - array( - 'success' => true, - 'data' => $flow_result['flows'][0], - 'message' => __( 'Flow updated successfully', 'data-machine' ), - ) - ); - } - } - - return rest_ensure_response( - array( - 'success' => true, - 'data' => $result['flow_data'] ?? array( 'flow_id' => $flow_id ), - 'message' => __( 'Flow updated successfully', 'data-machine' ), - ) - ); - } - - /** - * Handle single flow pause request. - * - * POST /datamachine/v1/flows/{flow_id}/pause - * - * @since 0.59.0 - */ - public static function handle_pause_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - $ability = wp_get_ability( 'datamachine/pause-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $result = $ability->execute( array( 'flow_id' => $flow_id ) ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'pause_failed', - $result['error'] ?? __( 'Failed to pause flow.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle single flow resume request. - * - * POST /datamachine/v1/flows/{flow_id}/resume - * - * @since 0.59.0 - */ - public static function handle_resume_flow( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - $ability = wp_get_ability( 'datamachine/resume-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $result = $ability->execute( array( 'flow_id' => $flow_id ) ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'resume_failed', - $result['error'] ?? __( 'Failed to resume flow.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle bulk pause request (by pipeline or agent). - * - * POST /datamachine/v1/flows/pause - * - * @since 0.59.0 - */ - public static function handle_bulk_pause( $request ) { - $pipeline_id = $request->get_param( 'pipeline_id' ); - $agent_id = $request->get_param( 'agent_id' ); - - if ( ! $pipeline_id && ! $agent_id ) { - return new \WP_Error( - 'missing_scope', - __( 'Must provide pipeline_id or agent_id.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - $ability = wp_get_ability( 'datamachine/pause-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array(); - if ( $pipeline_id ) { - $input['pipeline_id'] = (int) $pipeline_id; - } - if ( $agent_id ) { - $input['agent_id'] = (int) $agent_id; - } - - $result = $ability->execute( $input ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'bulk_pause_failed', - $result['error'] ?? __( 'Failed to pause flows.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle bulk resume request (by pipeline or agent). - * - * POST /datamachine/v1/flows/resume - * - * @since 0.59.0 - */ - public static function handle_bulk_resume( $request ) { - $pipeline_id = $request->get_param( 'pipeline_id' ); - $agent_id = $request->get_param( 'agent_id' ); - - if ( ! $pipeline_id && ! $agent_id ) { - return new \WP_Error( - 'missing_scope', - __( 'Must provide pipeline_id or agent_id.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - $ability = wp_get_ability( 'datamachine/resume-flow' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array(); - if ( $pipeline_id ) { - $input['pipeline_id'] = (int) $pipeline_id; - } - if ( $agent_id ) { - $input['agent_id'] = (int) $agent_id; - } - - $result = $ability->execute( $input ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'bulk_resume_failed', - $result['error'] ?? __( 'Failed to resume flows.', 'data-machine' ), - array( 'status' => 400 ) - ); - } - - return rest_ensure_response( $result ); - } - - /** - * Handle problem flows retrieval request. - * - * Returns flows with consecutive failures at or above the threshold. - * - * GET /datamachine/v1/flows/problems - */ - public static function handle_get_problem_flows( $request ) { - $threshold = $request->get_param( 'threshold' ); - - $ability = wp_get_ability( 'datamachine/get-problem-flows' ); - if ( ! $ability ) { - return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); - } - - $input = array(); - if ( null !== $threshold && $threshold > 0 ) { - $input['threshold'] = (int) $threshold; - } - - $result = $ability->execute( $input ); - - if ( ! $result['success'] ) { - return new \WP_Error( - 'get_problem_flows_error', - $result['error'] ?? __( 'Failed to get problem flows', 'data-machine' ), - array( 'status' => 500 ) - ); - } - - $problem_flows = array_merge( $result['failing'] ?? array(), $result['idle'] ?? array() ); - - return rest_ensure_response( - array( - 'success' => true, - 'data' => array( - 'problem_flows' => $problem_flows, - 'total' => $result['count'] ?? count( $problem_flows ), - 'threshold' => $result['threshold'] ?? 3, - 'failing' => $result['failing'] ?? array(), - 'idle' => $result['idle'] ?? array(), - ), - ) - ); - } - - /** - * Handle get memory files request for a flow. - * - * GET /datamachine/v1/flows/{flow_id}/memory-files - * - * @param \WP_REST_Request $request REST request. - * @return \WP_REST_Response|\WP_Error Response. - */ - public static function handle_get_memory_files( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db_flows->get_flow( $flow_id ); - - if ( ! $flow ) { - return new \WP_Error( - 'flow_not_found', - __( 'Flow not found.', 'data-machine' ), - array( 'status' => 404 ) - ); - } - - $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; - if ( ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to access this flow.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - $memory_files = $db_flows->get_flow_memory_files( $flow_id ); - $daily_memory = $db_flows->get_flow_daily_memory( $flow_id ); - - return rest_ensure_response( - array( - 'success' => true, - 'data' => array( - 'memory_files' => $memory_files, - 'daily_memory' => $daily_memory, - ), - ) - ); - } - - /** - * Handle update memory files request for a flow. - * - * PUT/POST /datamachine/v1/flows/{flow_id}/memory-files - * - * @param \WP_REST_Request $request REST request. - * @return \WP_REST_Response|\WP_Error Response. - */ - public static function handle_update_memory_files( $request ) { - $flow_id = (int) $request->get_param( 'flow_id' ); - $params = $request->get_json_params(); - $memory_files = $params['memory_files'] ?? array(); - $daily_memory = $params['daily_memory'] ?? null; - - $db_flows = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db_flows->get_flow( $flow_id ); - - if ( ! $flow ) { - return new \WP_Error( - 'flow_not_found', - __( 'Flow not found.', 'data-machine' ), - array( 'status' => 404 ) - ); - } - - $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; - if ( ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { - return new \WP_Error( - 'rest_forbidden', - __( 'You do not have permission to update this flow.', 'data-machine' ), - array( 'status' => 403 ) - ); - } - - // Sanitize filenames. - $memory_files = array_map( 'sanitize_file_name', $memory_files ); - $memory_files = array_values( array_filter( $memory_files ) ); - - // Sanitize daily_memory config if provided. - if ( null !== $daily_memory ) { - $daily_memory = self::sanitize_daily_memory( $daily_memory ); - } - - $result = $db_flows->update_flow_memory_files( $flow_id, $memory_files, $daily_memory ); - - if ( ! $result ) { - return new \WP_Error( - 'update_failed', - __( 'Failed to update memory files.', 'data-machine' ), - array( 'status' => 500 ) - ); - } - - return rest_ensure_response( - array( - 'success' => true, - 'data' => array( - 'memory_files' => $memory_files, - 'daily_memory' => $daily_memory ?? $db_flows->get_flow_daily_memory( $flow_id ), - ), - 'message' => __( 'Flow memory files updated successfully.', 'data-machine' ), - ) - ); - } - - /** - * Sanitize daily memory configuration. - * - * @since 0.40.0 - * - * @param array $config Raw daily memory config. - * @return array Sanitized config. - */ - private static function sanitize_daily_memory( array $config ): array { - $allowed_modes = array( 'none', 'recent_days', 'specific_dates', 'date_range', 'months' ); - $mode = $config['mode'] ?? 'none'; - - if ( ! in_array( $mode, $allowed_modes, true ) ) { - $mode = 'none'; - } - - $sanitized = array( 'mode' => $mode ); - - switch ( $mode ) { - case 'recent_days': - $sanitized['days'] = min( max( (int) ( $config['days'] ?? 7 ), 1 ), 90 ); - break; - - case 'specific_dates': - $dates = $config['dates'] ?? array(); - $sanitized['dates'] = array_values( - array_filter( - array_map( - function ( $date ) { - return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : null; - }, - (array) $dates - ) - ) - ); - break; - - case 'date_range': - $from = $config['from'] ?? null; - $to = $config['to'] ?? null; - if ( $from && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $from ) ) { - $sanitized['from'] = $from; - } - if ( $to && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $to ) ) { - $sanitized['to'] = $to; - } - break; - - case 'months': - $months = $config['months'] ?? array(); - $sanitized['months'] = array_values( - array_filter( - array_map( - function ( $month ) { - return preg_match( '/^\d{4}\/\d{2}$/', $month ) ? $month : null; - }, - (array) $months - ) - ) - ); - break; - } - - return $sanitized; - } } diff --git a/inc/Api/Flows/Flows/handle.php b/inc/Api/Flows/Flows/handle.php new file mode 100644 index 000000000..eb19ad739 --- /dev/null +++ b/inc/Api/Flows/Flows/handle.php @@ -0,0 +1,278 @@ +//! handle — extracted from Flows.php. + + + /** + * Check if user has permission to manage flows + */ + public static function check_permission( $request ) { + $request; + if ( ! PermissionHelper::can( 'manage_flows' ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to create flows.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Handle flow creation request + */ + public static function handle_create_flow( $request ) { + $ability = wp_get_ability( 'datamachine/create-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array( + 'pipeline_id' => (int) $request->get_param( 'pipeline_id' ), + 'flow_name' => $request->get_param( 'flow_name' ) ?? 'Flow', + 'user_id' => PermissionHelper::acting_user_id(), + ); + + // Carry agent_id from body params or query string (agent interceptor). + $scoped_agent_id = PermissionHelper::resolve_scoped_agent_id( $request ); + if ( null !== $scoped_agent_id ) { + $input['agent_id'] = $scoped_agent_id; + } + + if ( $request->get_param( 'flow_config' ) ) { + $input['flow_config'] = $request->get_param( 'flow_config' ); + } + if ( $request->get_param( 'scheduling_config' ) ) { + $input['scheduling_config'] = $request->get_param( 'scheduling_config' ); + } + + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'flow_creation_failed', + $result['error'] ?? __( 'Failed to create flow.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( + array( + 'success' => true, + 'data' => $result, + ) + ); + } + + /** + * Handle flow deletion request + */ + public static function handle_delete_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + // Verify ownership before deleting. + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; + if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to delete this flow.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + $ability = wp_get_ability( 'datamachine/delete-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $result = $ability->execute( + array( + 'flow_id' => $flow_id, + ) + ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'flow_deletion_failed', + $result['error'] ?? __( 'Failed to delete flow.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Handle flow duplication request + */ + public static function handle_duplicate_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + // Verify ownership before duplicating. + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; + if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to duplicate this flow.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + $ability = wp_get_ability( 'datamachine/duplicate-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $result = $ability->execute( + array( + 'source_flow_id' => $flow_id, + 'user_id' => PermissionHelper::acting_user_id(), + ) + ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'flow_duplication_failed', + $result['error'] ?? __( 'Failed to duplicate flow.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Handle flow update request (title and/or scheduling) + * + * PATCH /datamachine/v1/flows/{id} + */ + public static function handle_update_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + // Verify ownership before updating. + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; + if ( $flow && ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to update this flow.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + $ability = wp_get_ability( 'datamachine/update-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array( + 'flow_id' => $flow_id, + ); + + $flow_name = $request->get_param( 'flow_name' ); + $scheduling_config = $request->get_param( 'scheduling_config' ); + + if ( null !== $flow_name ) { + $input['flow_name'] = $flow_name; + } + if ( null !== $scheduling_config ) { + $input['scheduling_config'] = $scheduling_config; + } + + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'update_failed', + $result['error'] ?? __( 'Failed to update flow', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + $flow_id = $result['flow_id']; + + $get_ability = wp_get_ability( 'datamachine/get-flows' ); + if ( $get_ability ) { + $flow_result = $get_ability->execute( array( 'flow_id' => $flow_id ) ); + if ( ( $flow_result['success'] ?? false ) && ! empty( $flow_result['flows'] ) ) { + return rest_ensure_response( + array( + 'success' => true, + 'data' => $flow_result['flows'][0], + 'message' => __( 'Flow updated successfully', 'data-machine' ), + ) + ); + } + } + + return rest_ensure_response( + array( + 'success' => true, + 'data' => $result['flow_data'] ?? array( 'flow_id' => $flow_id ), + 'message' => __( 'Flow updated successfully', 'data-machine' ), + ) + ); + } + + /** + * Handle single flow pause request. + * + * POST /datamachine/v1/flows/{flow_id}/pause + * + * @since 0.59.0 + */ + public static function handle_pause_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + $ability = wp_get_ability( 'datamachine/pause-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( 'flow_id' => $flow_id ) ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'pause_failed', + $result['error'] ?? __( 'Failed to pause flow.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Handle single flow resume request. + * + * POST /datamachine/v1/flows/{flow_id}/resume + * + * @since 0.59.0 + */ + public static function handle_resume_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + $ability = wp_get_ability( 'datamachine/resume-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( 'flow_id' => $flow_id ) ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'resume_failed', + $result['error'] ?? __( 'Failed to resume flow.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } diff --git a/inc/Api/Flows/Flows/handle_bulk.php b/inc/Api/Flows/Flows/handle_bulk.php new file mode 100644 index 000000000..31f07015a --- /dev/null +++ b/inc/Api/Flows/Flows/handle_bulk.php @@ -0,0 +1,92 @@ +//! handle_bulk — extracted from Flows.php. + + + /** + * Handle bulk pause request (by pipeline or agent). + * + * POST /datamachine/v1/flows/pause + * + * @since 0.59.0 + */ + public static function handle_bulk_pause( $request ) { + $pipeline_id = $request->get_param( 'pipeline_id' ); + $agent_id = $request->get_param( 'agent_id' ); + + if ( ! $pipeline_id && ! $agent_id ) { + return new \WP_Error( + 'missing_scope', + __( 'Must provide pipeline_id or agent_id.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + $ability = wp_get_ability( 'datamachine/pause-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array(); + if ( $pipeline_id ) { + $input['pipeline_id'] = (int) $pipeline_id; + } + if ( $agent_id ) { + $input['agent_id'] = (int) $agent_id; + } + + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'bulk_pause_failed', + $result['error'] ?? __( 'Failed to pause flows.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Handle bulk resume request (by pipeline or agent). + * + * POST /datamachine/v1/flows/resume + * + * @since 0.59.0 + */ + public static function handle_bulk_resume( $request ) { + $pipeline_id = $request->get_param( 'pipeline_id' ); + $agent_id = $request->get_param( 'agent_id' ); + + if ( ! $pipeline_id && ! $agent_id ) { + return new \WP_Error( + 'missing_scope', + __( 'Must provide pipeline_id or agent_id.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + $ability = wp_get_ability( 'datamachine/resume-flow' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array(); + if ( $pipeline_id ) { + $input['pipeline_id'] = (int) $pipeline_id; + } + if ( $agent_id ) { + $input['agent_id'] = (int) $agent_id; + } + + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'bulk_resume_failed', + $result['error'] ?? __( 'Failed to resume flows.', 'data-machine' ), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } diff --git a/inc/Api/Flows/Flows/handle_get.php b/inc/Api/Flows/Flows/handle_get.php new file mode 100644 index 000000000..83322c40a --- /dev/null +++ b/inc/Api/Flows/Flows/handle_get.php @@ -0,0 +1,188 @@ +//! handle_get — extracted from Flows.php. + + + /** + * Handle flows retrieval request with pagination support + */ + public static function handle_get_flows( $request ) { + $pipeline_id = $request->get_param( 'pipeline_id' ); + $per_page = $request->get_param( 'per_page' ) ?? 20; + $offset = $request->get_param( 'offset' ) ?? 0; + $scoped_user_id = PermissionHelper::resolve_scoped_user_id( $request ); + $scoped_agent_id = PermissionHelper::resolve_scoped_agent_id( $request ); + + $ability = wp_get_ability( 'datamachine/get-flows' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array( + 'pipeline_id' => $pipeline_id, + 'per_page' => $per_page, + 'offset' => $offset, + ); + if ( null !== $scoped_agent_id ) { + $input['agent_id'] = $scoped_agent_id; + } elseif ( null !== $scoped_user_id ) { + $input['user_id'] = $scoped_user_id; + } + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result['success'] ) { + return new \WP_Error( 'ability_error', $result['error'], array( 'status' => 500 ) ); + } + + if ( $pipeline_id ) { + return rest_ensure_response( + array( + 'success' => true, + 'data' => array( + 'pipeline_id' => $pipeline_id, + 'flows' => $result['flows'], + ), + 'total' => $result['total'], + 'per_page' => $result['per_page'], + 'offset' => $result['offset'], + ) + ); + } + + return rest_ensure_response( + array( + 'success' => true, + 'data' => $result['flows'], + 'total' => $result['total'] ?? count( $result['flows'] ), + 'per_page' => $result['per_page'] ?? 20, + 'offset' => $result['offset'] ?? 0, + ) + ); + } + + /** + * Handle single flow retrieval request with scheduling metadata + */ + public static function handle_get_single_flow( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + $ability = wp_get_ability( 'datamachine/get-flows' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( 'flow_id' => $flow_id ) ); + + if ( ! $result['success'] || empty( $result['flows'] ) ) { + $status = 400; + if ( false !== strpos( $result['error'] ?? '', 'not found' ) || empty( $result['flows'] ) ) { + $status = 404; + } + + return new \WP_Error( + 'flow_not_found', + $result['error'] ?? __( 'Flow not found.', 'data-machine' ), + array( 'status' => $status ) + ); + } + + return rest_ensure_response( + array( + 'success' => true, + 'data' => $result['flows'][0], + ) + ); + } + + /** + * Handle problem flows retrieval request. + * + * Returns flows with consecutive failures at or above the threshold. + * + * GET /datamachine/v1/flows/problems + */ + public static function handle_get_problem_flows( $request ) { + $threshold = $request->get_param( 'threshold' ); + + $ability = wp_get_ability( 'datamachine/get-problem-flows' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Ability not found', array( 'status' => 500 ) ); + } + + $input = array(); + if ( null !== $threshold && $threshold > 0 ) { + $input['threshold'] = (int) $threshold; + } + + $result = $ability->execute( $input ); + + if ( ! $result['success'] ) { + return new \WP_Error( + 'get_problem_flows_error', + $result['error'] ?? __( 'Failed to get problem flows', 'data-machine' ), + array( 'status' => 500 ) + ); + } + + $problem_flows = array_merge( $result['failing'] ?? array(), $result['idle'] ?? array() ); + + return rest_ensure_response( + array( + 'success' => true, + 'data' => array( + 'problem_flows' => $problem_flows, + 'total' => $result['count'] ?? count( $problem_flows ), + 'threshold' => $result['threshold'] ?? 3, + 'failing' => $result['failing'] ?? array(), + 'idle' => $result['idle'] ?? array(), + ), + ) + ); + } + + /** + * Handle get memory files request for a flow. + * + * GET /datamachine/v1/flows/{flow_id}/memory-files + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response|\WP_Error Response. + */ + public static function handle_get_memory_files( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + if ( ! $flow ) { + return new \WP_Error( + 'flow_not_found', + __( 'Flow not found.', 'data-machine' ), + array( 'status' => 404 ) + ); + } + + $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; + if ( ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to access this flow.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + $memory_files = $db_flows->get_flow_memory_files( $flow_id ); + $daily_memory = $db_flows->get_flow_daily_memory( $flow_id ); + + return rest_ensure_response( + array( + 'success' => true, + 'data' => array( + 'memory_files' => $memory_files, + 'daily_memory' => $daily_memory, + ), + ) + ); + } diff --git a/inc/Api/Flows/Flows/register.php b/inc/Api/Flows/Flows/register.php new file mode 100644 index 000000000..3caf6553e --- /dev/null +++ b/inc/Api/Flows/Flows/register.php @@ -0,0 +1,315 @@ +//! register — extracted from Flows.php. + + + /** + * Register REST API routes + */ + public static function register() { + add_action( 'rest_api_init', array( self::class, 'register_routes' ) ); + } + + /** + * Register flow CRUD endpoints + */ + public static function register_routes() { + register_rest_route( + 'datamachine/v1', + '/flows', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_create_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'pipeline_id' => array( + 'required' => true, + 'type' => 'integer', + 'description' => __( 'Parent pipeline ID', 'data-machine' ), + 'validate_callback' => function ( $param ) { + return is_numeric( $param ) && $param > 0; + }, + 'sanitize_callback' => function ( $param ) { + return (int) $param; + }, + ), + 'flow_name' => array( + 'required' => false, + 'type' => 'string', + 'default' => 'Flow', + 'description' => __( 'Flow name', 'data-machine' ), + 'sanitize_callback' => function ( $param ) { + return sanitize_text_field( $param ); + }, + ), + 'flow_config' => array( + 'required' => false, + 'type' => 'array', + 'description' => __( 'Flow configuration (handler settings per step)', 'data-machine' ), + ), + 'scheduling_config' => array( + 'required' => false, + 'type' => 'array', + 'description' => __( 'Scheduling configuration', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'handle_get_flows' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'pipeline_id' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Optional pipeline ID to filter flows', 'data-machine' ), + ), + 'per_page' => array( + 'required' => false, + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'description' => __( 'Number of flows per page', 'data-machine' ), + ), + 'offset' => array( + 'required' => false, + 'type' => 'integer', + 'default' => 0, + 'minimum' => 0, + 'sanitize_callback' => 'absint', + 'description' => __( 'Offset for pagination', 'data-machine' ), + ), + 'user_id' => array( + 'required' => false, + 'type' => 'integer', + 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/(?P\d+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'handle_get_single_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID to retrieve', 'data-machine' ), + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( self::class, 'handle_delete_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID to delete', 'data-machine' ), + ), + ), + ), + array( + 'methods' => 'PATCH', + 'callback' => array( self::class, 'handle_update_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID to update', 'data-machine' ), + ), + 'flow_name' => array( + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => __( 'New flow title', 'data-machine' ), + ), + 'scheduling_config' => array( + 'required' => false, + 'type' => 'object', + 'description' => __( 'Scheduling configuration', 'data-machine' ), + ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/(?P\d+)/pause', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_pause_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID to pause', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/(?P\d+)/resume', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_resume_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID to resume', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/pause', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_bulk_pause' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'pipeline_id' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Pause all flows in this pipeline', 'data-machine' ), + ), + 'agent_id' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Pause all flows for this agent', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/resume', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_bulk_resume' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'pipeline_id' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Resume all flows in this pipeline', 'data-machine' ), + ), + 'agent_id' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Resume all flows for this agent', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/(?P\d+)/duplicate', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_duplicate_flow' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Source flow ID to duplicate', 'data-machine' ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/(?P\d+)/memory-files', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'handle_get_memory_files' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( self::class, 'handle_update_memory_files' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'flow_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Flow ID', 'data-machine' ), + ), + 'memory_files' => array( + 'required' => true, + 'type' => 'array', + 'description' => __( 'Array of agent memory filenames', 'data-machine' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + ) + ); + + register_rest_route( + 'datamachine/v1', + '/flows/problems', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'handle_get_problem_flows' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'threshold' => array( + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => __( 'Minimum consecutive failures (defaults to problem_flow_threshold setting)', 'data-machine' ), + ), + ), + ) + ); + } diff --git a/inc/Api/Flows/Flows/sanitize_daily_memory.php b/inc/Api/Flows/Flows/sanitize_daily_memory.php new file mode 100644 index 000000000..ae712f475 --- /dev/null +++ b/inc/Api/Flows/Flows/sanitize_daily_memory.php @@ -0,0 +1,133 @@ +//! sanitize_daily_memory — extracted from Flows.php. + + + /** + * Handle update memory files request for a flow. + * + * PUT/POST /datamachine/v1/flows/{flow_id}/memory-files + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response|\WP_Error Response. + */ + public static function handle_update_memory_files( $request ) { + $flow_id = (int) $request->get_param( 'flow_id' ); + $params = $request->get_json_params(); + $memory_files = $params['memory_files'] ?? array(); + $daily_memory = $params['daily_memory'] ?? null; + + $db_flows = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db_flows->get_flow( $flow_id ); + + if ( ! $flow ) { + return new \WP_Error( + 'flow_not_found', + __( 'Flow not found.', 'data-machine' ), + array( 'status' => 404 ) + ); + } + + $resource_agent_id = isset( $flow['agent_id'] ) ? (int) $flow['agent_id'] : null; + if ( ! PermissionHelper::owns_agent_resource( $resource_agent_id, (int) ( $flow['user_id'] ?? 0 ) ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to update this flow.', 'data-machine' ), + array( 'status' => 403 ) + ); + } + + // Sanitize filenames. + $memory_files = array_map( 'sanitize_file_name', $memory_files ); + $memory_files = array_values( array_filter( $memory_files ) ); + + // Sanitize daily_memory config if provided. + if ( null !== $daily_memory ) { + $daily_memory = self::sanitize_daily_memory( $daily_memory ); + } + + $result = $db_flows->update_flow_memory_files( $flow_id, $memory_files, $daily_memory ); + + if ( ! $result ) { + return new \WP_Error( + 'update_failed', + __( 'Failed to update memory files.', 'data-machine' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( + array( + 'success' => true, + 'data' => array( + 'memory_files' => $memory_files, + 'daily_memory' => $daily_memory ?? $db_flows->get_flow_daily_memory( $flow_id ), + ), + 'message' => __( 'Flow memory files updated successfully.', 'data-machine' ), + ) + ); + } + + /** + * Sanitize daily memory configuration. + * + * @since 0.40.0 + * + * @param array $config Raw daily memory config. + * @return array Sanitized config. + */ + private static function sanitize_daily_memory( array $config ): array { + $allowed_modes = array( 'none', 'recent_days', 'specific_dates', 'date_range', 'months' ); + $mode = $config['mode'] ?? 'none'; + + if ( ! in_array( $mode, $allowed_modes, true ) ) { + $mode = 'none'; + } + + $sanitized = array( 'mode' => $mode ); + + switch ( $mode ) { + case 'recent_days': + $sanitized['days'] = min( max( (int) ( $config['days'] ?? 7 ), 1 ), 90 ); + break; + + case 'specific_dates': + $dates = $config['dates'] ?? array(); + $sanitized['dates'] = array_values( + array_filter( + array_map( + function ( $date ) { + return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : null; + }, + (array) $dates + ) + ) + ); + break; + + case 'date_range': + $from = $config['from'] ?? null; + $to = $config['to'] ?? null; + if ( $from && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $from ) ) { + $sanitized['from'] = $from; + } + if ( $to && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $to ) ) { + $sanitized['to'] = $to; + } + break; + + case 'months': + $months = $config['months'] ?? array(); + $sanitized['months'] = array_values( + array_filter( + array_map( + function ( $month ) { + return preg_match( '/^\d{4}\/\d{2}$/', $month ) ? $month : null; + }, + (array) $months + ) + ) + ); + break; + } + + return $sanitized; + } diff --git a/inc/Api/Handlers.php b/inc/Api/Handlers.php index 35d9d606b..e595f18b9 100644 --- a/inc/Api/Handlers.php +++ b/inc/Api/Handlers.php @@ -16,6 +16,7 @@ use DataMachine\Abilities\StepTypeAbilities; use WP_REST_Server; use WP_REST_Request; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/InternalLinks.php b/inc/Api/InternalLinks.php index f6f96ce08..08f2e57b0 100644 --- a/inc/Api/InternalLinks.php +++ b/inc/Api/InternalLinks.php @@ -19,6 +19,7 @@ use DataMachine\Abilities\PermissionHelper; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/Jobs.php b/inc/Api/Jobs.php index ac531eb41..e104d6441 100644 --- a/inc/Api/Jobs.php +++ b/inc/Api/Jobs.php @@ -18,6 +18,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Abilities\JobAbilities; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; @@ -55,20 +56,20 @@ public static function register_routes() { 'callback' => array( self::class, 'handle_get_jobs' ), 'permission_callback' => array( self::class, 'check_permission' ), 'args' => array( - 'orderby' => array( + 'orderby' => array( 'required' => false, 'type' => 'string', 'default' => 'job_id', 'description' => __( 'Order jobs by field', 'data-machine' ), ), - 'order' => array( + 'order' => array( 'required' => false, 'type' => 'string', 'default' => 'DESC', 'enum' => array( 'ASC', 'DESC' ), 'description' => __( 'Sort order', 'data-machine' ), ), - 'per_page' => array( + 'per_page' => array( 'required' => false, 'type' => 'integer', 'default' => 50, @@ -76,46 +77,46 @@ public static function register_routes() { 'maximum' => 100, 'description' => __( 'Number of jobs per page', 'data-machine' ), ), - 'offset' => array( + 'offset' => array( 'required' => false, 'type' => 'integer', 'default' => 0, 'minimum' => 0, 'description' => __( 'Offset for pagination', 'data-machine' ), ), - 'pipeline_id' => array( + 'pipeline_id' => array( 'required' => false, 'type' => 'integer', 'description' => __( 'Filter by pipeline ID', 'data-machine' ), ), - 'flow_id' => array( + 'flow_id' => array( 'required' => false, 'type' => 'integer', 'description' => __( 'Filter by flow ID', 'data-machine' ), ), - 'status' => array( + 'status' => array( 'required' => false, 'type' => 'string', 'description' => __( 'Filter by job status', 'data-machine' ), ), - 'user_id' => array( - 'required' => false, - 'type' => 'integer', - 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), - 'sanitize_callback' => 'absint', - ), - 'parent_job_id' => array( - 'required' => false, - 'type' => 'integer', - 'description' => __( 'Filter by parent job ID (for batch child jobs)', 'data-machine' ), - ), - 'hide_children' => array( - 'required' => false, - 'type' => 'boolean', - 'default' => false, - 'description' => __( 'Hide child jobs from top-level list', 'data-machine' ), + 'user_id' => array( + 'required' => false, + 'type' => 'integer', + 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), + 'sanitize_callback' => 'absint', + ), + 'parent_job_id' => array( + 'required' => false, + 'type' => 'integer', + 'description' => __( 'Filter by parent job ID (for batch child jobs)', 'data-machine' ), + ), + 'hide_children' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Hide child jobs from top-level list', 'data-machine' ), + ), ), - ), ) ); diff --git a/inc/Api/Logs.php b/inc/Api/Logs.php index e26ba5aa8..cd4b0be5a 100644 --- a/inc/Api/Logs.php +++ b/inc/Api/Logs.php @@ -17,6 +17,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Abilities\LogAbilities; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Pipelines/PipelineFlows.php b/inc/Api/Pipelines/PipelineFlows.php index c9cb64f24..6a30c5451 100644 --- a/inc/Api/Pipelines/PipelineFlows.php +++ b/inc/Api/Pipelines/PipelineFlows.php @@ -11,6 +11,7 @@ namespace DataMachine\Api\Pipelines; use DataMachine\Abilities\PermissionHelper; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Pipelines/PipelineSteps.php b/inc/Api/Pipelines/PipelineSteps.php index 7cd314f1e..96e7d93a5 100644 --- a/inc/Api/Pipelines/PipelineSteps.php +++ b/inc/Api/Pipelines/PipelineSteps.php @@ -15,6 +15,7 @@ use DataMachine\Abilities\PipelineStepAbilities; use DataMachine\Abilities\StepTypeAbilities; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Pipelines/Pipelines.php b/inc/Api/Pipelines/Pipelines.php index e6dbf4ead..f1b21a87a 100644 --- a/inc/Api/Pipelines/Pipelines.php +++ b/inc/Api/Pipelines/Pipelines.php @@ -15,6 +15,7 @@ use DataMachine\Abilities\PipelineAbilities; use DataMachine\Core\Admin\DateFormatter; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/ProcessedItems.php b/inc/Api/ProcessedItems.php index e8a4ef406..74c372faa 100644 --- a/inc/Api/ProcessedItems.php +++ b/inc/Api/ProcessedItems.php @@ -16,6 +16,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Abilities\ProcessedItemsAbilities; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/Providers.php b/inc/Api/Providers.php index 62c4d6c63..d8ae1a843 100644 --- a/inc/Api/Providers.php +++ b/inc/Api/Providers.php @@ -13,6 +13,7 @@ use DataMachine\Core\PluginSettings; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/Settings.php b/inc/Api/Settings.php index c42816e96..ca98c7a15 100644 --- a/inc/Api/Settings.php +++ b/inc/Api/Settings.php @@ -15,6 +15,7 @@ use DataMachine\Abilities\SettingsAbilities; use WP_REST_Response; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/StepTypes.php b/inc/Api/StepTypes.php index 73009d9a0..c4aadfaab 100644 --- a/inc/Api/StepTypes.php +++ b/inc/Api/StepTypes.php @@ -14,6 +14,7 @@ use DataMachine\Abilities\HandlerAbilities; use DataMachine\Abilities\StepTypeAbilities; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/Tools.php b/inc/Api/Tools.php index 346300271..ae93c5f54 100644 --- a/inc/Api/Tools.php +++ b/inc/Api/Tools.php @@ -12,6 +12,7 @@ namespace DataMachine\Api; use WP_REST_Server; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Api/Users.php b/inc/Api/Users.php index 7fdf228b8..c86c8457f 100644 --- a/inc/Api/Users.php +++ b/inc/Api/Users.php @@ -13,6 +13,7 @@ use WP_REST_Request; use WP_REST_Server; use WP_Error; +use DataMachine\Api\Email; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Api/WebhookTrigger.php b/inc/Api/WebhookTrigger.php index 73f3c7a3a..7c37c7f89 100644 --- a/inc/Api/WebhookTrigger.php +++ b/inc/Api/WebhookTrigger.php @@ -18,6 +18,7 @@ use DataMachine\Abilities\PermissionHelper; use DataMachine\Core\Database\Flows\Flows; +use DataMachine\Api\Traits\HasRegister; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Cli/Commands/AgentsCommand.php b/inc/Cli/Commands/AgentsCommand.php index 7aeb8021c..9ceca7712 100644 --- a/inc/Cli/Commands/AgentsCommand.php +++ b/inc/Cli/Commands/AgentsCommand.php @@ -691,12 +691,12 @@ private function tokenList( $abilities, int $agent_id, array $assoc_args ): void } $items[] = array( - 'token_id' => $token['token_id'], - 'prefix' => $token['token_prefix'] . '...', - 'label' => $token['label'] ?: '(none)', - 'last_used' => $token['last_used_at'] ?? 'never', - 'expires' => $token['expires_at'] ?? 'never', - 'status' => $expired ? 'expired' : 'active', + 'token_id' => $token['token_id'], + 'prefix' => $token['token_prefix'] . '...', + 'label' => $token['label'] ? $token['label'] : '(none)', + 'last_used' => $token['last_used_at'] ?? 'never', + 'expires' => $token['expires_at'] ?? 'never', + 'status' => $expired ? 'expired' : 'active', ); } @@ -863,7 +863,7 @@ public function config( array $args, array $assoc_args ): void { $value = ( null !== $decoded || 'null' === $raw_value ) ? $decoded : $raw_value; $config[ $key ] = $value; - $display = is_array( $value ) ? wp_json_encode( $value, JSON_UNESCAPED_SLASHES ) : (string) $value; + $display = is_array( $value ) ? wp_json_encode( $value, JSON_UNESCAPED_SLASHES ) : (string) $value; WP_CLI::log( sprintf( ' %s → %s', $key, $display ) ); } } diff --git a/inc/Cli/Commands/EmailCommand.php b/inc/Cli/Commands/EmailCommand.php index 03209ab99..ccb2ecfaf 100644 --- a/inc/Cli/Commands/EmailCommand.php +++ b/inc/Cli/Commands/EmailCommand.php @@ -205,18 +205,18 @@ public function fetch( array $args, array $assoc_args ): void { foreach ( $items as $item ) { $meta = $item['metadata'] ?? array(); $rows[] = array( - 'uid' => $meta['uid'] ?? '', - 'from' => $meta['from'] ?? '', - 'from_name' => $meta['from_name'] ?? '', - 'to' => $meta['to'] ?? '', - 'subject' => $item['title'] ?? '', - 'date' => $meta['date'] ?? '', - 'seen' => ( $meta['seen'] ?? false ) ? 'Y' : 'N', - 'flagged' => ( $meta['flagged'] ?? false ) ? '*' : '', - 'size' => $meta['size'] ?? '', - 'attachments' => $meta['attachment_count'] ?? '', - 'message_id' => $meta['message_id'] ?? '', - 'in_reply_to' => $meta['in_reply_to'] ?? '', + 'uid' => $meta['uid'] ?? '', + 'from' => $meta['from'] ?? '', + 'from_name' => $meta['from_name'] ?? '', + 'to' => $meta['to'] ?? '', + 'subject' => $item['title'] ?? '', + 'date' => $meta['date'] ?? '', + 'seen' => ( $meta['seen'] ?? false ) ? 'Y' : 'N', + 'flagged' => ( $meta['flagged'] ?? false ) ? '*' : '', + 'size' => $meta['size'] ?? '', + 'attachments' => $meta['attachment_count'] ?? '', + 'message_id' => $meta['message_id'] ?? '', + 'in_reply_to' => $meta['in_reply_to'] ?? '', ); } diff --git a/inc/Cli/Commands/Flows/FlowsCommand.php b/inc/Cli/Commands/Flows/FlowsCommand.php index 7739a8808..ffaa59422 100644 --- a/inc/Cli/Commands/Flows/FlowsCommand.php +++ b/inc/Cli/Commands/Flows/FlowsCommand.php @@ -31,1531 +31,4 @@ class FlowsCommand extends BaseCommand { * @var array */ private array $default_fields = array( 'id', 'name', 'pipeline_id', 'handlers', 'config', 'schedule', 'max_items', 'status', 'next_run' ); - - /** - * Get flows with optional filtering. - * - * ## OPTIONS - * - * [...] - * : Subcommand and arguments. Accepts: list [pipeline_id], get , run , create, delete , update . - * - * [--handler=] - * : Filter flows using this handler slug (any step that uses this handler). - * - * [--per_page=] - * : Number of flows to return. 0 = all (default). - * --- - * default: 0 - * --- - * - * [--offset=] - * : Offset for pagination. - * --- - * default: 0 - * --- - * - * [--id=] - * : Get a specific flow by ID. - * - * [--format=] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * - ids - * - count - * --- - * - * [--fields=] - * : Limit output to specific fields (comma-separated). - * - * [--count=] - * : Number of times to run the flow (1-10, immediate execution only). - * --- - * default: 1 - * --- - * - * [--timestamp=] - * : Unix timestamp for delayed execution (future time required). - * - * [--pipeline_id=] - * : Pipeline ID for flow creation (create subcommand). - * - * [--name=] - * : Flow name (create subcommand). - * - * [--step_configs=] - * : JSON object with step configurations keyed by step_type (create subcommand). - * - * [--scheduling=] - * : Scheduling interval (manual, hourly, daily, one_time, etc.) or cron expression (e.g. "0 9 * * 1-5"). - * - * [--scheduled-at=] - * : ISO-8601 datetime for one-time scheduling (e.g. "2026-03-20T15:00:00Z"). Implies --scheduling=one_time. - * - * [--set-prompt=] - * : Update the prompt for a handler step (requires handler step to exist). - * - * [--handler-config=] - * : JSON object of handler config key-value pairs to update (merged with existing config). - * Requires --step to identify the target flow step. - * - * [--step=] - * : Target a specific flow step for prompt update or handler config update (auto-resolved if flow has exactly one handler step). - * - * [--add=] - * : Attach a memory file to a flow (memory-files subcommand). - * - * [--remove=] - * : Detach a memory file from a flow (memory-files subcommand). - * - * [--post_type=] - * : Post type to check against (validate subcommand). Default: 'post'. - * - * [--threshold=] - * : Jaccard similarity threshold 0.0-1.0 (validate subcommand). Default: 0.65. - * - * [--dry-run] - * : Validate without creating (create subcommand). - * - * [--pipeline=] - * : Pipeline ID for pause/resume scoping. - * - * [--agent=] - * : Agent slug or ID for scoping (pause/resume/list). - * - * [--yes] - * : Skip confirmation prompt (delete subcommand). - * - * ## EXAMPLES - * - * # List all flows - * wp datamachine flows - * - * # List flows for pipeline 5 - * wp datamachine flows 5 - * - * # List flows using rss handler - * wp datamachine flows --handler=rss - * - * # Get a specific flow by ID - * wp datamachine flows get 42 - * - * # Run a flow immediately - * wp datamachine flows run 42 - * - * # Create a new flow - * wp datamachine flows create --pipeline_id=3 --name="My Flow" - * - * # Delete a flow - * wp datamachine flows delete 141 - * - * # Update flow name - * wp datamachine flows update 141 --name="New Name" - * - * # Update flow prompt - * wp datamachine flows update 42 --set-prompt="New prompt text" - * - * # Add a handler to a flow step - * wp datamachine flows add-handler 42 --handler=rss - * - * # Remove a handler from a flow step - * wp datamachine flows remove-handler 42 --handler=rss - * - * # List handlers on a flow - * wp datamachine flows list-handlers 42 - * - * # List memory files for a flow - * wp datamachine flows memory-files 42 - * - * # Attach a memory file to a flow - * wp datamachine flows memory-files 42 --add=content-briefing.md - * - * # Detach a memory file from a flow - * wp datamachine flows memory-files 42 --remove=content-briefing.md - * - * # Pause a single flow - * wp datamachine flows pause 42 - * - * # Pause all flows in a pipeline - * wp datamachine flows pause --pipeline=12 - * - * # Pause all flows for an agent - * wp datamachine flows pause --agent=my-agent - * - * # Resume a single flow - * wp datamachine flows resume 42 - * - * # Resume all flows for an agent - * wp datamachine flows resume --agent=my-agent - */ - public function __invoke( array $args, array $assoc_args ): void { - $flow_id = null; - $pipeline_id = null; - - // Handle 'create' subcommand: `flows create --pipeline_id=3 --name="Test"`. - if ( ! empty( $args ) && 'create' === $args[0] ) { - $this->createFlow( $assoc_args ); - return; - } - - // Delegate 'queue' subcommand to QueueCommand. - if ( ! empty( $args ) && 'queue' === $args[0] ) { - $queue = new QueueCommand(); - $queue->dispatch( array_slice( $args, 1 ), $assoc_args ); - return; - } - - // Delegate 'webhook' subcommand to WebhookCommand. - if ( ! empty( $args ) && 'webhook' === $args[0] ) { - $webhook = new WebhookCommand(); - $webhook->dispatch( array_slice( $args, 1 ), $assoc_args ); - return; - } - - // Delegate 'bulk-config' subcommand to BulkConfigCommand. - if ( ! empty( $args ) && 'bulk-config' === $args[0] ) { - $bulk_config = new BulkConfigCommand(); - $bulk_config->dispatch( array_slice( $args, 1 ), $assoc_args ); - return; - } - - // Handle 'memory-files' subcommand. - if ( ! empty( $args ) && 'memory-files' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows memory-files [--add=] [--remove=]' ); - return; - } - $this->memoryFiles( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'pause' subcommand: `flows pause 42` or `flows pause --pipeline=12`. - if ( ! empty( $args ) && 'pause' === $args[0] ) { - $this->pauseFlows( array_slice( $args, 1 ), $assoc_args ); - return; - } - - // Handle 'resume' subcommand: `flows resume 42` or `flows resume --pipeline=12`. - if ( ! empty( $args ) && 'resume' === $args[0] ) { - $this->resumeFlows( array_slice( $args, 1 ), $assoc_args ); - return; - } - - // Handle 'delete' subcommand: `flows delete 42`. - if ( ! empty( $args ) && 'delete' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows delete [--yes]' ); - return; - } - $this->deleteFlow( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'update' subcommand: `flows update 42 --name="New Name"`. - if ( ! empty( $args ) && 'update' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows update [--name=] [--scheduling=] [--set-prompt=] [--handler-config=] [--step=]' ); - return; - } - $this->updateFlow( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'add-handler' subcommand. - if ( ! empty( $args ) && 'add-handler' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows add-handler --handler= [--step=] [--config=]' ); - return; - } - $this->addHandler( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'remove-handler' subcommand. - if ( ! empty( $args ) && 'remove-handler' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows remove-handler --handler= [--step=]' ); - return; - } - $this->removeHandler( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'list-handlers' subcommand. - if ( ! empty( $args ) && 'list-handlers' === $args[0] ) { - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows list-handlers [--step=]' ); - return; - } - $this->listHandlers( (int) $args[1], $assoc_args ); - return; - } - - // Handle 'get'/'show' subcommand: `flows get 42` or `flows show 42`. - if ( ! empty( $args ) && ( 'get' === $args[0] || 'show' === $args[0] ) ) { - if ( isset( $args[1] ) ) { - $flow_id = (int) $args[1]; - } - } elseif ( ! empty( $args ) && 'run' === $args[0] ) { - // Handle 'run' subcommand: `flows run 42`. - if ( ! isset( $args[1] ) ) { - WP_CLI::error( 'Usage: wp datamachine flows run [--count=N] [--timestamp=T]' ); - return; - } - $this->runFlow( (int) $args[1], $assoc_args ); - return; - } elseif ( ! empty( $args ) && 'list' !== $args[0] ) { - $pipeline_id = (int) $args[0]; - } - - // Handle --id flag (takes precedence if both provided). - if ( isset( $assoc_args['id'] ) ) { - $flow_id = (int) $assoc_args['id']; - } - - $handler_slug = $assoc_args['handler'] ?? null; - $per_page = (int) ( $assoc_args['per_page'] ?? 0 ); - $offset = (int) ( $assoc_args['offset'] ?? 0 ); - $format = $assoc_args['format'] ?? 'table'; - - if ( $per_page < 0 ) { - $per_page = 0; - } - if ( $offset < 0 ) { - $offset = 0; - } - - $scoping = AgentResolver::buildScopingInput( $assoc_args ); - $ability = new \DataMachine\Abilities\FlowAbilities(); - - // Use 'list' mode for multi-flow views (skips expensive handler enrichment). - // Use 'full' mode for single-flow detail views. - $output_mode = $flow_id ? 'full' : 'list'; - - $result = $ability->executeAbility( - array_merge( - $scoping, - array( - 'flow_id' => $flow_id, - 'pipeline_id' => $pipeline_id, - 'handler_slug' => $handler_slug, - 'per_page' => $per_page, - 'offset' => $offset, - 'output_mode' => $output_mode, - ) - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to get flows' ); - return; - } - - $flows = $result['flows'] ?? array(); - $total = $result['total'] ?? 0; - - if ( empty( $flows ) ) { - WP_CLI::warning( 'No flows found matching your criteria.' ); - return; - } - - // Single flow detail view: show full data including step configs. - if ( $flow_id && 1 === count( $flows ) ) { - $this->showFlowDetail( $flows[0], $format ); - return; - } - - // Transform flows to flat row format. - $items = array_map( - function ( $flow ) { - return array( - 'id' => $flow['flow_id'], - 'name' => $flow['flow_name'], - 'pipeline_id' => $flow['pipeline_id'], - 'handlers' => $this->extractHandlers( $flow ), - 'config' => $this->extractConfigSummary( $flow ), - 'schedule' => $this->extractSchedule( $flow ), - 'max_items' => $this->extractMaxItems( $flow ), - 'prompt' => $this->extractPrompt( $flow ), - 'status' => $flow['last_run_status'] ?? 'Never', - 'next_run' => $flow['next_run_display'] ?? 'Not scheduled', - ); - }, - $flows - ); - - $this->format_items( $items, $this->default_fields, $assoc_args, 'id' ); - $this->output_pagination( $offset, count( $flows ), $total, $format, 'flows' ); - $this->outputFilters( $result['filters_applied'] ?? array(), $format ); - } - - /** - * Show detailed view of a single flow including step configs. - * - * For JSON format: outputs the full flow data with flow_config intact. - * For table format: outputs key-value pairs followed by a step configs table. - * - * @param array $flow Full flow data from FlowAbilities. - * @param string $format Output format (table, json, csv, yaml). - */ - private function showFlowDetail( array $flow, string $format ): void { - // JSON/YAML: output the full flow data including flow_config. - if ( 'json' === $format ) { - WP_CLI::line( wp_json_encode( $flow, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); - return; - } - - if ( 'yaml' === $format ) { - WP_CLI\Utils\format_items( 'yaml', array( $flow ), array_keys( $flow ) ); - return; - } - - // Table format: show flow summary, then step configs. - $scheduling = $flow['scheduling_config'] ?? array(); - $interval = $scheduling['interval'] ?? 'manual'; - - $is_paused = isset( $scheduling['enabled'] ) && false === $scheduling['enabled']; - - WP_CLI::log( sprintf( 'Flow ID: %d', $flow['flow_id'] ) ); - WP_CLI::log( sprintf( 'Name: %s', $flow['flow_name'] ) ); - WP_CLI::log( sprintf( 'Pipeline ID: %s', $flow['pipeline_id'] ?? 'N/A' ) ); - if ( 'cron' === $interval && ! empty( $scheduling['cron_expression'] ) ) { - $cron_desc = \DataMachine\Api\Flows\FlowScheduling::describe_cron_expression( $scheduling['cron_expression'] ); - WP_CLI::log( sprintf( 'Scheduling: cron (%s) — %s', $scheduling['cron_expression'], $cron_desc ) ); - } else { - WP_CLI::log( sprintf( 'Scheduling: %s', $interval ) ); - } - if ( $is_paused ) { - WP_CLI::log( 'Status: PAUSED' ); - } - WP_CLI::log( sprintf( 'Last run: %s', $flow['last_run_display'] ?? 'Never' ) ); - WP_CLI::log( sprintf( 'Next run: %s', $flow['next_run_display'] ?? 'Not scheduled' ) ); - WP_CLI::log( sprintf( 'Running: %s', ( $flow['is_running'] ?? false ) ? 'Yes' : 'No' ) ); - WP_CLI::log( '' ); - - // Step configs section. - $config = $flow['flow_config'] ?? array(); - - if ( empty( $config ) ) { - WP_CLI::log( 'Steps: (none)' ); - return; - } - - // Show memory files if attached. - $memory_files = $config['memory_files'] ?? array(); - if ( ! empty( $memory_files ) ) { - WP_CLI::log( sprintf( 'Memory files: %s', implode( ', ', $memory_files ) ) ); - WP_CLI::log( '' ); - } - - $rows = array(); - foreach ( $config as $step_id => $step_data ) { - // Skip flow-level metadata keys — only display step configs. - if ( ! is_array( $step_data ) || ! isset( $step_data['step_type'] ) ) { - continue; - } - - $step_type = $step_data['step_type'] ?? ''; - $order = $step_data['execution_order'] ?? ''; - $slugs = $step_data['handler_slugs'] ?? array(); - $configs = $step_data['handler_configs'] ?? array(); - - // Show pipeline-level prompt if set. - $pipeline_prompt = $step_data['pipeline_config']['prompt'] ?? ''; - - if ( empty( $slugs ) ) { - // Step with no handlers (e.g. AI step with only pipeline config). - $config_display = ''; - - if ( $pipeline_prompt ) { - $config_display = 'prompt=' . $this->truncateValue( $pipeline_prompt, 60 ); - } - - $rows[] = array( - 'step_id' => $step_id, - 'order' => $order, - 'step_type' => $step_type, - 'handler' => '—', - 'config' => $config_display ? $config_display : '(default)', - ); - continue; - } - - foreach ( $slugs as $slug ) { - $handler_config = $configs[ $slug ] ?? array(); - $config_parts = array(); - - foreach ( $handler_config as $key => $value ) { - $config_parts[] = $key . '=' . $this->formatConfigValue( $value ); - } - - $rows[] = array( - 'step_id' => $step_id, - 'order' => $order, - 'step_type' => $step_type, - 'handler' => $slug, - 'config' => implode( ', ', $config_parts ) ? implode( ', ', $config_parts ) : '(default)', - ); - } - } - - WP_CLI::log( 'Steps:' ); - - $step_fields = array( 'step_id', 'order', 'step_type', 'handler', 'config' ); - WP_CLI\Utils\format_items( 'table', $rows, $step_fields ); - } - - /** - * Truncate a display value to a maximum length. - * - * @param string $value Value to truncate. - * @param int $max Maximum characters. - * @return string Truncated value. - */ - private function truncateValue( string $value, int $max = 40 ): string { - $value = str_replace( array( "\n", "\r" ), ' ', $value ); - if ( mb_strlen( $value ) > $max ) { - return mb_substr( $value, 0, $max - 3 ) . '...'; - } - return $value; - } - - /** - * Format a config value for display in the step configs table. - * - * @param mixed $value Config value. - * @return string Formatted value. - */ - private function formatConfigValue( $value ): string { - if ( is_bool( $value ) ) { - return $value ? 'true' : 'false'; - } - if ( is_array( $value ) ) { - return wp_json_encode( $value ); - } - $str = (string) $value; - return $this->truncateValue( $str ); - } - - /** - * Create a new flow. - * - * @param array $assoc_args Associative arguments (pipeline_id, name, step_configs, scheduling, dry-run). - */ - private function createFlow( array $assoc_args ): void { - $pipeline_id = isset( $assoc_args['pipeline_id'] ) ? (int) $assoc_args['pipeline_id'] : null; - $flow_name = $assoc_args['name'] ?? null; - $scheduling = $assoc_args['scheduling'] ?? 'manual'; - $scheduled_at = $assoc_args['scheduled-at'] ?? null; - $dry_run = isset( $assoc_args['dry-run'] ); - $format = $assoc_args['format'] ?? 'table'; - - if ( ! $pipeline_id ) { - WP_CLI::error( 'Required: --pipeline_id=' ); - return; - } - - if ( ! $flow_name ) { - WP_CLI::error( 'Required: --name=' ); - return; - } - - $step_configs = array(); - if ( isset( $assoc_args['step_configs'] ) ) { - $decoded = json_decode( wp_unslash( $assoc_args['step_configs'] ), true ); - if ( null === $decoded && '' !== $assoc_args['step_configs'] ) { - WP_CLI::error( 'Invalid JSON in --step_configs' ); - return; - } - if ( null !== $decoded && ! is_array( $decoded ) ) { - WP_CLI::error( '--step_configs must be a JSON object' ); - return; - } - $step_configs = $decoded ?? array(); - } - - // Convert --handler-config to step_configs entries. - // --handler-config accepts handler-keyed JSON, e.g. {"reddit":{"subreddit":"test"}}. - // Each handler slug is resolved to its step type and merged into step_configs. - if ( isset( $assoc_args['handler-config'] ) ) { - $handler_config_input = json_decode( wp_unslash( $assoc_args['handler-config'] ), true ); - if ( ! is_array( $handler_config_input ) ) { - WP_CLI::error( 'Invalid JSON in --handler-config. Must be a JSON object.' ); - return; - } - - $handler_abilities = new \DataMachine\Abilities\HandlerAbilities(); - $all_handlers = $handler_abilities->getAllHandlers(); - - foreach ( $handler_config_input as $handler_slug => $config ) { - if ( ! isset( $all_handlers[ $handler_slug ] ) ) { - WP_CLI::error( "Unknown handler '{$handler_slug}'. Use --handler-config with valid handler slugs." ); - return; - } - - $step_type = $all_handlers[ $handler_slug ]['type'] ?? ''; - if ( empty( $step_type ) ) { - WP_CLI::error( "Cannot determine step type for handler '{$handler_slug}'." ); - return; - } - - $step_configs[ $step_type ] = array( - 'handler_slug' => $handler_slug, - 'handler_config' => $config, - ); - } - } - - $scheduling_config = self::build_scheduling_config( $scheduling, $scheduled_at ); - - $input = array( - 'pipeline_id' => $pipeline_id, - 'flow_name' => $flow_name, - 'scheduling_config' => $scheduling_config, - 'step_configs' => $step_configs, - ); - - if ( $dry_run ) { - $input['validate_only'] = true; - $input['flows'] = array( - array( - 'pipeline_id' => $pipeline_id, - 'flow_name' => $flow_name, - 'scheduling_config' => $scheduling_config, - 'step_configs' => $step_configs, - ), - ); - } - - $ability = new \DataMachine\Abilities\FlowAbilities(); - $result = $ability->executeCreateFlow( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to create flow' ); - return; - } - - if ( $dry_run ) { - WP_CLI::success( 'Validation passed.' ); - if ( isset( $result['would_create'] ) && 'json' === $format ) { - WP_CLI::line( wp_json_encode( $result['would_create'], JSON_PRETTY_PRINT ) ); - } elseif ( isset( $result['would_create'] ) ) { - foreach ( $result['would_create'] as $preview ) { - WP_CLI::log( - sprintf( - 'Would create: "%s" on pipeline %d (scheduling: %s)', - $preview['flow_name'], - $preview['pipeline_id'], - $preview['scheduling'] - ) - ); - } - } - return; - } - - WP_CLI::success( sprintf( 'Flow created: ID %d', $result['flow_id'] ) ); - WP_CLI::log( sprintf( 'Name: %s', $result['flow_name'] ) ); - WP_CLI::log( sprintf( 'Pipeline ID: %d', $result['pipeline_id'] ) ); - WP_CLI::log( sprintf( 'Synced steps: %d', $result['synced_steps'] ?? 0 ) ); - - if ( ! empty( $result['configured_steps'] ) ) { - WP_CLI::log( sprintf( 'Configured steps: %s', implode( ', ', $result['configured_steps'] ) ) ); - } - - if ( ! empty( $result['configuration_errors'] ) ) { - WP_CLI::warning( 'Some step configurations failed:' ); - foreach ( $result['configuration_errors'] as $error ) { - WP_CLI::log( sprintf( ' - %s: %s', $error['step_type'] ?? 'unknown', $error['error'] ?? 'unknown error' ) ); - } - } - - if ( 'json' === $format && isset( $result['flow_data'] ) ) { - WP_CLI::line( wp_json_encode( $result['flow_data'], JSON_PRETTY_PRINT ) ); - } - } - - /** - * Run a flow immediately or with scheduling. - * - * @param int $flow_id Flow ID to execute. - * @param array $assoc_args Associative arguments (count, timestamp). - */ - private function runFlow( int $flow_id, array $assoc_args ): void { - $count = isset( $assoc_args['count'] ) ? (int) $assoc_args['count'] : 1; - $timestamp = isset( $assoc_args['timestamp'] ) ? (int) $assoc_args['timestamp'] : null; - - // Validate count range (1-10). - if ( $count < 1 || $count > 10 ) { - WP_CLI::error( 'Count must be between 1 and 10.' ); - return; - } - - $ability = new \DataMachine\Abilities\JobAbilities(); - $result = $ability->executeWorkflow( - array( - 'flow_id' => $flow_id, - 'count' => $count, - 'timestamp' => $timestamp, - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to run flow' ); - return; - } - - // Output success message. - WP_CLI::success( $result['message'] ?? 'Flow execution scheduled.' ); - - // Show job ID(s) for follow-up. - if ( isset( $result['job_id'] ) ) { - WP_CLI::log( sprintf( 'Job ID: %d', $result['job_id'] ) ); - } elseif ( isset( $result['job_ids'] ) ) { - WP_CLI::log( sprintf( 'Job IDs: %s', implode( ', ', $result['job_ids'] ) ) ); - } - } - - /** - * Delete a flow. - * - * @param int $flow_id Flow ID to delete. - * @param array $assoc_args Associative arguments (--yes). - */ - private function deleteFlow( int $flow_id, array $assoc_args ): void { - if ( $flow_id <= 0 ) { - WP_CLI::error( 'flow_id must be a positive integer' ); - return; - } - - $skip_confirm = isset( $assoc_args['yes'] ); - - if ( ! $skip_confirm ) { - WP_CLI::confirm( sprintf( 'Are you sure you want to delete flow %d?', $flow_id ) ); - } - - $ability = new \DataMachine\Abilities\FlowAbilities(); - $result = $ability->executeDeleteFlow( array( 'flow_id' => $flow_id ) ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to delete flow' ); - return; - } - - WP_CLI::success( sprintf( 'Flow %d deleted.', $flow_id ) ); - - if ( isset( $result['pipeline_id'] ) ) { - WP_CLI::log( sprintf( 'Pipeline ID: %d', $result['pipeline_id'] ) ); - } - } - - /** - * Update a flow's name or scheduling. - * - * @param int $flow_id Flow ID to update. - * @param array $assoc_args Associative arguments (--name, --scheduling). - */ - private function updateFlow( int $flow_id, array $assoc_args ): void { - if ( $flow_id <= 0 ) { - WP_CLI::error( 'flow_id must be a positive integer' ); - return; - } - - $name = $assoc_args['name'] ?? null; - $scheduling = $assoc_args['scheduling'] ?? null; - $scheduled_at = $assoc_args['scheduled-at'] ?? null; - $prompt = isset( $assoc_args['set-prompt'] ) - ? wp_kses_post( wp_unslash( $assoc_args['set-prompt'] ) ) - : null; - $handler_config = isset( $assoc_args['handler-config'] ) - ? json_decode( wp_unslash( $assoc_args['handler-config'] ), true ) - : null; - $step = $assoc_args['step'] ?? null; - - // --scheduled-at implies --scheduling=one_time. - if ( $scheduled_at && null === $scheduling ) { - $scheduling = 'one_time'; - } - - if ( null !== $handler_config && ! is_array( $handler_config ) ) { - WP_CLI::error( 'Invalid JSON in --handler-config. Must be a JSON object.' ); - return; - } - - if ( null === $name && null === $scheduling && null === $prompt && null === $handler_config ) { - WP_CLI::error( 'Must provide --name, --scheduling, --set-prompt, --scheduled-at, or --handler-config to update' ); - return; - } - - // Validate step resolution BEFORE any writes (atomic: fail fast, change nothing). - $needs_step = null !== $prompt || null !== $handler_config; - - if ( $needs_step && null === $step ) { - $resolved = $this->resolveHandlerStep( $flow_id ); - if ( $resolved['error'] ) { - WP_CLI::error( $resolved['error'] ); - return; - } - $step = $resolved['step_id']; - } - - // Phase 1: Flow-level updates (name, scheduling). - $input = array( 'flow_id' => $flow_id ); - - if ( null !== $name ) { - $input['flow_name'] = $name; - } - - if ( null !== $scheduling ) { - $input['scheduling_config'] = self::build_scheduling_config( $scheduling, $scheduled_at ); - } - - if ( null !== $name || null !== $scheduling ) { - $ability = new \DataMachine\Abilities\FlowAbilities(); - $result = $ability->executeUpdateFlow( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to update flow' ); - return; - } - - WP_CLI::success( sprintf( 'Flow %d updated.', $flow_id ) ); - WP_CLI::log( sprintf( 'Name: %s', $result['flow_name'] ?? '' ) ); - - $sched = $result['flow_data']['scheduling_config'] ?? array(); - if ( 'cron' === ( $sched['interval'] ?? '' ) && ! empty( $sched['cron_expression'] ) ) { - WP_CLI::log( sprintf( 'Scheduling: cron (%s)', $sched['cron_expression'] ) ); - } elseif ( isset( $sched['interval'] ) ) { - WP_CLI::log( sprintf( 'Scheduling: %s', $sched['interval'] ) ); - } - } - - // Phase 2: Step-level updates (prompt, handler config). - if ( null !== $prompt ) { - $step_ability = new \DataMachine\Abilities\FlowStep\UpdateFlowStepAbility(); - $step_result = $step_ability->execute( - array( - 'flow_step_id' => $step, - 'handler_config' => array( 'prompt' => $prompt ), - ) - ); - - if ( ! $step_result['success'] ) { - WP_CLI::error( $step_result['error'] ?? 'Failed to update prompt' ); - return; - } - - WP_CLI::success( 'Prompt updated for step: ' . $step ); - } - - if ( null !== $handler_config ) { - // --handler-config accepts handler-keyed JSON, e.g. {"reddit":{"subreddit":"test"}}. - // Unwrap: the key is the handler slug, the value is the config. - $handler_slug = null; - $unwrapped_config = $handler_config; - $handler_config_keys = array_keys( $handler_config ); - - // If the top-level keys look like handler slugs (single key wrapping a config object), - // unwrap the handler slug from the JSON structure. - if ( count( $handler_config_keys ) === 1 && is_array( $handler_config[ $handler_config_keys[0] ] ) ) { - $handler_slug = $handler_config_keys[0]; - $unwrapped_config = $handler_config[ $handler_slug ]; - } - - $step_input = array( - 'flow_step_id' => $step, - 'handler_config' => $unwrapped_config, - ); - - if ( $handler_slug ) { - $step_input['handler_slug'] = $handler_slug; - } - - $step_ability = new \DataMachine\Abilities\FlowStep\UpdateFlowStepAbility(); - $step_result = $step_ability->execute( $step_input ); - - if ( ! $step_result['success'] ) { - WP_CLI::error( $step_result['error'] ?? 'Failed to update handler config' ); - return; - } - - $updated_keys = implode( ', ', array_keys( $unwrapped_config ) ); - WP_CLI::success( sprintf( 'Handler config updated for step %s: %s', $step, $updated_keys ) ); - } - } - - /** - * Output filter info (table format only). - * - * @param array $filters_applied Applied filters. - * @param string $format Current output format. - */ - private function outputFilters( array $filters_applied, string $format ): void { - if ( 'table' !== $format ) { - return; - } - - if ( $filters_applied['flow_id'] ?? null ) { - WP_CLI::log( "Filtered by flow ID: {$filters_applied['flow_id']}" ); - } - if ( $filters_applied['pipeline_id'] ?? null ) { - WP_CLI::log( "Filtered by pipeline ID: {$filters_applied['pipeline_id']}" ); - } - if ( $filters_applied['handler_slug'] ?? null ) { - WP_CLI::log( "Filtered by handler slug: {$filters_applied['handler_slug']}" ); - } - } - - /** - * Extract handler slugs from flow config. - * - * @param array $flow Flow data. - * @return string Comma-separated handler slugs. - */ - private function extractHandlers( array $flow ): string { - $flow_config = $flow['flow_config'] ?? array(); - $handlers = array(); - - foreach ( $flow_config as $step_data ) { - // Data is normalized at the DB layer — handler_slugs is canonical. - $handlers = array_merge( $handlers, $step_data['handler_slugs'] ?? array() ); - } - - return implode( ', ', array_unique( $handlers ) ); - } - - /** - * Extract a concise config summary from flow handler configs. - * - * Domain-agnostic: reads raw config values without assuming any - * specific taxonomy, handler, or post type. Surfaces distinguishing - * values like coordinates, city names, URLs, and taxonomy selections. - * - * @param array $flow Flow data. - * @return string Config summary (max ~60 chars). - */ - private function extractConfigSummary( array $flow ): string { - $flow_config = $flow['flow_config'] ?? array(); - $parts = array(); - - foreach ( $flow_config as $step_data ) { - $handler_configs = $step_data['handler_configs'] ?? array(); - - foreach ( $handler_configs as $hconfig ) { - if ( ! is_array( $hconfig ) ) { - continue; - } - - // Coordinates (location field with lat,lon). - if ( ! empty( $hconfig['location'] ) && strpos( $hconfig['location'], ',' ) !== false ) { - $loc = $hconfig['location']; - $rad = $hconfig['radius'] ?? ''; - $parts[] = $loc . ( $rad ? " r={$rad}" : '' ); - } - - // City name. - if ( ! empty( $hconfig['city'] ) ) { - $parts[] = "city={$hconfig['city']}"; - } - - // Source URL — show domain only. - if ( ! empty( $hconfig['source_url'] ) ) { - $host = wp_parse_url( $hconfig['source_url'], PHP_URL_HOST ); - $parts[] = $host ?: $hconfig['source_url']; - } - - // Venue/source name. - if ( ! empty( $hconfig['venue_name'] ) ) { - $parts[] = $hconfig['venue_name']; - } - - // Feed URL — show domain only. - $feed_url = $hconfig['feed_url'] ?? $hconfig['url'] ?? ''; - if ( $feed_url && empty( $hconfig['source_url'] ) ) { - $host = wp_parse_url( $feed_url, PHP_URL_HOST ); - $parts[] = $host ?: $feed_url; - } - - // Taxonomy term selections (any taxonomy_*_selection key). - foreach ( $hconfig as $key => $val ) { - if ( strpos( $key, 'taxonomy_' ) === 0 && strpos( $key, '_selection' ) !== false ) { - if ( ! empty( $val ) && 'skip' !== $val && 'ai_decides' !== $val ) { - $tax_name = str_replace( array( 'taxonomy_', '_selection' ), '', $key ); - $parts[] = "{$tax_name}={$val}"; - } - } - } - } - } - - $summary = implode( ' | ', array_unique( $parts ) ); - - if ( mb_strlen( $summary ) > 60 ) { - $summary = mb_substr( $summary, 0, 57 ) . '...'; - } - - return $summary ?: '—'; - } - - /** - * Extract the first prompt from flow config for display. - * - * @param array $flow Flow data. - * @return string Prompt preview. - */ - private function extractPrompt( array $flow ): string { - $flow_config = $flow['flow_config'] ?? array(); - - foreach ( $flow_config as $step_data ) { - $primary_slug = $step_data['handler_slugs'][0] ?? ''; - $primary_config = ! empty( $primary_slug ) ? ( $step_data['handler_configs'][ $primary_slug ] ?? array() ) : array(); - if ( ! empty( $primary_config['prompt'] ) ) { - $prompt = $primary_config['prompt']; - return mb_strlen( $prompt ) > 50 - ? mb_substr( $prompt, 0, 47 ) . '...' - : $prompt; - } - if ( ! empty( $step_data['pipeline_config']['prompt'] ) ) { - $prompt = $step_data['pipeline_config']['prompt']; - return mb_strlen( $prompt ) > 50 - ? mb_substr( $prompt, 0, 47 ) . '...' - : $prompt; - } - } - - return ''; - } - - /** - * Extract scheduling summary from flow scheduling config. - * - * @param array $flow Flow data. - * @return string Scheduling summary for list view. - */ - private function extractSchedule( array $flow ): string { - $scheduling_config = $flow['scheduling_config'] ?? array(); - $interval = $scheduling_config['interval'] ?? 'manual'; - $is_paused = isset( $scheduling_config['enabled'] ) && false === $scheduling_config['enabled']; - - $label = $interval; - if ( 'cron' === $interval && ! empty( $scheduling_config['cron_expression'] ) ) { - $label = 'cron:' . $scheduling_config['cron_expression']; - } - - if ( $is_paused ) { - $label .= ' (paused)'; - } - - return $label; - } - - /** - * Extract max_items values from handler configs in a flow. - * - * @param array $flow Flow data. - * @return string Comma-separated handler=max_items pairs, or empty string. - */ - private function extractMaxItems( array $flow ): string { - $flow_config = $flow['flow_config'] ?? array(); - $pairs = array(); - - foreach ( $flow_config as $step_data ) { - if ( ! is_array( $step_data ) ) { - continue; - } - - $handler_configs = $step_data['handler_configs'] ?? array(); - if ( ! is_array( $handler_configs ) ) { - continue; - } - - foreach ( $handler_configs as $handler_slug => $handler_config ) { - if ( ! is_array( $handler_config ) || ! array_key_exists( 'max_items', $handler_config ) ) { - continue; - } - - $pairs[] = $handler_slug . '=' . (string) $handler_config['max_items']; - } - } - - $pairs = array_values( array_unique( $pairs ) ); - - return implode( ', ', $pairs ); - } - - /** - * Add a handler to a flow step. - * - * @param int $flow_id Flow ID. - * @param array $assoc_args Arguments (handler, step, config). - */ - private function addHandler( int $flow_id, array $assoc_args ): void { - $handler_slug = $assoc_args['handler'] ?? null; - $step_id = $assoc_args['step'] ?? null; - - if ( ! $handler_slug ) { - WP_CLI::error( 'Required: --handler=' ); - return; - } - - // Auto-resolve handler step if not specified. - if ( ! $step_id ) { - $resolved = $this->resolveHandlerStep( $flow_id ); - if ( ! empty( $resolved['error'] ) ) { - WP_CLI::error( $resolved['error'] ); - return; - } - $step_id = $resolved['step_id']; - } - - $input = array( - 'flow_step_id' => $step_id, - 'add_handler' => $handler_slug, - ); - - // Parse --config if provided. - if ( isset( $assoc_args['config'] ) ) { - $handler_config = json_decode( wp_unslash( $assoc_args['config'] ), true ); - if ( json_last_error() !== JSON_ERROR_NONE ) { - WP_CLI::error( 'Invalid JSON in --config: ' . json_last_error_msg() ); - return; - } - $input['add_handler_config'] = $handler_config; - } - - $ability = new \DataMachine\Abilities\FlowStepAbilities(); - $result = $ability->executeUpdateFlowStep( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to add handler' ); - return; - } - - WP_CLI::success( "Added handler '{$handler_slug}' to flow step {$step_id}" ); - } - - /** - * Remove a handler from a flow step. - * - * @param int $flow_id Flow ID. - * @param array $assoc_args Arguments (handler, step). - */ - private function removeHandler( int $flow_id, array $assoc_args ): void { - $handler_slug = $assoc_args['handler'] ?? null; - $step_id = $assoc_args['step'] ?? null; - - if ( ! $handler_slug ) { - WP_CLI::error( 'Required: --handler=' ); - return; - } - - if ( ! $step_id ) { - $resolved = $this->resolveHandlerStep( $flow_id ); - if ( ! empty( $resolved['error'] ) ) { - WP_CLI::error( $resolved['error'] ); - return; - } - $step_id = $resolved['step_id']; - } - - $ability = new \DataMachine\Abilities\FlowStepAbilities(); - $result = $ability->executeUpdateFlowStep( - array( - 'flow_step_id' => $step_id, - 'remove_handler' => $handler_slug, - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to remove handler' ); - return; - } - - WP_CLI::success( "Removed handler '{$handler_slug}' from flow step {$step_id}" ); - } - - /** - * List handlers on flow steps. - * - * @param int $flow_id Flow ID. - * @param array $assoc_args Arguments (step, format). - */ - private function listHandlers( int $flow_id, array $assoc_args ): void { - $step_id = $assoc_args['step'] ?? null; - - $db = new \DataMachine\Core\Database\Flows\Flows(); - $flow = $db->get_flow( $flow_id ); - - if ( ! $flow ) { - WP_CLI::error( "Flow {$flow_id} not found" ); - return; - } - - $config = $flow['flow_config'] ?? array(); - $rows = array(); - - foreach ( $config as $sid => $step ) { - // Skip flow-level metadata keys. - if ( ! is_array( $step ) || ! isset( $step['step_type'] ) ) { - continue; - } - - if ( $step_id && $sid !== $step_id ) { - continue; - } - - $step_type = $step['step_type'] ?? ''; - - $slugs = $step['handler_slugs'] ?? array(); - $configs = $step['handler_configs'] ?? array(); - - if ( empty( $slugs ) && ! $step_id ) { - continue; // Skip steps with no handlers unless specifically requested. - } - - foreach ( $slugs as $slug ) { - $handler_config = $configs[ $slug ] ?? array(); - $config_summary = array(); - foreach ( $handler_config as $k => $v ) { - if ( is_string( $v ) && strlen( $v ) > 30 ) { - $v = substr( $v, 0, 27 ) . '...'; - } - $config_summary[] = "{$k}=" . ( is_array( $v ) ? wp_json_encode( $v ) : $v ); - } - - $rows[] = array( - 'flow_step_id' => $sid, - 'step_type' => $step_type, - 'handler' => $slug, - 'config' => implode( ', ', $config_summary ) ? implode( ', ', $config_summary ) : '(default)', - ); - } - } - - if ( empty( $rows ) ) { - WP_CLI::warning( 'No handlers found.' ); - return; - } - - $this->format_items( $rows, array( 'flow_step_id', 'step_type', 'handler', 'config' ), $assoc_args ); - } - - /** - * Resolve the handler step for a flow when --step is not provided. - * - * @param int $flow_id Flow ID. - * @return array{step_id: string|null, error: string|null} - */ - private function resolveHandlerStep( int $flow_id ): array { - global $wpdb; - - $flow = $wpdb->get_row( - $wpdb->prepare( - "SELECT flow_config FROM {$wpdb->prefix}datamachine_flows WHERE flow_id = %d", - $flow_id - ), - ARRAY_A - ); - - if ( ! $flow ) { - return array( - 'step_id' => null, - 'error' => 'Flow not found', - ); - } - - $flow_config = json_decode( $flow['flow_config'], true ); - if ( empty( $flow_config ) ) { - return array( - 'step_id' => null, - 'error' => 'Flow has no steps', - ); - } - - $handler_steps = array(); - foreach ( $flow_config as $step_id => $step_data ) { - if ( ! empty( $step_data['handler_slugs'] ) ) { - $handler_steps[] = $step_id; - } - } - - if ( empty( $handler_steps ) ) { - return array( - 'step_id' => null, - 'error' => 'Flow has no handler steps', - ); - } - - if ( count( $handler_steps ) > 1 ) { - return array( - 'step_id' => null, - 'error' => sprintf( - 'Flow has multiple handler steps. Use --step= to specify. Available: %s', - implode( ', ', $handler_steps ) - ), - ); - } - - return array( - 'step_id' => $handler_steps[0], - 'error' => null, - ); - } - - /** - * Manage memory files attached to a flow. - * - * Without --add or --remove, lists current memory files. - * With --add, attaches a file. With --remove, detaches a file. - * - * @param int $flow_id Flow ID. - * @param array $assoc_args Arguments (add, remove, format). - */ - private function memoryFiles( int $flow_id, array $assoc_args ): void { - if ( $flow_id <= 0 ) { - WP_CLI::error( 'flow_id must be a positive integer' ); - return; - } - - $format = $assoc_args['format'] ?? 'table'; - $add_file = $assoc_args['add'] ?? null; - $rm_file = $assoc_args['remove'] ?? null; - - $db = new \DataMachine\Core\Database\Flows\Flows(); - - // Verify flow exists. - $flow = $db->get_flow( $flow_id ); - if ( ! $flow ) { - WP_CLI::error( "Flow {$flow_id} not found" ); - return; - } - - $current_files = $db->get_flow_memory_files( $flow_id ); - - // Add a file. - if ( $add_file ) { - $add_file = sanitize_file_name( $add_file ); - - if ( in_array( $add_file, $current_files, true ) ) { - WP_CLI::warning( sprintf( '"%s" is already attached to flow %d.', $add_file, $flow_id ) ); - return; - } - - $current_files[] = $add_file; - $result = $db->update_flow_memory_files( $flow_id, $current_files ); - - if ( ! $result ) { - WP_CLI::error( 'Failed to update memory files' ); - return; - } - - WP_CLI::success( sprintf( 'Added "%s" to flow %d. Files: %s', $add_file, $flow_id, implode( ', ', $current_files ) ) ); - return; - } - - // Remove a file. - if ( $rm_file ) { - $rm_file = sanitize_file_name( $rm_file ); - - if ( ! in_array( $rm_file, $current_files, true ) ) { - WP_CLI::warning( sprintf( '"%s" is not attached to flow %d.', $rm_file, $flow_id ) ); - return; - } - - $current_files = array_values( array_diff( $current_files, array( $rm_file ) ) ); - $result = $db->update_flow_memory_files( $flow_id, $current_files ); - - if ( ! $result ) { - WP_CLI::error( 'Failed to update memory files' ); - return; - } - - WP_CLI::success( sprintf( 'Removed "%s" from flow %d.', $rm_file, $flow_id ) ); - - if ( ! empty( $current_files ) ) { - WP_CLI::log( sprintf( 'Remaining: %s', implode( ', ', $current_files ) ) ); - } else { - WP_CLI::log( 'No memory files attached.' ); - } - return; - } - - // List files. - if ( empty( $current_files ) ) { - WP_CLI::log( sprintf( 'Flow %d has no memory files attached.', $flow_id ) ); - return; - } - - if ( 'json' === $format ) { - WP_CLI::line( wp_json_encode( $current_files, JSON_PRETTY_PRINT ) ); - return; - } - - $items = array_map( - function ( $filename ) { - return array( 'filename' => $filename ); - }, - $current_files - ); - - \WP_CLI\Utils\format_items( $format, $items, array( 'filename' ) ); - } - - /** - * Pause one or more flows. - * - * Preserves the original schedule so flows can be resumed later. - * - * ## USAGE - * - * wp datamachine flows pause - * wp datamachine flows pause --pipeline= - * wp datamachine flows pause --agent= - * - * @param array $args Positional args (optional flow_id). - * @param array $assoc_args Associative args (--pipeline, --agent). - */ - private function pauseFlows( array $args, array $assoc_args ): void { - $input = $this->buildPauseResumeInput( $args, $assoc_args ); - if ( null === $input ) { - return; // Error already printed. - } - - $ability = new \DataMachine\Abilities\FlowAbilities(); - $result = $ability->executePauseFlow( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to pause flows' ); - return; - } - - WP_CLI::success( $result['message'] ?? 'Flows paused.' ); - - $format = $assoc_args['format'] ?? 'table'; - if ( 'json' === $format ) { - WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); - } else { - foreach ( $result['flows'] ?? array() as $detail ) { - WP_CLI::log( sprintf( ' Flow %d: %s', $detail['flow_id'], $detail['status'] ) ); - } - } - } - - /** - * Resume one or more paused flows. - * - * Re-registers Action Scheduler hooks from the preserved schedule. - * - * ## USAGE - * - * wp datamachine flows resume - * wp datamachine flows resume --pipeline= - * wp datamachine flows resume --agent= - * - * @param array $args Positional args (optional flow_id). - * @param array $assoc_args Associative args (--pipeline, --agent). - */ - private function resumeFlows( array $args, array $assoc_args ): void { - $input = $this->buildPauseResumeInput( $args, $assoc_args ); - if ( null === $input ) { - return; // Error already printed. - } - - $ability = new \DataMachine\Abilities\FlowAbilities(); - $result = $ability->executeResumeFlow( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to resume flows' ); - return; - } - - WP_CLI::success( $result['message'] ?? 'Flows resumed.' ); - - $format = $assoc_args['format'] ?? 'table'; - if ( 'json' === $format ) { - WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); - } else { - foreach ( $result['flows'] ?? array() as $detail ) { - $line = sprintf( ' Flow %d: %s', $detail['flow_id'], $detail['status'] ); - if ( ! empty( $detail['error'] ) ) { - $line .= ' — ' . $detail['error']; - } - WP_CLI::log( $line ); - } - } - } - - /** - * Build input array for pause/resume from CLI args. - * - * @param array $args Positional args. - * @param array $assoc_args Associative args. - * @return array|null Input array, or null on validation error. - */ - private function buildPauseResumeInput( array $args, array $assoc_args ): ?array { - $flow_id = ! empty( $args[0] ) ? (int) $args[0] : null; - $pipeline_id = isset( $assoc_args['pipeline'] ) ? (int) $assoc_args['pipeline'] : ( isset( $assoc_args['pipeline_id'] ) ? (int) $assoc_args['pipeline_id'] : null ); - $agent_id = AgentResolver::resolve( $assoc_args ); - - if ( null === $flow_id && null === $pipeline_id && null === $agent_id ) { - WP_CLI::error( 'Must provide a flow ID, --pipeline=, or --agent=.' ); - return null; - } - - $input = array(); - if ( null !== $flow_id ) { - $input['flow_id'] = $flow_id; - } elseif ( null !== $pipeline_id ) { - $input['pipeline_id'] = $pipeline_id; - } elseif ( null !== $agent_id ) { - $input['agent_id'] = $agent_id; - } - - return $input; - } - - /** - * Build a scheduling_config array from a CLI --scheduling value. - * - * Detects cron expressions and routes them correctly: - * - Cron expression (e.g. "0 * /3 * * *") → interval=cron + cron_expression - * - Interval key (e.g. "daily") → interval= - * - One-time (scheduling=one_time) → interval=one_time + timestamp (requires $scheduled_at) - * - * @param string $scheduling Value from --scheduling CLI flag. - * @param string|null $scheduled_at ISO-8601 datetime for one-time scheduling. - * @return array Scheduling config array. - */ - private static function build_scheduling_config( string $scheduling, ?string $scheduled_at = null ): array { - // If --scheduled-at is provided, treat as one_time regardless of --scheduling value. - if ( $scheduled_at ) { - $timestamp = strtotime( $scheduled_at ); - if ( ! $timestamp ) { - \WP_CLI::error( "Invalid --scheduled-at value: {$scheduled_at}. Use ISO-8601 format (e.g. 2026-03-20T15:00:00Z)." ); - } - return array( - 'interval' => 'one_time', - 'timestamp' => $timestamp, - ); - } - - if ( 'one_time' === $scheduling ) { - \WP_CLI::error( 'one_time scheduling requires --scheduled-at= (ISO-8601 format).' ); - } - - if ( \DataMachine\Api\Flows\FlowScheduling::looks_like_cron_expression( $scheduling ) ) { - return array( - 'interval' => 'cron', - 'cron_expression' => $scheduling, - ); - } - - return array( 'interval' => $scheduling ); - } } diff --git a/inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php b/inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php new file mode 100644 index 000000000..31cdf2900 --- /dev/null +++ b/inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php @@ -0,0 +1,115 @@ +//! buildPauseResumeInput — extracted from FlowsCommand.php. + + + /** + * Pause one or more flows. + * + * Preserves the original schedule so flows can be resumed later. + * + * ## USAGE + * + * wp datamachine flows pause + * wp datamachine flows pause --pipeline= + * wp datamachine flows pause --agent= + * + * @param array $args Positional args (optional flow_id). + * @param array $assoc_args Associative args (--pipeline, --agent). + */ + private function pauseFlows( array $args, array $assoc_args ): void { + $input = $this->buildPauseResumeInput( $args, $assoc_args ); + if ( null === $input ) { + return; // Error already printed. + } + + $ability = new \DataMachine\Abilities\FlowAbilities(); + $result = $ability->executePauseFlow( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to pause flows' ); + return; + } + + WP_CLI::success( $result['message'] ?? 'Flows paused.' ); + + $format = $assoc_args['format'] ?? 'table'; + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); + } else { + foreach ( $result['flows'] ?? array() as $detail ) { + WP_CLI::log( sprintf( ' Flow %d: %s', $detail['flow_id'], $detail['status'] ) ); + } + } + } + + /** + * Resume one or more paused flows. + * + * Re-registers Action Scheduler hooks from the preserved schedule. + * + * ## USAGE + * + * wp datamachine flows resume + * wp datamachine flows resume --pipeline= + * wp datamachine flows resume --agent= + * + * @param array $args Positional args (optional flow_id). + * @param array $assoc_args Associative args (--pipeline, --agent). + */ + private function resumeFlows( array $args, array $assoc_args ): void { + $input = $this->buildPauseResumeInput( $args, $assoc_args ); + if ( null === $input ) { + return; // Error already printed. + } + + $ability = new \DataMachine\Abilities\FlowAbilities(); + $result = $ability->executeResumeFlow( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to resume flows' ); + return; + } + + WP_CLI::success( $result['message'] ?? 'Flows resumed.' ); + + $format = $assoc_args['format'] ?? 'table'; + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); + } else { + foreach ( $result['flows'] ?? array() as $detail ) { + $line = sprintf( ' Flow %d: %s', $detail['flow_id'], $detail['status'] ); + if ( ! empty( $detail['error'] ) ) { + $line .= ' — ' . $detail['error']; + } + WP_CLI::log( $line ); + } + } + } + + /** + * Build input array for pause/resume from CLI args. + * + * @param array $args Positional args. + * @param array $assoc_args Associative args. + * @return array|null Input array, or null on validation error. + */ + private function buildPauseResumeInput( array $args, array $assoc_args ): ?array { + $flow_id = ! empty( $args[0] ) ? (int) $args[0] : null; + $pipeline_id = isset( $assoc_args['pipeline'] ) ? (int) $assoc_args['pipeline'] : ( isset( $assoc_args['pipeline_id'] ) ? (int) $assoc_args['pipeline_id'] : null ); + $agent_id = AgentResolver::resolve( $assoc_args ); + + if ( null === $flow_id && null === $pipeline_id && null === $agent_id ) { + WP_CLI::error( 'Must provide a flow ID, --pipeline=, or --agent=.' ); + return null; + } + + $input = array(); + if ( null !== $flow_id ) { + $input['flow_id'] = $flow_id; + } elseif ( null !== $pipeline_id ) { + $input['pipeline_id'] = $pipeline_id; + } elseif ( null !== $agent_id ) { + $input['agent_id'] = $agent_id; + } + + return $input; + } diff --git a/inc/Cli/Commands/Flows/FlowsCommand/helpers.php b/inc/Cli/Commands/Flows/FlowsCommand/helpers.php new file mode 100644 index 000000000..be3943d14 --- /dev/null +++ b/inc/Cli/Commands/Flows/FlowsCommand/helpers.php @@ -0,0 +1,803 @@ +//! helpers — extracted from FlowsCommand.php. + + + /** + * Get flows with optional filtering. + * + * ## OPTIONS + * + * [...] + * : Subcommand and arguments. Accepts: list [pipeline_id], get , run , create, delete , update . + * + * [--handler=] + * : Filter flows using this handler slug (any step that uses this handler). + * + * [--per_page=] + * : Number of flows to return. 0 = all (default). + * --- + * default: 0 + * --- + * + * [--offset=] + * : Offset for pagination. + * --- + * default: 0 + * --- + * + * [--id=] + * : Get a specific flow by ID. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * - ids + * - count + * --- + * + * [--fields=] + * : Limit output to specific fields (comma-separated). + * + * [--count=] + * : Number of times to run the flow (1-10, immediate execution only). + * --- + * default: 1 + * --- + * + * [--timestamp=] + * : Unix timestamp for delayed execution (future time required). + * + * [--pipeline_id=] + * : Pipeline ID for flow creation (create subcommand). + * + * [--name=] + * : Flow name (create subcommand). + * + * [--step_configs=] + * : JSON object with step configurations keyed by step_type (create subcommand). + * + * [--scheduling=] + * : Scheduling interval (manual, hourly, daily, one_time, etc.) or cron expression (e.g. "0 9 * * 1-5"). + * + * [--scheduled-at=] + * : ISO-8601 datetime for one-time scheduling (e.g. "2026-03-20T15:00:00Z"). Implies --scheduling=one_time. + * + * [--set-prompt=] + * : Update the prompt for a handler step (requires handler step to exist). + * + * [--handler-config=] + * : JSON object of handler config key-value pairs to update (merged with existing config). + * Requires --step to identify the target flow step. + * + * [--step=] + * : Target a specific flow step for prompt update or handler config update (auto-resolved if flow has exactly one handler step). + * + * [--add=] + * : Attach a memory file to a flow (memory-files subcommand). + * + * [--remove=] + * : Detach a memory file from a flow (memory-files subcommand). + * + * [--post_type=] + * : Post type to check against (validate subcommand). Default: 'post'. + * + * [--threshold=] + * : Jaccard similarity threshold 0.0-1.0 (validate subcommand). Default: 0.65. + * + * [--dry-run] + * : Validate without creating (create subcommand). + * + * [--pipeline=] + * : Pipeline ID for pause/resume scoping. + * + * [--agent=] + * : Agent slug or ID for scoping (pause/resume/list). + * + * [--yes] + * : Skip confirmation prompt (delete subcommand). + * + * ## EXAMPLES + * + * # List all flows + * wp datamachine flows + * + * # List flows for pipeline 5 + * wp datamachine flows 5 + * + * # List flows using rss handler + * wp datamachine flows --handler=rss + * + * # Get a specific flow by ID + * wp datamachine flows get 42 + * + * # Run a flow immediately + * wp datamachine flows run 42 + * + * # Create a new flow + * wp datamachine flows create --pipeline_id=3 --name="My Flow" + * + * # Delete a flow + * wp datamachine flows delete 141 + * + * # Update flow name + * wp datamachine flows update 141 --name="New Name" + * + * # Update flow prompt + * wp datamachine flows update 42 --set-prompt="New prompt text" + * + * # Add a handler to a flow step + * wp datamachine flows add-handler 42 --handler=rss + * + * # Remove a handler from a flow step + * wp datamachine flows remove-handler 42 --handler=rss + * + * # List handlers on a flow + * wp datamachine flows list-handlers 42 + * + * # List memory files for a flow + * wp datamachine flows memory-files 42 + * + * # Attach a memory file to a flow + * wp datamachine flows memory-files 42 --add=content-briefing.md + * + * # Detach a memory file from a flow + * wp datamachine flows memory-files 42 --remove=content-briefing.md + * + * # Pause a single flow + * wp datamachine flows pause 42 + * + * # Pause all flows in a pipeline + * wp datamachine flows pause --pipeline=12 + * + * # Pause all flows for an agent + * wp datamachine flows pause --agent=my-agent + * + * # Resume a single flow + * wp datamachine flows resume 42 + * + * # Resume all flows for an agent + * wp datamachine flows resume --agent=my-agent + */ + public function __invoke( array $args, array $assoc_args ): void { + $flow_id = null; + $pipeline_id = null; + + // Handle 'create' subcommand: `flows create --pipeline_id=3 --name="Test"`. + if ( ! empty( $args ) && 'create' === $args[0] ) { + $this->createFlow( $assoc_args ); + return; + } + + // Delegate 'queue' subcommand to QueueCommand. + if ( ! empty( $args ) && 'queue' === $args[0] ) { + $queue = new QueueCommand(); + $queue->dispatch( array_slice( $args, 1 ), $assoc_args ); + return; + } + + // Delegate 'webhook' subcommand to WebhookCommand. + if ( ! empty( $args ) && 'webhook' === $args[0] ) { + $webhook = new WebhookCommand(); + $webhook->dispatch( array_slice( $args, 1 ), $assoc_args ); + return; + } + + // Delegate 'bulk-config' subcommand to BulkConfigCommand. + if ( ! empty( $args ) && 'bulk-config' === $args[0] ) { + $bulk_config = new BulkConfigCommand(); + $bulk_config->dispatch( array_slice( $args, 1 ), $assoc_args ); + return; + } + + // Handle 'memory-files' subcommand. + if ( ! empty( $args ) && 'memory-files' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows memory-files [--add=] [--remove=]' ); + return; + } + $this->memoryFiles( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'pause' subcommand: `flows pause 42` or `flows pause --pipeline=12`. + if ( ! empty( $args ) && 'pause' === $args[0] ) { + $this->pauseFlows( array_slice( $args, 1 ), $assoc_args ); + return; + } + + // Handle 'resume' subcommand: `flows resume 42` or `flows resume --pipeline=12`. + if ( ! empty( $args ) && 'resume' === $args[0] ) { + $this->resumeFlows( array_slice( $args, 1 ), $assoc_args ); + return; + } + + // Handle 'delete' subcommand: `flows delete 42`. + if ( ! empty( $args ) && 'delete' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows delete [--yes]' ); + return; + } + $this->deleteFlow( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'update' subcommand: `flows update 42 --name="New Name"`. + if ( ! empty( $args ) && 'update' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows update [--name=] [--scheduling=] [--set-prompt=] [--handler-config=] [--step=]' ); + return; + } + $this->updateFlow( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'add-handler' subcommand. + if ( ! empty( $args ) && 'add-handler' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows add-handler --handler= [--step=] [--config=]' ); + return; + } + $this->addHandler( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'remove-handler' subcommand. + if ( ! empty( $args ) && 'remove-handler' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows remove-handler --handler= [--step=]' ); + return; + } + $this->removeHandler( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'list-handlers' subcommand. + if ( ! empty( $args ) && 'list-handlers' === $args[0] ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows list-handlers [--step=]' ); + return; + } + $this->listHandlers( (int) $args[1], $assoc_args ); + return; + } + + // Handle 'get'/'show' subcommand: `flows get 42` or `flows show 42`. + if ( ! empty( $args ) && ( 'get' === $args[0] || 'show' === $args[0] ) ) { + if ( isset( $args[1] ) ) { + $flow_id = (int) $args[1]; + } + } elseif ( ! empty( $args ) && 'run' === $args[0] ) { + // Handle 'run' subcommand: `flows run 42`. + if ( ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine flows run [--count=N] [--timestamp=T]' ); + return; + } + $this->runFlow( (int) $args[1], $assoc_args ); + return; + } elseif ( ! empty( $args ) && 'list' !== $args[0] ) { + $pipeline_id = (int) $args[0]; + } + + // Handle --id flag (takes precedence if both provided). + if ( isset( $assoc_args['id'] ) ) { + $flow_id = (int) $assoc_args['id']; + } + + $handler_slug = $assoc_args['handler'] ?? null; + $per_page = (int) ( $assoc_args['per_page'] ?? 0 ); + $offset = (int) ( $assoc_args['offset'] ?? 0 ); + $format = $assoc_args['format'] ?? 'table'; + + if ( $per_page < 0 ) { + $per_page = 0; + } + if ( $offset < 0 ) { + $offset = 0; + } + + $scoping = AgentResolver::buildScopingInput( $assoc_args ); + $ability = new \DataMachine\Abilities\FlowAbilities(); + + // Use 'list' mode for multi-flow views (skips expensive handler enrichment). + // Use 'full' mode for single-flow detail views. + $output_mode = $flow_id ? 'full' : 'list'; + + $result = $ability->executeAbility( + array_merge( + $scoping, + array( + 'flow_id' => $flow_id, + 'pipeline_id' => $pipeline_id, + 'handler_slug' => $handler_slug, + 'per_page' => $per_page, + 'offset' => $offset, + 'output_mode' => $output_mode, + ) + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to get flows' ); + return; + } + + $flows = $result['flows'] ?? array(); + $total = $result['total'] ?? 0; + + if ( empty( $flows ) ) { + WP_CLI::warning( 'No flows found matching your criteria.' ); + return; + } + + // Single flow detail view: show full data including step configs. + if ( $flow_id && 1 === count( $flows ) ) { + $this->showFlowDetail( $flows[0], $format ); + return; + } + + // Transform flows to flat row format. + $items = array_map( + function ( $flow ) { + return array( + 'id' => $flow['flow_id'], + 'name' => $flow['flow_name'], + 'pipeline_id' => $flow['pipeline_id'], + 'handlers' => $this->extractHandlers( $flow ), + 'config' => $this->extractConfigSummary( $flow ), + 'schedule' => $this->extractSchedule( $flow ), + 'max_items' => $this->extractMaxItems( $flow ), + 'prompt' => $this->extractPrompt( $flow ), + 'status' => $flow['last_run_status'] ?? 'Never', + 'next_run' => $flow['next_run_display'] ?? 'Not scheduled', + ); + }, + $flows + ); + + $this->format_items( $items, $this->default_fields, $assoc_args, 'id' ); + $this->output_pagination( $offset, count( $flows ), $total, $format, 'flows' ); + $this->outputFilters( $result['filters_applied'] ?? array(), $format ); + } + + /** + * Run a flow immediately or with scheduling. + * + * @param int $flow_id Flow ID to execute. + * @param array $assoc_args Associative arguments (count, timestamp). + */ + private function runFlow( int $flow_id, array $assoc_args ): void { + $count = isset( $assoc_args['count'] ) ? (int) $assoc_args['count'] : 1; + $timestamp = isset( $assoc_args['timestamp'] ) ? (int) $assoc_args['timestamp'] : null; + + // Validate count range (1-10). + if ( $count < 1 || $count > 10 ) { + WP_CLI::error( 'Count must be between 1 and 10.' ); + return; + } + + $ability = new \DataMachine\Abilities\JobAbilities(); + $result = $ability->executeWorkflow( + array( + 'flow_id' => $flow_id, + 'count' => $count, + 'timestamp' => $timestamp, + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to run flow' ); + return; + } + + // Output success message. + WP_CLI::success( $result['message'] ?? 'Flow execution scheduled.' ); + + // Show job ID(s) for follow-up. + if ( isset( $result['job_id'] ) ) { + WP_CLI::log( sprintf( 'Job ID: %d', $result['job_id'] ) ); + } elseif ( isset( $result['job_ids'] ) ) { + WP_CLI::log( sprintf( 'Job IDs: %s', implode( ', ', $result['job_ids'] ) ) ); + } + } + + /** + * Delete a flow. + * + * @param int $flow_id Flow ID to delete. + * @param array $assoc_args Associative arguments (--yes). + */ + private function deleteFlow( int $flow_id, array $assoc_args ): void { + if ( $flow_id <= 0 ) { + WP_CLI::error( 'flow_id must be a positive integer' ); + return; + } + + $skip_confirm = isset( $assoc_args['yes'] ); + + if ( ! $skip_confirm ) { + WP_CLI::confirm( sprintf( 'Are you sure you want to delete flow %d?', $flow_id ) ); + } + + $ability = new \DataMachine\Abilities\FlowAbilities(); + $result = $ability->executeDeleteFlow( array( 'flow_id' => $flow_id ) ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to delete flow' ); + return; + } + + WP_CLI::success( sprintf( 'Flow %d deleted.', $flow_id ) ); + + if ( isset( $result['pipeline_id'] ) ) { + WP_CLI::log( sprintf( 'Pipeline ID: %d', $result['pipeline_id'] ) ); + } + } + + /** + * Output filter info (table format only). + * + * @param array $filters_applied Applied filters. + * @param string $format Current output format. + */ + private function outputFilters( array $filters_applied, string $format ): void { + if ( 'table' !== $format ) { + return; + } + + if ( $filters_applied['flow_id'] ?? null ) { + WP_CLI::log( "Filtered by flow ID: {$filters_applied['flow_id']}" ); + } + if ( $filters_applied['pipeline_id'] ?? null ) { + WP_CLI::log( "Filtered by pipeline ID: {$filters_applied['pipeline_id']}" ); + } + if ( $filters_applied['handler_slug'] ?? null ) { + WP_CLI::log( "Filtered by handler slug: {$filters_applied['handler_slug']}" ); + } + } + + /** + * Extract handler slugs from flow config. + * + * @param array $flow Flow data. + * @return string Comma-separated handler slugs. + */ + private function extractHandlers( array $flow ): string { + $flow_config = $flow['flow_config'] ?? array(); + $handlers = array(); + + foreach ( $flow_config as $step_data ) { + // Data is normalized at the DB layer — handler_slugs is canonical. + $handlers = array_merge( $handlers, $step_data['handler_slugs'] ?? array() ); + } + + return implode( ', ', array_unique( $handlers ) ); + } + + /** + * Extract a concise config summary from flow handler configs. + * + * Domain-agnostic: reads raw config values without assuming any + * specific taxonomy, handler, or post type. Surfaces distinguishing + * values like coordinates, city names, URLs, and taxonomy selections. + * + * @param array $flow Flow data. + * @return string Config summary (max ~60 chars). + */ + private function extractConfigSummary( array $flow ): string { + $flow_config = $flow['flow_config'] ?? array(); + $parts = array(); + + foreach ( $flow_config as $step_data ) { + $handler_configs = $step_data['handler_configs'] ?? array(); + + foreach ( $handler_configs as $hconfig ) { + if ( ! is_array( $hconfig ) ) { + continue; + } + + // Coordinates (location field with lat,lon). + if ( ! empty( $hconfig['location'] ) && strpos( $hconfig['location'], ',' ) !== false ) { + $loc = $hconfig['location']; + $rad = $hconfig['radius'] ?? ''; + $parts[] = $loc . ( $rad ? " r={$rad}" : '' ); + } + + // City name. + if ( ! empty( $hconfig['city'] ) ) { + $parts[] = "city={$hconfig['city']}"; + } + + // Source URL — show domain only. + if ( ! empty( $hconfig['source_url'] ) ) { + $host = wp_parse_url( $hconfig['source_url'], PHP_URL_HOST ); + $parts[] = $host ?: $hconfig['source_url']; + } + + // Venue/source name. + if ( ! empty( $hconfig['venue_name'] ) ) { + $parts[] = $hconfig['venue_name']; + } + + // Feed URL — show domain only. + $feed_url = $hconfig['feed_url'] ?? $hconfig['url'] ?? ''; + if ( $feed_url && empty( $hconfig['source_url'] ) ) { + $host = wp_parse_url( $feed_url, PHP_URL_HOST ); + $parts[] = $host ?: $feed_url; + } + + // Taxonomy term selections (any taxonomy_*_selection key). + foreach ( $hconfig as $key => $val ) { + if ( strpos( $key, 'taxonomy_' ) === 0 && strpos( $key, '_selection' ) !== false ) { + if ( ! empty( $val ) && 'skip' !== $val && 'ai_decides' !== $val ) { + $tax_name = str_replace( array( 'taxonomy_', '_selection' ), '', $key ); + $parts[] = "{$tax_name}={$val}"; + } + } + } + } + } + + $summary = implode( ' | ', array_unique( $parts ) ); + + if ( mb_strlen( $summary ) > 60 ) { + $summary = mb_substr( $summary, 0, 57 ) . '...'; + } + + return $summary ?: '—'; + } + + /** + * Extract the first prompt from flow config for display. + * + * @param array $flow Flow data. + * @return string Prompt preview. + */ + private function extractPrompt( array $flow ): string { + $flow_config = $flow['flow_config'] ?? array(); + + foreach ( $flow_config as $step_data ) { + $primary_slug = $step_data['handler_slugs'][0] ?? ''; + $primary_config = ! empty( $primary_slug ) ? ( $step_data['handler_configs'][ $primary_slug ] ?? array() ) : array(); + if ( ! empty( $primary_config['prompt'] ) ) { + $prompt = $primary_config['prompt']; + return mb_strlen( $prompt ) > 50 + ? mb_substr( $prompt, 0, 47 ) . '...' + : $prompt; + } + if ( ! empty( $step_data['pipeline_config']['prompt'] ) ) { + $prompt = $step_data['pipeline_config']['prompt']; + return mb_strlen( $prompt ) > 50 + ? mb_substr( $prompt, 0, 47 ) . '...' + : $prompt; + } + } + + return ''; + } + + /** + * Extract scheduling summary from flow scheduling config. + * + * @param array $flow Flow data. + * @return string Scheduling summary for list view. + */ + private function extractSchedule( array $flow ): string { + $scheduling_config = $flow['scheduling_config'] ?? array(); + $interval = $scheduling_config['interval'] ?? 'manual'; + $is_paused = isset( $scheduling_config['enabled'] ) && false === $scheduling_config['enabled']; + + $label = $interval; + if ( 'cron' === $interval && ! empty( $scheduling_config['cron_expression'] ) ) { + $label = 'cron:' . $scheduling_config['cron_expression']; + } + + if ( $is_paused ) { + $label .= ' (paused)'; + } + + return $label; + } + + /** + * Extract max_items values from handler configs in a flow. + * + * @param array $flow Flow data. + * @return string Comma-separated handler=max_items pairs, or empty string. + */ + private function extractMaxItems( array $flow ): string { + $flow_config = $flow['flow_config'] ?? array(); + $pairs = array(); + + foreach ( $flow_config as $step_data ) { + if ( ! is_array( $step_data ) ) { + continue; + } + + $handler_configs = $step_data['handler_configs'] ?? array(); + if ( ! is_array( $handler_configs ) ) { + continue; + } + + foreach ( $handler_configs as $handler_slug => $handler_config ) { + if ( ! is_array( $handler_config ) || ! array_key_exists( 'max_items', $handler_config ) ) { + continue; + } + + $pairs[] = $handler_slug . '=' . (string) $handler_config['max_items']; + } + } + + $pairs = array_values( array_unique( $pairs ) ); + + return implode( ', ', $pairs ); + } + + /** + * List handlers on flow steps. + * + * @param int $flow_id Flow ID. + * @param array $assoc_args Arguments (step, format). + */ + private function listHandlers( int $flow_id, array $assoc_args ): void { + $step_id = $assoc_args['step'] ?? null; + + $db = new \DataMachine\Core\Database\Flows\Flows(); + $flow = $db->get_flow( $flow_id ); + + if ( ! $flow ) { + WP_CLI::error( "Flow {$flow_id} not found" ); + return; + } + + $config = $flow['flow_config'] ?? array(); + $rows = array(); + + foreach ( $config as $sid => $step ) { + // Skip flow-level metadata keys. + if ( ! is_array( $step ) || ! isset( $step['step_type'] ) ) { + continue; + } + + if ( $step_id && $sid !== $step_id ) { + continue; + } + + $step_type = $step['step_type'] ?? ''; + + $slugs = $step['handler_slugs'] ?? array(); + $configs = $step['handler_configs'] ?? array(); + + if ( empty( $slugs ) && ! $step_id ) { + continue; // Skip steps with no handlers unless specifically requested. + } + + foreach ( $slugs as $slug ) { + $handler_config = $configs[ $slug ] ?? array(); + $config_summary = array(); + foreach ( $handler_config as $k => $v ) { + if ( is_string( $v ) && strlen( $v ) > 30 ) { + $v = substr( $v, 0, 27 ) . '...'; + } + $config_summary[] = "{$k}=" . ( is_array( $v ) ? wp_json_encode( $v ) : $v ); + } + + $rows[] = array( + 'flow_step_id' => $sid, + 'step_type' => $step_type, + 'handler' => $slug, + 'config' => implode( ', ', $config_summary ) ? implode( ', ', $config_summary ) : '(default)', + ); + } + } + + if ( empty( $rows ) ) { + WP_CLI::warning( 'No handlers found.' ); + return; + } + + $this->format_items( $rows, array( 'flow_step_id', 'step_type', 'handler', 'config' ), $assoc_args ); + } + + /** + * Manage memory files attached to a flow. + * + * Without --add or --remove, lists current memory files. + * With --add, attaches a file. With --remove, detaches a file. + * + * @param int $flow_id Flow ID. + * @param array $assoc_args Arguments (add, remove, format). + */ + private function memoryFiles( int $flow_id, array $assoc_args ): void { + if ( $flow_id <= 0 ) { + WP_CLI::error( 'flow_id must be a positive integer' ); + return; + } + + $format = $assoc_args['format'] ?? 'table'; + $add_file = $assoc_args['add'] ?? null; + $rm_file = $assoc_args['remove'] ?? null; + + $db = new \DataMachine\Core\Database\Flows\Flows(); + + // Verify flow exists. + $flow = $db->get_flow( $flow_id ); + if ( ! $flow ) { + WP_CLI::error( "Flow {$flow_id} not found" ); + return; + } + + $current_files = $db->get_flow_memory_files( $flow_id ); + + // Add a file. + if ( $add_file ) { + $add_file = sanitize_file_name( $add_file ); + + if ( in_array( $add_file, $current_files, true ) ) { + WP_CLI::warning( sprintf( '"%s" is already attached to flow %d.', $add_file, $flow_id ) ); + return; + } + + $current_files[] = $add_file; + $result = $db->update_flow_memory_files( $flow_id, $current_files ); + + if ( ! $result ) { + WP_CLI::error( 'Failed to update memory files' ); + return; + } + + WP_CLI::success( sprintf( 'Added "%s" to flow %d. Files: %s', $add_file, $flow_id, implode( ', ', $current_files ) ) ); + return; + } + + // Remove a file. + if ( $rm_file ) { + $rm_file = sanitize_file_name( $rm_file ); + + if ( ! in_array( $rm_file, $current_files, true ) ) { + WP_CLI::warning( sprintf( '"%s" is not attached to flow %d.', $rm_file, $flow_id ) ); + return; + } + + $current_files = array_values( array_diff( $current_files, array( $rm_file ) ) ); + $result = $db->update_flow_memory_files( $flow_id, $current_files ); + + if ( ! $result ) { + WP_CLI::error( 'Failed to update memory files' ); + return; + } + + WP_CLI::success( sprintf( 'Removed "%s" from flow %d.', $rm_file, $flow_id ) ); + + if ( ! empty( $current_files ) ) { + WP_CLI::log( sprintf( 'Remaining: %s', implode( ', ', $current_files ) ) ); + } else { + WP_CLI::log( 'No memory files attached.' ); + } + return; + } + + // List files. + if ( empty( $current_files ) ) { + WP_CLI::log( sprintf( 'Flow %d has no memory files attached.', $flow_id ) ); + return; + } + + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $current_files, JSON_PRETTY_PRINT ) ); + return; + } + + $items = array_map( + function ( $filename ) { + return array( 'filename' => $filename ); + }, + $current_files + ); + + \WP_CLI\Utils\format_items( $format, $items, array( 'filename' ) ); + } diff --git a/inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php b/inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php new file mode 100644 index 000000000..0bcf9a37b --- /dev/null +++ b/inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php @@ -0,0 +1,467 @@ +//! resolveHandlerStep — extracted from FlowsCommand.php. + + + /** + * Create a new flow. + * + * @param array $assoc_args Associative arguments (pipeline_id, name, step_configs, scheduling, dry-run). + */ + private function createFlow( array $assoc_args ): void { + $pipeline_id = isset( $assoc_args['pipeline_id'] ) ? (int) $assoc_args['pipeline_id'] : null; + $flow_name = $assoc_args['name'] ?? null; + $scheduling = $assoc_args['scheduling'] ?? 'manual'; + $scheduled_at = $assoc_args['scheduled-at'] ?? null; + $dry_run = isset( $assoc_args['dry-run'] ); + $format = $assoc_args['format'] ?? 'table'; + + if ( ! $pipeline_id ) { + WP_CLI::error( 'Required: --pipeline_id=' ); + return; + } + + if ( ! $flow_name ) { + WP_CLI::error( 'Required: --name=' ); + return; + } + + $step_configs = array(); + if ( isset( $assoc_args['step_configs'] ) ) { + $decoded = json_decode( wp_unslash( $assoc_args['step_configs'] ), true ); + if ( null === $decoded && '' !== $assoc_args['step_configs'] ) { + WP_CLI::error( 'Invalid JSON in --step_configs' ); + return; + } + if ( null !== $decoded && ! is_array( $decoded ) ) { + WP_CLI::error( '--step_configs must be a JSON object' ); + return; + } + $step_configs = $decoded ?? array(); + } + + // Convert --handler-config to step_configs entries. + // --handler-config accepts handler-keyed JSON, e.g. {"reddit":{"subreddit":"test"}}. + // Each handler slug is resolved to its step type and merged into step_configs. + if ( isset( $assoc_args['handler-config'] ) ) { + $handler_config_input = json_decode( wp_unslash( $assoc_args['handler-config'] ), true ); + if ( ! is_array( $handler_config_input ) ) { + WP_CLI::error( 'Invalid JSON in --handler-config. Must be a JSON object.' ); + return; + } + + $handler_abilities = new \DataMachine\Abilities\HandlerAbilities(); + $all_handlers = $handler_abilities->getAllHandlers(); + + foreach ( $handler_config_input as $handler_slug => $config ) { + if ( ! isset( $all_handlers[ $handler_slug ] ) ) { + WP_CLI::error( "Unknown handler '{$handler_slug}'. Use --handler-config with valid handler slugs." ); + return; + } + + $step_type = $all_handlers[ $handler_slug ]['type'] ?? ''; + if ( empty( $step_type ) ) { + WP_CLI::error( "Cannot determine step type for handler '{$handler_slug}'." ); + return; + } + + $step_configs[ $step_type ] = array( + 'handler_slug' => $handler_slug, + 'handler_config' => $config, + ); + } + } + + $scheduling_config = self::build_scheduling_config( $scheduling, $scheduled_at ); + + $input = array( + 'pipeline_id' => $pipeline_id, + 'flow_name' => $flow_name, + 'scheduling_config' => $scheduling_config, + 'step_configs' => $step_configs, + ); + + if ( $dry_run ) { + $input['validate_only'] = true; + $input['flows'] = array( + array( + 'pipeline_id' => $pipeline_id, + 'flow_name' => $flow_name, + 'scheduling_config' => $scheduling_config, + 'step_configs' => $step_configs, + ), + ); + } + + $ability = new \DataMachine\Abilities\FlowAbilities(); + $result = $ability->executeCreateFlow( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to create flow' ); + return; + } + + if ( $dry_run ) { + WP_CLI::success( 'Validation passed.' ); + if ( isset( $result['would_create'] ) && 'json' === $format ) { + WP_CLI::line( wp_json_encode( $result['would_create'], JSON_PRETTY_PRINT ) ); + } elseif ( isset( $result['would_create'] ) ) { + foreach ( $result['would_create'] as $preview ) { + WP_CLI::log( + sprintf( + 'Would create: "%s" on pipeline %d (scheduling: %s)', + $preview['flow_name'], + $preview['pipeline_id'], + $preview['scheduling'] + ) + ); + } + } + return; + } + + WP_CLI::success( sprintf( 'Flow created: ID %d', $result['flow_id'] ) ); + WP_CLI::log( sprintf( 'Name: %s', $result['flow_name'] ) ); + WP_CLI::log( sprintf( 'Pipeline ID: %d', $result['pipeline_id'] ) ); + WP_CLI::log( sprintf( 'Synced steps: %d', $result['synced_steps'] ?? 0 ) ); + + if ( ! empty( $result['configured_steps'] ) ) { + WP_CLI::log( sprintf( 'Configured steps: %s', implode( ', ', $result['configured_steps'] ) ) ); + } + + if ( ! empty( $result['configuration_errors'] ) ) { + WP_CLI::warning( 'Some step configurations failed:' ); + foreach ( $result['configuration_errors'] as $error ) { + WP_CLI::log( sprintf( ' - %s: %s', $error['step_type'] ?? 'unknown', $error['error'] ?? 'unknown error' ) ); + } + } + + if ( 'json' === $format && isset( $result['flow_data'] ) ) { + WP_CLI::line( wp_json_encode( $result['flow_data'], JSON_PRETTY_PRINT ) ); + } + } + + /** + * Update a flow's name or scheduling. + * + * @param int $flow_id Flow ID to update. + * @param array $assoc_args Associative arguments (--name, --scheduling). + */ + private function updateFlow( int $flow_id, array $assoc_args ): void { + if ( $flow_id <= 0 ) { + WP_CLI::error( 'flow_id must be a positive integer' ); + return; + } + + $name = $assoc_args['name'] ?? null; + $scheduling = $assoc_args['scheduling'] ?? null; + $scheduled_at = $assoc_args['scheduled-at'] ?? null; + $prompt = isset( $assoc_args['set-prompt'] ) + ? wp_kses_post( wp_unslash( $assoc_args['set-prompt'] ) ) + : null; + $handler_config = isset( $assoc_args['handler-config'] ) + ? json_decode( wp_unslash( $assoc_args['handler-config'] ), true ) + : null; + $step = $assoc_args['step'] ?? null; + + // --scheduled-at implies --scheduling=one_time. + if ( $scheduled_at && null === $scheduling ) { + $scheduling = 'one_time'; + } + + if ( null !== $handler_config && ! is_array( $handler_config ) ) { + WP_CLI::error( 'Invalid JSON in --handler-config. Must be a JSON object.' ); + return; + } + + if ( null === $name && null === $scheduling && null === $prompt && null === $handler_config ) { + WP_CLI::error( 'Must provide --name, --scheduling, --set-prompt, --scheduled-at, or --handler-config to update' ); + return; + } + + // Validate step resolution BEFORE any writes (atomic: fail fast, change nothing). + $needs_step = null !== $prompt || null !== $handler_config; + + if ( $needs_step && null === $step ) { + $resolved = $this->resolveHandlerStep( $flow_id ); + if ( $resolved['error'] ) { + WP_CLI::error( $resolved['error'] ); + return; + } + $step = $resolved['step_id']; + } + + // Phase 1: Flow-level updates (name, scheduling). + $input = array( 'flow_id' => $flow_id ); + + if ( null !== $name ) { + $input['flow_name'] = $name; + } + + if ( null !== $scheduling ) { + $input['scheduling_config'] = self::build_scheduling_config( $scheduling, $scheduled_at ); + } + + if ( null !== $name || null !== $scheduling ) { + $ability = new \DataMachine\Abilities\FlowAbilities(); + $result = $ability->executeUpdateFlow( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to update flow' ); + return; + } + + WP_CLI::success( sprintf( 'Flow %d updated.', $flow_id ) ); + WP_CLI::log( sprintf( 'Name: %s', $result['flow_name'] ?? '' ) ); + + $sched = $result['flow_data']['scheduling_config'] ?? array(); + if ( 'cron' === ( $sched['interval'] ?? '' ) && ! empty( $sched['cron_expression'] ) ) { + WP_CLI::log( sprintf( 'Scheduling: cron (%s)', $sched['cron_expression'] ) ); + } elseif ( isset( $sched['interval'] ) ) { + WP_CLI::log( sprintf( 'Scheduling: %s', $sched['interval'] ) ); + } + } + + // Phase 2: Step-level updates (prompt, handler config). + if ( null !== $prompt ) { + $step_ability = new \DataMachine\Abilities\FlowStep\UpdateFlowStepAbility(); + $step_result = $step_ability->execute( + array( + 'flow_step_id' => $step, + 'handler_config' => array( 'prompt' => $prompt ), + ) + ); + + if ( ! $step_result['success'] ) { + WP_CLI::error( $step_result['error'] ?? 'Failed to update prompt' ); + return; + } + + WP_CLI::success( 'Prompt updated for step: ' . $step ); + } + + if ( null !== $handler_config ) { + // --handler-config accepts handler-keyed JSON, e.g. {"reddit":{"subreddit":"test"}}. + // Unwrap: the key is the handler slug, the value is the config. + $handler_slug = null; + $unwrapped_config = $handler_config; + $handler_config_keys = array_keys( $handler_config ); + + // If the top-level keys look like handler slugs (single key wrapping a config object), + // unwrap the handler slug from the JSON structure. + if ( count( $handler_config_keys ) === 1 && is_array( $handler_config[ $handler_config_keys[0] ] ) ) { + $handler_slug = $handler_config_keys[0]; + $unwrapped_config = $handler_config[ $handler_slug ]; + } + + $step_input = array( + 'flow_step_id' => $step, + 'handler_config' => $unwrapped_config, + ); + + if ( $handler_slug ) { + $step_input['handler_slug'] = $handler_slug; + } + + $step_ability = new \DataMachine\Abilities\FlowStep\UpdateFlowStepAbility(); + $step_result = $step_ability->execute( $step_input ); + + if ( ! $step_result['success'] ) { + WP_CLI::error( $step_result['error'] ?? 'Failed to update handler config' ); + return; + } + + $updated_keys = implode( ', ', array_keys( $unwrapped_config ) ); + WP_CLI::success( sprintf( 'Handler config updated for step %s: %s', $step, $updated_keys ) ); + } + } + + /** + * Add a handler to a flow step. + * + * @param int $flow_id Flow ID. + * @param array $assoc_args Arguments (handler, step, config). + */ + private function addHandler( int $flow_id, array $assoc_args ): void { + $handler_slug = $assoc_args['handler'] ?? null; + $step_id = $assoc_args['step'] ?? null; + + if ( ! $handler_slug ) { + WP_CLI::error( 'Required: --handler=' ); + return; + } + + // Auto-resolve handler step if not specified. + if ( ! $step_id ) { + $resolved = $this->resolveHandlerStep( $flow_id ); + if ( ! empty( $resolved['error'] ) ) { + WP_CLI::error( $resolved['error'] ); + return; + } + $step_id = $resolved['step_id']; + } + + $input = array( + 'flow_step_id' => $step_id, + 'add_handler' => $handler_slug, + ); + + // Parse --config if provided. + if ( isset( $assoc_args['config'] ) ) { + $handler_config = json_decode( wp_unslash( $assoc_args['config'] ), true ); + if ( json_last_error() !== JSON_ERROR_NONE ) { + WP_CLI::error( 'Invalid JSON in --config: ' . json_last_error_msg() ); + return; + } + $input['add_handler_config'] = $handler_config; + } + + $ability = new \DataMachine\Abilities\FlowStepAbilities(); + $result = $ability->executeUpdateFlowStep( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to add handler' ); + return; + } + + WP_CLI::success( "Added handler '{$handler_slug}' to flow step {$step_id}" ); + } + + /** + * Remove a handler from a flow step. + * + * @param int $flow_id Flow ID. + * @param array $assoc_args Arguments (handler, step). + */ + private function removeHandler( int $flow_id, array $assoc_args ): void { + $handler_slug = $assoc_args['handler'] ?? null; + $step_id = $assoc_args['step'] ?? null; + + if ( ! $handler_slug ) { + WP_CLI::error( 'Required: --handler=' ); + return; + } + + if ( ! $step_id ) { + $resolved = $this->resolveHandlerStep( $flow_id ); + if ( ! empty( $resolved['error'] ) ) { + WP_CLI::error( $resolved['error'] ); + return; + } + $step_id = $resolved['step_id']; + } + + $ability = new \DataMachine\Abilities\FlowStepAbilities(); + $result = $ability->executeUpdateFlowStep( + array( + 'flow_step_id' => $step_id, + 'remove_handler' => $handler_slug, + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to remove handler' ); + return; + } + + WP_CLI::success( "Removed handler '{$handler_slug}' from flow step {$step_id}" ); + } + + /** + * Resolve the handler step for a flow when --step is not provided. + * + * @param int $flow_id Flow ID. + * @return array{step_id: string|null, error: string|null} + */ + private function resolveHandlerStep( int $flow_id ): array { + global $wpdb; + + $flow = $wpdb->get_row( + $wpdb->prepare( + "SELECT flow_config FROM {$wpdb->prefix}datamachine_flows WHERE flow_id = %d", + $flow_id + ), + ARRAY_A + ); + + if ( ! $flow ) { + return array( + 'step_id' => null, + 'error' => 'Flow not found', + ); + } + + $flow_config = json_decode( $flow['flow_config'], true ); + if ( empty( $flow_config ) ) { + return array( + 'step_id' => null, + 'error' => 'Flow has no steps', + ); + } + + $handler_steps = array(); + foreach ( $flow_config as $step_id => $step_data ) { + if ( ! empty( $step_data['handler_slugs'] ) ) { + $handler_steps[] = $step_id; + } + } + + if ( empty( $handler_steps ) ) { + return array( + 'step_id' => null, + 'error' => 'Flow has no handler steps', + ); + } + + if ( count( $handler_steps ) > 1 ) { + return array( + 'step_id' => null, + 'error' => sprintf( + 'Flow has multiple handler steps. Use --step= to specify. Available: %s', + implode( ', ', $handler_steps ) + ), + ); + } + + return array( + 'step_id' => $handler_steps[0], + 'error' => null, + ); + } + + /** + * Build a scheduling_config array from a CLI --scheduling value. + * + * Detects cron expressions and routes them correctly: + * - Cron expression (e.g. "0 * /3 * * *") → interval=cron + cron_expression + * - Interval key (e.g. "daily") → interval= + * - One-time (scheduling=one_time) → interval=one_time + timestamp (requires $scheduled_at) + * + * @param string $scheduling Value from --scheduling CLI flag. + * @param string|null $scheduled_at ISO-8601 datetime for one-time scheduling. + * @return array Scheduling config array. + */ + private static function build_scheduling_config( string $scheduling, ?string $scheduled_at = null ): array { + // If --scheduled-at is provided, treat as one_time regardless of --scheduling value. + if ( $scheduled_at ) { + $timestamp = strtotime( $scheduled_at ); + if ( ! $timestamp ) { + \WP_CLI::error( "Invalid --scheduled-at value: {$scheduled_at}. Use ISO-8601 format (e.g. 2026-03-20T15:00:00Z)." ); + } + return array( + 'interval' => 'one_time', + 'timestamp' => $timestamp, + ); + } + + if ( 'one_time' === $scheduling ) { + \WP_CLI::error( 'one_time scheduling requires --scheduled-at= (ISO-8601 format).' ); + } + + if ( \DataMachine\Api\Flows\FlowScheduling::looks_like_cron_expression( $scheduling ) ) { + return array( + 'interval' => 'cron', + 'cron_expression' => $scheduling, + ); + } + + return array( 'interval' => $scheduling ); + } diff --git a/inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php b/inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php new file mode 100644 index 000000000..f986ab130 --- /dev/null +++ b/inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php @@ -0,0 +1,150 @@ +//! truncateValue — extracted from FlowsCommand.php. + + + /** + * Show detailed view of a single flow including step configs. + * + * For JSON format: outputs the full flow data with flow_config intact. + * For table format: outputs key-value pairs followed by a step configs table. + * + * @param array $flow Full flow data from FlowAbilities. + * @param string $format Output format (table, json, csv, yaml). + */ + private function showFlowDetail( array $flow, string $format ): void { + // JSON/YAML: output the full flow data including flow_config. + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $flow, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + if ( 'yaml' === $format ) { + WP_CLI\Utils\format_items( 'yaml', array( $flow ), array_keys( $flow ) ); + return; + } + + // Table format: show flow summary, then step configs. + $scheduling = $flow['scheduling_config'] ?? array(); + $interval = $scheduling['interval'] ?? 'manual'; + + $is_paused = isset( $scheduling['enabled'] ) && false === $scheduling['enabled']; + + WP_CLI::log( sprintf( 'Flow ID: %d', $flow['flow_id'] ) ); + WP_CLI::log( sprintf( 'Name: %s', $flow['flow_name'] ) ); + WP_CLI::log( sprintf( 'Pipeline ID: %s', $flow['pipeline_id'] ?? 'N/A' ) ); + if ( 'cron' === $interval && ! empty( $scheduling['cron_expression'] ) ) { + $cron_desc = \DataMachine\Api\Flows\FlowScheduling::describe_cron_expression( $scheduling['cron_expression'] ); + WP_CLI::log( sprintf( 'Scheduling: cron (%s) — %s', $scheduling['cron_expression'], $cron_desc ) ); + } else { + WP_CLI::log( sprintf( 'Scheduling: %s', $interval ) ); + } + if ( $is_paused ) { + WP_CLI::log( 'Status: PAUSED' ); + } + WP_CLI::log( sprintf( 'Last run: %s', $flow['last_run_display'] ?? 'Never' ) ); + WP_CLI::log( sprintf( 'Next run: %s', $flow['next_run_display'] ?? 'Not scheduled' ) ); + WP_CLI::log( sprintf( 'Running: %s', ( $flow['is_running'] ?? false ) ? 'Yes' : 'No' ) ); + WP_CLI::log( '' ); + + // Step configs section. + $config = $flow['flow_config'] ?? array(); + + if ( empty( $config ) ) { + WP_CLI::log( 'Steps: (none)' ); + return; + } + + // Show memory files if attached. + $memory_files = $config['memory_files'] ?? array(); + if ( ! empty( $memory_files ) ) { + WP_CLI::log( sprintf( 'Memory files: %s', implode( ', ', $memory_files ) ) ); + WP_CLI::log( '' ); + } + + $rows = array(); + foreach ( $config as $step_id => $step_data ) { + // Skip flow-level metadata keys — only display step configs. + if ( ! is_array( $step_data ) || ! isset( $step_data['step_type'] ) ) { + continue; + } + + $step_type = $step_data['step_type'] ?? ''; + $order = $step_data['execution_order'] ?? ''; + $slugs = $step_data['handler_slugs'] ?? array(); + $configs = $step_data['handler_configs'] ?? array(); + + // Show pipeline-level prompt if set. + $pipeline_prompt = $step_data['pipeline_config']['prompt'] ?? ''; + + if ( empty( $slugs ) ) { + // Step with no handlers (e.g. AI step with only pipeline config). + $config_display = ''; + + if ( $pipeline_prompt ) { + $config_display = 'prompt=' . $this->truncateValue( $pipeline_prompt, 60 ); + } + + $rows[] = array( + 'step_id' => $step_id, + 'order' => $order, + 'step_type' => $step_type, + 'handler' => '—', + 'config' => $config_display ? $config_display : '(default)', + ); + continue; + } + + foreach ( $slugs as $slug ) { + $handler_config = $configs[ $slug ] ?? array(); + $config_parts = array(); + + foreach ( $handler_config as $key => $value ) { + $config_parts[] = $key . '=' . $this->formatConfigValue( $value ); + } + + $rows[] = array( + 'step_id' => $step_id, + 'order' => $order, + 'step_type' => $step_type, + 'handler' => $slug, + 'config' => implode( ', ', $config_parts ) ? implode( ', ', $config_parts ) : '(default)', + ); + } + } + + WP_CLI::log( 'Steps:' ); + + $step_fields = array( 'step_id', 'order', 'step_type', 'handler', 'config' ); + WP_CLI\Utils\format_items( 'table', $rows, $step_fields ); + } + + /** + * Truncate a display value to a maximum length. + * + * @param string $value Value to truncate. + * @param int $max Maximum characters. + * @return string Truncated value. + */ + private function truncateValue( string $value, int $max = 40 ): string { + $value = str_replace( array( "\n", "\r" ), ' ', $value ); + if ( mb_strlen( $value ) > $max ) { + return mb_substr( $value, 0, $max - 3 ) . '...'; + } + return $value; + } + + /** + * Format a config value for display in the step configs table. + * + * @param mixed $value Config value. + * @return string Formatted value. + */ + private function formatConfigValue( $value ): string { + if ( is_bool( $value ) ) { + return $value ? 'true' : 'false'; + } + if ( is_array( $value ) ) { + return wp_json_encode( $value ); + } + $str = (string) $value; + return $this->truncateValue( $str ); + } diff --git a/inc/Cli/Commands/JobsCommand.php b/inc/Cli/Commands/JobsCommand.php index af58a400d..80ad4eee4 100644 --- a/inc/Cli/Commands/JobsCommand.php +++ b/inc/Cli/Commands/JobsCommand.php @@ -36,1113 +36,4 @@ class JobsCommand extends BaseCommand { * @var JobAbilities */ private JobAbilities $abilities; - - public function __construct() { - $this->abilities = new JobAbilities(); - } - - /** - * Recover stuck jobs that have job_status in engine_data but status is 'processing'. - * - * Jobs can become stuck when the engine stores a status override (e.g., from skip_item) - * in engine_data but the main status column doesn't get updated. This command finds - * those jobs and completes them with their intended final status. - * - * Also recovers jobs that have been processing for longer than the timeout threshold - * without a status override, marking them as failed and potentially requeuing prompts. - * - * ## OPTIONS - * - * [--dry-run] - * : Show what would be updated without making changes. - * - * [--flow=] - * : Only recover jobs for a specific flow ID. - * - * [--timeout=] - * : Hours before a processing job without status override is considered timed out. - * --- - * default: 2 - * --- - * - * ## EXAMPLES - * - * # Preview stuck jobs recovery - * wp datamachine jobs recover-stuck --dry-run - * - * # Recover all stuck jobs - * wp datamachine jobs recover-stuck - * - * # Recover stuck jobs for a specific flow - * wp datamachine jobs recover-stuck --flow=98 - * - * # Recover stuck jobs with custom timeout - * wp datamachine jobs recover-stuck --timeout=4 - * - * @subcommand recover-stuck - */ - public function recover_stuck( array $args, array $assoc_args ): void { - $dry_run = isset( $assoc_args['dry-run'] ); - $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; - $timeout = isset( $assoc_args['timeout'] ) ? max( 1, (int) $assoc_args['timeout'] ) : 2; - - $result = $this->abilities->executeRecoverStuckJobs( - array( - 'dry_run' => $dry_run, - 'flow_id' => $flow_id, - 'timeout_hours' => $timeout, - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - $jobs = $result['jobs'] ?? array(); - - if ( empty( $jobs ) ) { - WP_CLI::success( 'No stuck jobs found.' ); - return; - } - - WP_CLI::log( sprintf( 'Found %d stuck jobs with job_status in engine_data.', count( $jobs ) ) ); - - if ( $dry_run ) { - WP_CLI::log( 'Dry run - no changes will be made.' ); - WP_CLI::log( '' ); - } - - foreach ( $jobs as $job ) { - if ( 'skipped' === $job['status'] ) { - WP_CLI::warning( sprintf( 'Job %d: %s', $job['job_id'], $job['reason'] ?? 'Unknown reason' ) ); - } elseif ( 'would_recover' === $job['status'] ) { - $display_status = strlen( $job['target_status'] ) > 60 ? substr( $job['target_status'], 0, 60 ) . '...' : $job['target_status']; - WP_CLI::log( - sprintf( - 'Would update job %d (flow %d) to: %s', - $job['job_id'], - $job['flow_id'], - $display_status - ) - ); - } elseif ( 'recovered' === $job['status'] ) { - $display_status = strlen( $job['target_status'] ) > 60 ? substr( $job['target_status'], 0, 60 ) . '...' : $job['target_status']; - WP_CLI::log( sprintf( 'Updated job %d to: %s', $job['job_id'], $display_status ) ); - } elseif ( 'would_timeout' === $job['status'] ) { - WP_CLI::log( sprintf( 'Would timeout job %d (flow %d)', $job['job_id'], $job['flow_id'] ) ); - } elseif ( 'timed_out' === $job['status'] ) { - WP_CLI::log( sprintf( 'Timed out job %d (flow %d)', $job['job_id'], $job['flow_id'] ) ); - } - } - - WP_CLI::success( $result['message'] ); - } - - /** - * List jobs with optional status filter. - * - * ## OPTIONS - * - * [--status=] - * : Filter by status (pending, processing, completed, failed, agent_skipped, completed_no_items). - * - * [--flow=] - * : Filter by flow ID. - * - * [--source=] - * : Filter by source (pipeline, system). - * - * [--since=] - * : Show jobs created after this time. Accepts ISO datetime or relative strings (e.g., "1 hour ago", "today", "yesterday"). - * - * [--limit=] - * : Number of jobs to show. - * --- - * default: 20 - * --- - * - * [--format=] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * - ids - * - count - * --- - * - * [--fields=] - * : Limit output to specific fields (comma-separated). - * - * ## EXAMPLES - * - * # List recent jobs - * wp datamachine jobs list - * - * # List processing jobs - * wp datamachine jobs list --status=processing - * - * # List jobs for a specific flow - * wp datamachine jobs list --flow=98 --limit=50 - * - * # Output as CSV - * wp datamachine jobs list --format=csv - * - * # Output only IDs (space-separated) - * wp datamachine jobs list --format=ids - * - * # Count total jobs - * wp datamachine jobs list --format=count - * - * # JSON output - * wp datamachine jobs list --format=json - * - * # Show failed jobs from the last 2 hours - * wp datamachine jobs list --status=failed --since="2 hours ago" - * - * # Show all jobs since midnight - * wp datamachine jobs list --since=today - * - * @subcommand list - */ - public function list_jobs( array $args, array $assoc_args ): void { - $status = $assoc_args['status'] ?? null; - $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; - $limit = (int) ( $assoc_args['limit'] ?? 20 ); - $format = $assoc_args['format'] ?? 'table'; - - if ( $limit < 1 ) { - $limit = 20; - } - if ( $limit > 500 ) { - $limit = 500; - } - - $scoping = AgentResolver::buildScopingInput( $assoc_args ); - - $input = array_merge( - $scoping, - array( - 'per_page' => $limit, - 'offset' => 0, - 'orderby' => 'j.job_id', - 'order' => 'DESC', - ) - ); - - if ( $status ) { - $input['status'] = $status; - } - - if ( $flow_id ) { - $input['flow_id'] = $flow_id; - } - - $since = $assoc_args['since'] ?? null; - if ( $since ) { - $timestamp = strtotime( $since ); - if ( false === $timestamp ) { - WP_CLI::error( sprintf( 'Invalid --since value: "%s". Use ISO datetime or relative string (e.g., "1 hour ago", "today").', $since ) ); - return; - } - $input['since'] = gmdate( 'Y-m-d H:i:s', $timestamp ); - } - - $result = $this->abilities->executeGetJobs( $input ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - $jobs = $result['jobs'] ?? array(); - - if ( empty( $jobs ) ) { - WP_CLI::warning( 'No jobs found.' ); - return; - } - - // Filter by source if specified. - $source_filter = $assoc_args['source'] ?? null; - if ( $source_filter ) { - $jobs = array_filter( - $jobs, - function ( $j ) use ( $source_filter ) { - return ( $j['source'] ?? 'pipeline' ) === $source_filter; - } - ); - $jobs = array_values( $jobs ); - - if ( empty( $jobs ) ) { - WP_CLI::warning( sprintf( 'No %s jobs found.', $source_filter ) ); - return; - } - } - - // Transform jobs to flat row format. - $items = array_map( - function ( $j ) { - $source = $j['source'] ?? 'pipeline'; - $status_display = strlen( $j['status'] ?? '' ) > 40 ? substr( $j['status'], 0, 40 ) . '...' : ( $j['status'] ?? '' ); - - if ( 'system' === $source ) { - $flow_display = $j['label'] ?? $j['display_label'] ?? 'System Task'; - } else { - $flow_display = $j['flow_name'] ?? ( isset( $j['flow_id'] ) ? "Flow {$j['flow_id']}" : '' ); - } - - return array( - 'id' => $j['job_id'] ?? '', - 'source' => $source, - 'flow' => $flow_display, - 'status' => $status_display, - 'created' => $j['created_at'] ?? '', - 'completed' => $j['completed_at'] ?? '-', - ); - }, - $jobs - ); - - $this->format_items( $items, $this->default_fields, $assoc_args, 'id' ); - - if ( 'table' === $format ) { - WP_CLI::log( sprintf( 'Showing %d jobs.', count( $jobs ) ) ); - } - } - - /** - * Show detailed information about a specific job. - * - * ## OPTIONS - * - * - * : The job ID to display. - * - * [--format=] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - yaml - * --- - * - * ## EXAMPLES - * - * # Show job details - * wp datamachine jobs show 844 - * - * # Show job as JSON (includes full engine_data) - * wp datamachine jobs show 844 --format=json - * - * @subcommand show - */ - public function show( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) ) { - WP_CLI::error( 'Job ID is required.' ); - return; - } - - $job_id = $args[0]; - - if ( ! is_numeric( $job_id ) || (int) $job_id <= 0 ) { - WP_CLI::error( 'Job ID must be a positive integer.' ); - return; - } - - $result = $this->abilities->executeGetJobs( array( 'job_id' => (int) $job_id ) ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - $jobs = $result['jobs'] ?? array(); - - if ( empty( $jobs ) ) { - WP_CLI::error( sprintf( 'Job %d not found.', (int) $job_id ) ); - return; - } - - $job = $jobs[0]; - $format = $assoc_args['format'] ?? 'table'; - - if ( 'json' === $format ) { - WP_CLI::log( wp_json_encode( $job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); - return; - } - - if ( 'yaml' === $format ) { - WP_CLI::log( \Spyc::YAMLDump( $job, false, false, true ) ); - return; - } - - $this->outputJobTable( $job ); - } - - /** - * Output job details in table format. - * - * @param array $job Job data. - */ - private function outputJobTable( array $job ): void { - $parsed_status = $this->parseCompoundStatus( $job['status'] ?? '' ); - $source = $job['source'] ?? 'pipeline'; - $is_system = ( 'system' === $source ); - - WP_CLI::log( sprintf( 'Job ID: %d', $job['job_id'] ?? 0 ) ); - - if ( $is_system ) { - WP_CLI::log( sprintf( 'Source: %s', $source ) ); - WP_CLI::log( sprintf( 'Label: %s', $job['label'] ?? $job['display_label'] ?? 'System Task' ) ); - } else { - WP_CLI::log( sprintf( 'Flow: %s (ID: %s)', $job['flow_name'] ?? 'N/A', $job['flow_id'] ?? 'N/A' ) ); - WP_CLI::log( sprintf( 'Pipeline ID: %s', $job['pipeline_id'] ?? 'N/A' ) ); - } - - WP_CLI::log( sprintf( 'Status: %s', $parsed_status['type'] ) ); - - if ( $parsed_status['reason'] ) { - WP_CLI::log( sprintf( 'Reason: %s', $parsed_status['reason'] ) ); - } - - // Display structured error details for failed jobs (persisted by #536). - if ( 'failed' === $parsed_status['type'] ) { - $engine_data = $job['engine_data'] ?? array(); - $error_message = $engine_data['error_message'] ?? null; - $error_step_id = $engine_data['error_step_id'] ?? null; - $error_trace = $engine_data['error_trace'] ?? null; - - if ( $error_message ) { - WP_CLI::log( '' ); - WP_CLI::log( WP_CLI::colorize( '%RError:%n ' . $error_message ) ); - - if ( $error_step_id ) { - WP_CLI::log( sprintf( ' Step: %s', $error_step_id ) ); - } - - if ( $error_trace ) { - WP_CLI::log( '' ); - WP_CLI::log( ' Stack Trace (truncated):' ); - $trace_lines = explode( "\n", $error_trace ); - foreach ( array_slice( $trace_lines, 0, 10 ) as $line ) { - WP_CLI::log( ' ' . $line ); - } - if ( count( $trace_lines ) > 10 ) { - WP_CLI::log( sprintf( ' ... (%d more lines, use --format=json for full trace)', count( $trace_lines ) - 10 ) ); - } - } - } - } - - WP_CLI::log( '' ); - WP_CLI::log( sprintf( 'Created: %s', $job['created_at_display'] ?? $job['created_at'] ?? 'N/A' ) ); - WP_CLI::log( sprintf( 'Completed: %s', $job['completed_at_display'] ?? $job['completed_at'] ?? '-' ) ); - - // Show Action Scheduler status for processing/pending jobs (#169). - if ( in_array( $parsed_status['type'], array( 'processing', 'pending' ), true ) ) { - $this->outputActionSchedulerStatus( (int) ( $job['job_id'] ?? 0 ) ); - } - - $engine_data = $job['engine_data'] ?? array(); - - // Strip error keys already displayed in the error section above. - unset( $engine_data['error_reason'], $engine_data['error_message'], $engine_data['error_step_id'], $engine_data['error_trace'] ); - - if ( ! empty( $engine_data ) ) { - WP_CLI::log( '' ); - WP_CLI::log( 'Engine Data:' ); - - $summary = $this->extractEngineDataSummary( $engine_data ); - $has_nested = false; - - foreach ( $summary as $key => $value ) { - WP_CLI::log( sprintf( ' %s: %s', $key, $value ) ); - if ( str_starts_with( $value, 'array (' ) ) { - $has_nested = true; - } - } - - if ( $has_nested ) { - WP_CLI::log( '' ); - WP_CLI::log( ' Use --format=json for full engine data.' ); - } - } - } - - /** - * Output Action Scheduler status for a job. - * - * Queries the Action Scheduler tables to find the latest action - * and its logs for the given job ID. Helps diagnose stuck jobs - * where the AS action may have failed or timed out. - * - * @param int $job_id Job ID to look up. - */ - private function outputActionSchedulerStatus( int $job_id ): void { - if ( $job_id <= 0 ) { - return; - } - - global $wpdb; - $actions_table = $wpdb->prefix . 'actionscheduler_actions'; - $logs_table = $wpdb->prefix . 'actionscheduler_logs'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $action = $wpdb->get_row( - $wpdb->prepare( - "SELECT action_id, status, scheduled_date_gmt, last_attempt_gmt - FROM %i - WHERE hook = 'datamachine_execute_step' - AND args LIKE %s - ORDER BY action_id DESC - LIMIT 1", - $actions_table, - '%"job_id":' . $job_id . '%' - ) - ); - - if ( ! $action ) { - return; - } - - WP_CLI::log( '' ); - WP_CLI::log( 'Action Scheduler:' ); - WP_CLI::log( sprintf( ' Action ID: %d', $action->action_id ) ); - WP_CLI::log( sprintf( ' AS Status: %s', $action->status ) ); - WP_CLI::log( sprintf( ' Scheduled: %s', $action->scheduled_date_gmt ) ); - WP_CLI::log( sprintf( ' Last Attempt: %s', $action->last_attempt_gmt ) ); - - // Get the latest log message (usually contains failure reason). - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $log = $wpdb->get_row( - $wpdb->prepare( - 'SELECT message, log_date_gmt - FROM %i - WHERE action_id = %d - ORDER BY log_id DESC - LIMIT 1', - $logs_table, - $action->action_id - ) - ); - - if ( $log && ! empty( $log->message ) ) { - WP_CLI::log( sprintf( ' Last Log: %s (%s)', $log->message, $log->log_date_gmt ) ); - } - } - - /** - * Parse compound status into type and reason. - * - * Handles formats like "agent_skipped - not a music event". - * - * @param string $status Raw status string. - * @return array With 'type' and 'reason' keys. - */ - private function parseCompoundStatus( string $status ): array { - if ( strpos( $status, ' - ' ) !== false ) { - $parts = explode( ' - ', $status, 2 ); - return array( - 'type' => trim( $parts[0] ), - 'reason' => trim( $parts[1] ), - ); - } - - return array( - 'type' => $status, - 'reason' => '', - ); - } - - /** - * Extract a summary of engine_data for CLI display. - * - * Iterates all top-level keys and formats each value by type: - * scalars display directly (strings truncated at 120 chars), - * arrays show item count and serialized size, bools/nulls display - * as literals. No hardcoded key list — works for any job type. - * - * @param array $engine_data Full engine data array. - * @return array Key-value pairs for display. - */ - private function extractEngineDataSummary( array $engine_data ): array { - $summary = array(); - - foreach ( $engine_data as $key => $value ) { - $label = ucwords( str_replace( '_', ' ', $key ) ); - - if ( is_array( $value ) ) { - $count = count( $value ); - $json = wp_json_encode( $value ); - $size = strlen( $json ); - $summary[ $label ] = sprintf( 'array (%d items, %s)', $count, size_format( $size ) ); - } elseif ( is_bool( $value ) ) { - $summary[ $label ] = $value ? 'true' : 'false'; - } elseif ( is_null( $value ) ) { - $summary[ $label ] = '(null)'; - } elseif ( is_string( $value ) && strlen( $value ) > 120 ) { - $summary[ $label ] = substr( $value, 0, 117 ) . '...'; - } else { - $summary[ $label ] = (string) $value; - } - } - - return $summary; - } - - /** - * Show job status summary grouped by status. - * - * ## OPTIONS - * - * [--format=] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * --- - * - * [--fields=] - * : Limit output to specific fields (comma-separated). - * - * ## EXAMPLES - * - * # Show status summary - * wp datamachine jobs summary - * - * # Output as CSV - * wp datamachine jobs summary --format=csv - * - * # JSON output - * wp datamachine jobs summary --format=json - * - * @subcommand summary - */ - public function summary( array $args, array $assoc_args ): void { - $result = $this->abilities->executeGetJobsSummary( array() ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - $summary = $result['summary'] ?? array(); - - if ( empty( $summary ) ) { - WP_CLI::warning( 'No job summary data available.' ); - return; - } - - // Transform summary to row format. - $items = array(); - foreach ( $summary as $status => $count ) { - $items[] = array( - 'status' => $status, - 'count' => $count, - ); - } - - $this->format_items( $items, array( 'status', 'count' ), $assoc_args ); - } - - /** - * Manually fail a processing job. - * - * ## OPTIONS - * - * - * : The job ID to fail. - * - * [--reason=] - * : Reason for failure. - * --- - * default: manual - * --- - * - * ## EXAMPLES - * - * # Fail a stuck job - * wp datamachine jobs fail 844 - * - * # Fail with a reason - * wp datamachine jobs fail 844 --reason="timeout" - * - * @subcommand fail - */ - public function fail( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) || ! is_numeric( $args[0] ) || (int) $args[0] <= 0 ) { - WP_CLI::error( 'Job ID is required and must be a positive integer.' ); - return; - } - - $result = $this->abilities->executeFailJob( - array( - 'job_id' => (int) $args[0], - 'reason' => $assoc_args['reason'] ?? 'manual', - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - WP_CLI::success( $result['message'] ); - } - - /** - * Retry a failed or stuck job. - * - * Marks the job as failed and optionally requeues its prompt - * if a queued_prompt_backup exists in engine_data. - * - * ## OPTIONS - * - * - * : The job ID to retry. - * - * [--force] - * : Allow retrying any status, not just failed/processing. - * - * ## EXAMPLES - * - * # Retry a failed job - * wp datamachine jobs retry 844 - * - * # Force retry a completed job - * wp datamachine jobs retry 844 --force - * - * @subcommand retry - */ - public function retry( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) || ! is_numeric( $args[0] ) || (int) $args[0] <= 0 ) { - WP_CLI::error( 'Job ID is required and must be a positive integer.' ); - return; - } - - $result = $this->abilities->executeRetryJob( - array( - 'job_id' => (int) $args[0], - 'force' => isset( $assoc_args['force'] ), - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); - return; - } - - WP_CLI::success( $result['message'] ); - - if ( ! empty( $result['prompt_requeued'] ) ) { - WP_CLI::log( 'Prompt was requeued to the flow.' ); - } - } - - /** - * Delete jobs by type. - * - * Removes job records from the database. Supports deleting all jobs - * or only failed jobs. Optionally cleans up processed items tracking - * for the deleted jobs. - * - * ## OPTIONS - * - * [--type=] - * : Which jobs to delete. - * --- - * default: failed - * options: - * - all - * - failed - * --- - * - * [--cleanup-processed] - * : Also clear processed items tracking for deleted jobs. - * - * [--yes] - * : Skip confirmation prompt. - * - * ## EXAMPLES - * - * # Delete failed jobs - * wp datamachine jobs delete - * - * # Delete all jobs - * wp datamachine jobs delete --type=all - * - * # Delete failed jobs and cleanup processed items - * wp datamachine jobs delete --cleanup-processed - * - * # Delete all jobs without confirmation - * wp datamachine jobs delete --type=all --yes - * - * @subcommand delete - */ - public function delete( array $args, array $assoc_args ): void { - $type = $assoc_args['type'] ?? 'failed'; - $cleanup_processed = isset( $assoc_args['cleanup-processed'] ); - $skip_confirm = isset( $assoc_args['yes'] ); - - if ( ! in_array( $type, array( 'all', 'failed' ), true ) ) { - WP_CLI::error( 'type must be "all" or "failed"' ); - return; - } - - // Require confirmation for destructive operations. - if ( ! $skip_confirm ) { - $message = 'all' === $type - ? 'Delete ALL jobs? This cannot be undone.' - : 'Delete all FAILED jobs?'; - - if ( $cleanup_processed ) { - $message .= ' Processed items tracking will also be cleared.'; - } - - WP_CLI::confirm( $message ); - } - - $result = $this->abilities->executeDeleteJobs( - array( - 'type' => $type, - 'cleanup_processed' => $cleanup_processed, - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['error'] ?? 'Failed to delete jobs' ); - return; - } - - WP_CLI::success( $result['message'] ); - - if ( $cleanup_processed && ( $result['processed_items_cleaned'] ?? 0 ) > 0 ) { - WP_CLI::log( sprintf( 'Processed items cleaned: %d', $result['processed_items_cleaned'] ) ); - } - } - - /** - * Cleanup old jobs by status and age. - * - * Removes jobs matching a status that are older than a specified age. - * Useful for keeping the jobs table clean by purging stale failures, - * completed jobs, or other terminal statuses. - * - * ## OPTIONS - * - * [--older-than=] - * : Delete jobs older than this duration. Accepts days (e.g., 30d), - * weeks (e.g., 4w), or hours (e.g., 72h). - * --- - * default: 30d - * --- - * - * [--status=] - * : Which job status to clean up. Uses prefix matching to catch - * compound statuses (e.g., "failed" matches "failed - timeout"). - * --- - * default: failed - * --- - * - * [--dry-run] - * : Show what would be deleted without making changes. - * - * [--yes] - * : Skip confirmation prompt. - * - * ## EXAMPLES - * - * # Preview cleanup of failed jobs older than 30 days - * wp datamachine jobs cleanup --dry-run - * - * # Delete failed jobs older than 30 days - * wp datamachine jobs cleanup --yes - * - * # Delete failed jobs older than 2 weeks - * wp datamachine jobs cleanup --older-than=2w --yes - * - * # Delete completed jobs older than 90 days - * wp datamachine jobs cleanup --status=completed --older-than=90d --yes - * - * # Delete agent_skipped jobs older than 1 week - * wp datamachine jobs cleanup --status=agent_skipped --older-than=1w - * - * @subcommand cleanup - */ - public function cleanup( array $args, array $assoc_args ): void { - $duration_str = $assoc_args['older-than'] ?? '30d'; - $status = $assoc_args['status'] ?? 'failed'; - $dry_run = isset( $assoc_args['dry-run'] ); - $skip_confirm = isset( $assoc_args['yes'] ); - - $days = $this->parseDurationToDays( $duration_str ); - if ( null === $days ) { - WP_CLI::error( sprintf( 'Invalid duration format: "%s". Use format like 30d, 4w, or 72h.', $duration_str ) ); - return; - } - - $db_jobs = new Jobs(); - $count = $db_jobs->count_old_jobs( $status, $days ); - - if ( 0 === $count ) { - WP_CLI::success( sprintf( 'No "%s" jobs older than %s found. Nothing to clean up.', $status, $duration_str ) ); - return; - } - - WP_CLI::log( sprintf( 'Found %d "%s" job(s) older than %s (%d days).', $count, $status, $duration_str, $days ) ); - - if ( $dry_run ) { - WP_CLI::success( sprintf( 'Dry run: %d job(s) would be deleted.', $count ) ); - return; - } - - if ( ! $skip_confirm ) { - WP_CLI::confirm( sprintf( 'Delete %d "%s" job(s) older than %s?', $count, $status, $duration_str ) ); - } - - $deleted = $db_jobs->delete_old_jobs( $status, $days ); - - if ( false === $deleted ) { - WP_CLI::error( 'Failed to delete jobs.' ); - return; - } - - WP_CLI::success( sprintf( 'Deleted %d "%s" job(s) older than %s.', $deleted, $status, $duration_str ) ); - } - - /** - * Parse a human-readable duration string to days. - * - * Supports formats: 30d (days), 4w (weeks), 72h (hours). - * - * @param string $duration Duration string. - * @return int|null Number of days, or null if invalid. - */ - private function parseDurationToDays( string $duration ): ?int { - if ( ! preg_match( '/^(\d+)(d|w|h)$/i', trim( $duration ), $matches ) ) { - return null; - } - - $value = (int) $matches[1]; - $unit = strtolower( $matches[2] ); - - if ( $value <= 0 ) { - return null; - } - - return match ( $unit ) { - 'd' => $value, - 'w' => $value * 7, - 'h' => max( 1, (int) ceil( $value / 24 ) ), - default => null, - }; - } - - /** - * Undo a completed job by reversing its recorded effects. - * - * Reads the standardized effects array from the job's engine_data and - * reverses each effect (restore content revision, delete meta, remove - * attachments, etc.). Only works on jobs whose task type supports undo. - * - * ## OPTIONS - * - * [] - * : Specific job ID to undo. - * - * [--task-type=] - * : Undo all completed jobs of this task type (e.g. internal_linking). - * - * [--dry-run] - * : Preview what would be undone without making changes. - * - * [--force] - * : Re-undo a job even if it was already undone. - * - * ## EXAMPLES - * - * # Undo a single job - * wp datamachine jobs undo 1632 - * - * # Preview batch undo of all internal linking jobs - * wp datamachine jobs undo --task-type=internal_linking --dry-run - * - * # Batch undo all internal linking jobs - * wp datamachine jobs undo --task-type=internal_linking - * - * @subcommand undo - */ - public function undo( array $args, array $assoc_args ): void { - $job_id = ! empty( $args[0] ) && is_numeric( $args[0] ) ? (int) $args[0] : 0; - $task_type = $assoc_args['task-type'] ?? ''; - $dry_run = isset( $assoc_args['dry-run'] ); - $force = isset( $assoc_args['force'] ); - - if ( $job_id <= 0 && empty( $task_type ) ) { - WP_CLI::error( 'Provide a job ID or --task-type to undo.' ); - return; - } - - // Resolve jobs to undo. - $jobs_db = new Jobs(); - $jobs = array(); - - if ( $job_id > 0 ) { - $job = $jobs_db->get_job( $job_id ); - if ( ! $job ) { - WP_CLI::error( "Job #{$job_id} not found." ); - return; - } - $jobs[] = $job; - } else { - $jobs = $this->findJobsByTaskType( $jobs_db, $task_type ); - if ( empty( $jobs ) ) { - WP_CLI::warning( "No completed jobs found for task type '{$task_type}'." ); - return; - } - WP_CLI::log( sprintf( 'Found %d completed %s job(s).', count( $jobs ), $task_type ) ); - } - - // Resolve task handlers. - $handlers = TaskRegistry::getHandlers(); - - $total_reverted = 0; - $total_skipped = 0; - $total_failed = 0; - - foreach ( $jobs as $job ) { - $jid = $job['job_id'] ?? 0; - $engine_data = $job['engine_data'] ?? array(); - $jtype = $engine_data['task_type'] ?? ''; - - // Check if already undone. - if ( ! $force && ! empty( $engine_data['undo'] ) ) { - WP_CLI::log( sprintf( ' Job #%d: already undone (use --force to re-undo).', $jid ) ); - ++$total_skipped; - continue; - } - - // Check task supports undo. - if ( ! isset( $handlers[ $jtype ] ) ) { - WP_CLI::warning( sprintf( 'Job #%d: unknown task type "%s".', $jid, $jtype ) ); - ++$total_skipped; - continue; - } - - $task = new $handlers[ $jtype ](); - - if ( ! $task->supportsUndo() ) { - WP_CLI::log( sprintf( ' Job #%d: task type "%s" does not support undo.', $jid, $jtype ) ); - ++$total_skipped; - continue; - } - - $effects = $engine_data['effects'] ?? array(); - if ( empty( $effects ) ) { - WP_CLI::log( sprintf( ' Job #%d: no effects recorded.', $jid ) ); - ++$total_skipped; - continue; - } - - // Dry run — just describe what would happen. - if ( $dry_run ) { - WP_CLI::log( sprintf( ' Job #%d (%s): would undo %d effect(s):', $jid, $jtype, count( $effects ) ) ); - foreach ( $effects as $effect ) { - $type = $effect['type'] ?? 'unknown'; - $target = $effect['target'] ?? array(); - WP_CLI::log( sprintf( ' - %s → %s', $type, wp_json_encode( $target ) ) ); - } - continue; - } - - // Execute undo. - WP_CLI::log( sprintf( ' Job #%d (%s): undoing %d effect(s)...', $jid, $jtype, count( $effects ) ) ); - $result = $task->undo( $jid, $engine_data ); - - foreach ( $result['reverted'] as $r ) { - WP_CLI::log( sprintf( ' ✓ %s reverted', $r['type'] ) ); - } - foreach ( $result['skipped'] as $s ) { - WP_CLI::log( sprintf( ' - %s skipped: %s', $s['type'], $s['reason'] ?? '' ) ); - } - foreach ( $result['failed'] as $f ) { - WP_CLI::warning( sprintf( ' ✗ %s failed: %s', $f['type'], $f['reason'] ?? '' ) ); - } - - $total_reverted += count( $result['reverted'] ); - $total_skipped += count( $result['skipped'] ); - $total_failed += count( $result['failed'] ); - - // Record undo metadata in engine_data. - $engine_data['undo'] = array( - 'undone_at' => current_time( 'mysql' ), - 'effects_reverted' => count( $result['reverted'] ), - 'effects_skipped' => count( $result['skipped'] ), - 'effects_failed' => count( $result['failed'] ), - ); - $jobs_db->store_engine_data( $jid, $engine_data ); - } - - if ( $dry_run ) { - WP_CLI::success( sprintf( 'Dry run complete. %d job(s) would be undone.', count( $jobs ) ) ); - return; - } - - WP_CLI::success( sprintf( - 'Undo complete: %d effect(s) reverted, %d skipped, %d failed.', - $total_reverted, - $total_skipped, - $total_failed - ) ); - } - - /** - * Find completed jobs by task type. - * - * @param Jobs $jobs_db Jobs database instance. - * @param string $task_type Task type to filter by. - * @return array Array of job records. - */ - private function findJobsByTaskType( Jobs $jobs_db, string $task_type ): array { - global $wpdb; - $table = $wpdb->prefix . 'datamachine_jobs'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT job_id FROM {$table} - WHERE status LIKE %s - AND engine_data LIKE %s - ORDER BY job_id DESC", - 'completed%', - '%"task_type":"' . $wpdb->esc_like( $task_type ) . '"%' - ) - ); - // phpcs:enable WordPress.DB.PreparedSQL - - if ( empty( $rows ) ) { - return array(); - } - - $jobs = array(); - foreach ( $rows as $row ) { - $job = $jobs_db->get_job( (int) $row->job_id ); - if ( $job ) { - $jobs[] = $job; - } - } - - return $jobs; - } } diff --git a/inc/Cli/Commands/JobsCommand/delete.php b/inc/Cli/Commands/JobsCommand/delete.php new file mode 100644 index 000000000..81c37c349 --- /dev/null +++ b/inc/Cli/Commands/JobsCommand/delete.php @@ -0,0 +1,399 @@ +//! delete — extracted from JobsCommand.php. + + + /** + * Delete jobs by type. + * + * Removes job records from the database. Supports deleting all jobs + * or only failed jobs. Optionally cleans up processed items tracking + * for the deleted jobs. + * + * ## OPTIONS + * + * [--type=] + * : Which jobs to delete. + * --- + * default: failed + * options: + * - all + * - failed + * --- + * + * [--cleanup-processed] + * : Also clear processed items tracking for deleted jobs. + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * # Delete failed jobs + * wp datamachine jobs delete + * + * # Delete all jobs + * wp datamachine jobs delete --type=all + * + * # Delete failed jobs and cleanup processed items + * wp datamachine jobs delete --cleanup-processed + * + * # Delete all jobs without confirmation + * wp datamachine jobs delete --type=all --yes + * + * @subcommand delete + */ + public function delete( array $args, array $assoc_args ): void { + $type = $assoc_args['type'] ?? 'failed'; + $cleanup_processed = isset( $assoc_args['cleanup-processed'] ); + $skip_confirm = isset( $assoc_args['yes'] ); + + if ( ! in_array( $type, array( 'all', 'failed' ), true ) ) { + WP_CLI::error( 'type must be "all" or "failed"' ); + return; + } + + // Require confirmation for destructive operations. + if ( ! $skip_confirm ) { + $message = 'all' === $type + ? 'Delete ALL jobs? This cannot be undone.' + : 'Delete all FAILED jobs?'; + + if ( $cleanup_processed ) { + $message .= ' Processed items tracking will also be cleared.'; + } + + WP_CLI::confirm( $message ); + } + + $result = $this->abilities->executeDeleteJobs( + array( + 'type' => $type, + 'cleanup_processed' => $cleanup_processed, + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Failed to delete jobs' ); + return; + } + + WP_CLI::success( $result['message'] ); + + if ( $cleanup_processed && ( $result['processed_items_cleaned'] ?? 0 ) > 0 ) { + WP_CLI::log( sprintf( 'Processed items cleaned: %d', $result['processed_items_cleaned'] ) ); + } + } + + /** + * Cleanup old jobs by status and age. + * + * Removes jobs matching a status that are older than a specified age. + * Useful for keeping the jobs table clean by purging stale failures, + * completed jobs, or other terminal statuses. + * + * ## OPTIONS + * + * [--older-than=] + * : Delete jobs older than this duration. Accepts days (e.g., 30d), + * weeks (e.g., 4w), or hours (e.g., 72h). + * --- + * default: 30d + * --- + * + * [--status=] + * : Which job status to clean up. Uses prefix matching to catch + * compound statuses (e.g., "failed" matches "failed - timeout"). + * --- + * default: failed + * --- + * + * [--dry-run] + * : Show what would be deleted without making changes. + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * # Preview cleanup of failed jobs older than 30 days + * wp datamachine jobs cleanup --dry-run + * + * # Delete failed jobs older than 30 days + * wp datamachine jobs cleanup --yes + * + * # Delete failed jobs older than 2 weeks + * wp datamachine jobs cleanup --older-than=2w --yes + * + * # Delete completed jobs older than 90 days + * wp datamachine jobs cleanup --status=completed --older-than=90d --yes + * + * # Delete agent_skipped jobs older than 1 week + * wp datamachine jobs cleanup --status=agent_skipped --older-than=1w + * + * @subcommand cleanup + */ + public function cleanup( array $args, array $assoc_args ): void { + $duration_str = $assoc_args['older-than'] ?? '30d'; + $status = $assoc_args['status'] ?? 'failed'; + $dry_run = isset( $assoc_args['dry-run'] ); + $skip_confirm = isset( $assoc_args['yes'] ); + + $days = $this->parseDurationToDays( $duration_str ); + if ( null === $days ) { + WP_CLI::error( sprintf( 'Invalid duration format: "%s". Use format like 30d, 4w, or 72h.', $duration_str ) ); + return; + } + + $db_jobs = new Jobs(); + $count = $db_jobs->count_old_jobs( $status, $days ); + + if ( 0 === $count ) { + WP_CLI::success( sprintf( 'No "%s" jobs older than %s found. Nothing to clean up.', $status, $duration_str ) ); + return; + } + + WP_CLI::log( sprintf( 'Found %d "%s" job(s) older than %s (%d days).', $count, $status, $duration_str, $days ) ); + + if ( $dry_run ) { + WP_CLI::success( sprintf( 'Dry run: %d job(s) would be deleted.', $count ) ); + return; + } + + if ( ! $skip_confirm ) { + WP_CLI::confirm( sprintf( 'Delete %d "%s" job(s) older than %s?', $count, $status, $duration_str ) ); + } + + $deleted = $db_jobs->delete_old_jobs( $status, $days ); + + if ( false === $deleted ) { + WP_CLI::error( 'Failed to delete jobs.' ); + return; + } + + WP_CLI::success( sprintf( 'Deleted %d "%s" job(s) older than %s.', $deleted, $status, $duration_str ) ); + } + + /** + * Parse a human-readable duration string to days. + * + * Supports formats: 30d (days), 4w (weeks), 72h (hours). + * + * @param string $duration Duration string. + * @return int|null Number of days, or null if invalid. + */ + private function parseDurationToDays( string $duration ): ?int { + if ( ! preg_match( '/^(\d+)(d|w|h)$/i', trim( $duration ), $matches ) ) { + return null; + } + + $value = (int) $matches[1]; + $unit = strtolower( $matches[2] ); + + if ( $value <= 0 ) { + return null; + } + + return match ( $unit ) { + 'd' => $value, + 'w' => $value * 7, + 'h' => max( 1, (int) ceil( $value / 24 ) ), + default => null, + }; + } + + /** + * Undo a completed job by reversing its recorded effects. + * + * Reads the standardized effects array from the job's engine_data and + * reverses each effect (restore content revision, delete meta, remove + * attachments, etc.). Only works on jobs whose task type supports undo. + * + * ## OPTIONS + * + * [] + * : Specific job ID to undo. + * + * [--task-type=] + * : Undo all completed jobs of this task type (e.g. internal_linking). + * + * [--dry-run] + * : Preview what would be undone without making changes. + * + * [--force] + * : Re-undo a job even if it was already undone. + * + * ## EXAMPLES + * + * # Undo a single job + * wp datamachine jobs undo 1632 + * + * # Preview batch undo of all internal linking jobs + * wp datamachine jobs undo --task-type=internal_linking --dry-run + * + * # Batch undo all internal linking jobs + * wp datamachine jobs undo --task-type=internal_linking + * + * @subcommand undo + */ + public function undo( array $args, array $assoc_args ): void { + $job_id = ! empty( $args[0] ) && is_numeric( $args[0] ) ? (int) $args[0] : 0; + $task_type = $assoc_args['task-type'] ?? ''; + $dry_run = isset( $assoc_args['dry-run'] ); + $force = isset( $assoc_args['force'] ); + + if ( $job_id <= 0 && empty( $task_type ) ) { + WP_CLI::error( 'Provide a job ID or --task-type to undo.' ); + return; + } + + // Resolve jobs to undo. + $jobs_db = new Jobs(); + $jobs = array(); + + if ( $job_id > 0 ) { + $job = $jobs_db->get_job( $job_id ); + if ( ! $job ) { + WP_CLI::error( "Job #{$job_id} not found." ); + return; + } + $jobs[] = $job; + } else { + $jobs = $this->findJobsByTaskType( $jobs_db, $task_type ); + if ( empty( $jobs ) ) { + WP_CLI::warning( "No completed jobs found for task type '{$task_type}'." ); + return; + } + WP_CLI::log( sprintf( 'Found %d completed %s job(s).', count( $jobs ), $task_type ) ); + } + + // Resolve task handlers. + $handlers = TaskRegistry::getHandlers(); + + $total_reverted = 0; + $total_skipped = 0; + $total_failed = 0; + + foreach ( $jobs as $job ) { + $jid = $job['job_id'] ?? 0; + $engine_data = $job['engine_data'] ?? array(); + $jtype = $engine_data['task_type'] ?? ''; + + // Check if already undone. + if ( ! $force && ! empty( $engine_data['undo'] ) ) { + WP_CLI::log( sprintf( ' Job #%d: already undone (use --force to re-undo).', $jid ) ); + ++$total_skipped; + continue; + } + + // Check task supports undo. + if ( ! isset( $handlers[ $jtype ] ) ) { + WP_CLI::warning( sprintf( 'Job #%d: unknown task type "%s".', $jid, $jtype ) ); + ++$total_skipped; + continue; + } + + $task = new $handlers[ $jtype ](); + + if ( ! $task->supportsUndo() ) { + WP_CLI::log( sprintf( ' Job #%d: task type "%s" does not support undo.', $jid, $jtype ) ); + ++$total_skipped; + continue; + } + + $effects = $engine_data['effects'] ?? array(); + if ( empty( $effects ) ) { + WP_CLI::log( sprintf( ' Job #%d: no effects recorded.', $jid ) ); + ++$total_skipped; + continue; + } + + // Dry run — just describe what would happen. + if ( $dry_run ) { + WP_CLI::log( sprintf( ' Job #%d (%s): would undo %d effect(s):', $jid, $jtype, count( $effects ) ) ); + foreach ( $effects as $effect ) { + $type = $effect['type'] ?? 'unknown'; + $target = $effect['target'] ?? array(); + WP_CLI::log( sprintf( ' - %s → %s', $type, wp_json_encode( $target ) ) ); + } + continue; + } + + // Execute undo. + WP_CLI::log( sprintf( ' Job #%d (%s): undoing %d effect(s)...', $jid, $jtype, count( $effects ) ) ); + $result = $task->undo( $jid, $engine_data ); + + foreach ( $result['reverted'] as $r ) { + WP_CLI::log( sprintf( ' ✓ %s reverted', $r['type'] ) ); + } + foreach ( $result['skipped'] as $s ) { + WP_CLI::log( sprintf( ' - %s skipped: %s', $s['type'], $s['reason'] ?? '' ) ); + } + foreach ( $result['failed'] as $f ) { + WP_CLI::warning( sprintf( ' ✗ %s failed: %s', $f['type'], $f['reason'] ?? '' ) ); + } + + $total_reverted += count( $result['reverted'] ); + $total_skipped += count( $result['skipped'] ); + $total_failed += count( $result['failed'] ); + + // Record undo metadata in engine_data. + $engine_data['undo'] = array( + 'undone_at' => current_time( 'mysql' ), + 'effects_reverted' => count( $result['reverted'] ), + 'effects_skipped' => count( $result['skipped'] ), + 'effects_failed' => count( $result['failed'] ), + ); + $jobs_db->store_engine_data( $jid, $engine_data ); + } + + if ( $dry_run ) { + WP_CLI::success( sprintf( 'Dry run complete. %d job(s) would be undone.', count( $jobs ) ) ); + return; + } + + WP_CLI::success( sprintf( + 'Undo complete: %d effect(s) reverted, %d skipped, %d failed.', + $total_reverted, + $total_skipped, + $total_failed + ) ); + } + + /** + * Find completed jobs by task type. + * + * @param Jobs $jobs_db Jobs database instance. + * @param string $task_type Task type to filter by. + * @return array Array of job records. + */ + private function findJobsByTaskType( Jobs $jobs_db, string $task_type ): array { + global $wpdb; + $table = $wpdb->prefix . 'datamachine_jobs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT job_id FROM {$table} + WHERE status LIKE %s + AND engine_data LIKE %s + ORDER BY job_id DESC", + 'completed%', + '%"task_type":"' . $wpdb->esc_like( $task_type ) . '"%' + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL + + if ( empty( $rows ) ) { + return array(); + } + + $jobs = array(); + foreach ( $rows as $row ) { + $job = $jobs_db->get_job( (int) $row->job_id ); + if ( $job ) { + $jobs[] = $job; + } + } + + return $jobs; + } diff --git a/inc/Cli/Commands/JobsCommand/helpers.php b/inc/Cli/Commands/JobsCommand/helpers.php new file mode 100644 index 000000000..9d8760015 --- /dev/null +++ b/inc/Cli/Commands/JobsCommand/helpers.php @@ -0,0 +1,373 @@ +//! helpers — extracted from JobsCommand.php. + + + public function __construct() { + $this->abilities = new JobAbilities(); + } + + /** + * Recover stuck jobs that have job_status in engine_data but status is 'processing'. + * + * Jobs can become stuck when the engine stores a status override (e.g., from skip_item) + * in engine_data but the main status column doesn't get updated. This command finds + * those jobs and completes them with their intended final status. + * + * Also recovers jobs that have been processing for longer than the timeout threshold + * without a status override, marking them as failed and potentially requeuing prompts. + * + * ## OPTIONS + * + * [--dry-run] + * : Show what would be updated without making changes. + * + * [--flow=] + * : Only recover jobs for a specific flow ID. + * + * [--timeout=] + * : Hours before a processing job without status override is considered timed out. + * --- + * default: 2 + * --- + * + * ## EXAMPLES + * + * # Preview stuck jobs recovery + * wp datamachine jobs recover-stuck --dry-run + * + * # Recover all stuck jobs + * wp datamachine jobs recover-stuck + * + * # Recover stuck jobs for a specific flow + * wp datamachine jobs recover-stuck --flow=98 + * + * # Recover stuck jobs with custom timeout + * wp datamachine jobs recover-stuck --timeout=4 + * + * @subcommand recover-stuck + */ + public function recover_stuck( array $args, array $assoc_args ): void { + $dry_run = isset( $assoc_args['dry-run'] ); + $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; + $timeout = isset( $assoc_args['timeout'] ) ? max( 1, (int) $assoc_args['timeout'] ) : 2; + + $result = $this->abilities->executeRecoverStuckJobs( + array( + 'dry_run' => $dry_run, + 'flow_id' => $flow_id, + 'timeout_hours' => $timeout, + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + $jobs = $result['jobs'] ?? array(); + + if ( empty( $jobs ) ) { + WP_CLI::success( 'No stuck jobs found.' ); + return; + } + + WP_CLI::log( sprintf( 'Found %d stuck jobs with job_status in engine_data.', count( $jobs ) ) ); + + if ( $dry_run ) { + WP_CLI::log( 'Dry run - no changes will be made.' ); + WP_CLI::log( '' ); + } + + foreach ( $jobs as $job ) { + if ( 'skipped' === $job['status'] ) { + WP_CLI::warning( sprintf( 'Job %d: %s', $job['job_id'], $job['reason'] ?? 'Unknown reason' ) ); + } elseif ( 'would_recover' === $job['status'] ) { + $display_status = strlen( $job['target_status'] ) > 60 ? substr( $job['target_status'], 0, 60 ) . '...' : $job['target_status']; + WP_CLI::log( + sprintf( + 'Would update job %d (flow %d) to: %s', + $job['job_id'], + $job['flow_id'], + $display_status + ) + ); + } elseif ( 'recovered' === $job['status'] ) { + $display_status = strlen( $job['target_status'] ) > 60 ? substr( $job['target_status'], 0, 60 ) . '...' : $job['target_status']; + WP_CLI::log( sprintf( 'Updated job %d to: %s', $job['job_id'], $display_status ) ); + } elseif ( 'would_timeout' === $job['status'] ) { + WP_CLI::log( sprintf( 'Would timeout job %d (flow %d)', $job['job_id'], $job['flow_id'] ) ); + } elseif ( 'timed_out' === $job['status'] ) { + WP_CLI::log( sprintf( 'Timed out job %d (flow %d)', $job['job_id'], $job['flow_id'] ) ); + } + } + + WP_CLI::success( $result['message'] ); + } + + /** + * Output job details in table format. + * + * @param array $job Job data. + */ + private function outputJobTable( array $job ): void { + $parsed_status = $this->parseCompoundStatus( $job['status'] ?? '' ); + $source = $job['source'] ?? 'pipeline'; + $is_system = ( 'system' === $source ); + + WP_CLI::log( sprintf( 'Job ID: %d', $job['job_id'] ?? 0 ) ); + + if ( $is_system ) { + WP_CLI::log( sprintf( 'Source: %s', $source ) ); + WP_CLI::log( sprintf( 'Label: %s', $job['label'] ?? $job['display_label'] ?? 'System Task' ) ); + } else { + WP_CLI::log( sprintf( 'Flow: %s (ID: %s)', $job['flow_name'] ?? 'N/A', $job['flow_id'] ?? 'N/A' ) ); + WP_CLI::log( sprintf( 'Pipeline ID: %s', $job['pipeline_id'] ?? 'N/A' ) ); + } + + WP_CLI::log( sprintf( 'Status: %s', $parsed_status['type'] ) ); + + if ( $parsed_status['reason'] ) { + WP_CLI::log( sprintf( 'Reason: %s', $parsed_status['reason'] ) ); + } + + // Display structured error details for failed jobs (persisted by #536). + if ( 'failed' === $parsed_status['type'] ) { + $engine_data = $job['engine_data'] ?? array(); + $error_message = $engine_data['error_message'] ?? null; + $error_step_id = $engine_data['error_step_id'] ?? null; + $error_trace = $engine_data['error_trace'] ?? null; + + if ( $error_message ) { + WP_CLI::log( '' ); + WP_CLI::log( WP_CLI::colorize( '%RError:%n ' . $error_message ) ); + + if ( $error_step_id ) { + WP_CLI::log( sprintf( ' Step: %s', $error_step_id ) ); + } + + if ( $error_trace ) { + WP_CLI::log( '' ); + WP_CLI::log( ' Stack Trace (truncated):' ); + $trace_lines = explode( "\n", $error_trace ); + foreach ( array_slice( $trace_lines, 0, 10 ) as $line ) { + WP_CLI::log( ' ' . $line ); + } + if ( count( $trace_lines ) > 10 ) { + WP_CLI::log( sprintf( ' ... (%d more lines, use --format=json for full trace)', count( $trace_lines ) - 10 ) ); + } + } + } + } + + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Created: %s', $job['created_at_display'] ?? $job['created_at'] ?? 'N/A' ) ); + WP_CLI::log( sprintf( 'Completed: %s', $job['completed_at_display'] ?? $job['completed_at'] ?? '-' ) ); + + // Show Action Scheduler status for processing/pending jobs (#169). + if ( in_array( $parsed_status['type'], array( 'processing', 'pending' ), true ) ) { + $this->outputActionSchedulerStatus( (int) ( $job['job_id'] ?? 0 ) ); + } + + $engine_data = $job['engine_data'] ?? array(); + + // Strip error keys already displayed in the error section above. + unset( $engine_data['error_reason'], $engine_data['error_message'], $engine_data['error_step_id'], $engine_data['error_trace'] ); + + if ( ! empty( $engine_data ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Engine Data:' ); + + $summary = $this->extractEngineDataSummary( $engine_data ); + $has_nested = false; + + foreach ( $summary as $key => $value ) { + WP_CLI::log( sprintf( ' %s: %s', $key, $value ) ); + if ( str_starts_with( $value, 'array (' ) ) { + $has_nested = true; + } + } + + if ( $has_nested ) { + WP_CLI::log( '' ); + WP_CLI::log( ' Use --format=json for full engine data.' ); + } + } + } + + /** + * Output Action Scheduler status for a job. + * + * Queries the Action Scheduler tables to find the latest action + * and its logs for the given job ID. Helps diagnose stuck jobs + * where the AS action may have failed or timed out. + * + * @param int $job_id Job ID to look up. + */ + private function outputActionSchedulerStatus( int $job_id ): void { + if ( $job_id <= 0 ) { + return; + } + + global $wpdb; + $actions_table = $wpdb->prefix . 'actionscheduler_actions'; + $logs_table = $wpdb->prefix . 'actionscheduler_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $action = $wpdb->get_row( + $wpdb->prepare( + "SELECT action_id, status, scheduled_date_gmt, last_attempt_gmt + FROM %i + WHERE hook = 'datamachine_execute_step' + AND args LIKE %s + ORDER BY action_id DESC + LIMIT 1", + $actions_table, + '%"job_id":' . $job_id . '%' + ) + ); + + if ( ! $action ) { + return; + } + + WP_CLI::log( '' ); + WP_CLI::log( 'Action Scheduler:' ); + WP_CLI::log( sprintf( ' Action ID: %d', $action->action_id ) ); + WP_CLI::log( sprintf( ' AS Status: %s', $action->status ) ); + WP_CLI::log( sprintf( ' Scheduled: %s', $action->scheduled_date_gmt ) ); + WP_CLI::log( sprintf( ' Last Attempt: %s', $action->last_attempt_gmt ) ); + + // Get the latest log message (usually contains failure reason). + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $log = $wpdb->get_row( + $wpdb->prepare( + 'SELECT message, log_date_gmt + FROM %i + WHERE action_id = %d + ORDER BY log_id DESC + LIMIT 1', + $logs_table, + $action->action_id + ) + ); + + if ( $log && ! empty( $log->message ) ) { + WP_CLI::log( sprintf( ' Last Log: %s (%s)', $log->message, $log->log_date_gmt ) ); + } + } + + /** + * Parse compound status into type and reason. + * + * Handles formats like "agent_skipped - not a music event". + * + * @param string $status Raw status string. + * @return array With 'type' and 'reason' keys. + */ + private function parseCompoundStatus( string $status ): array { + if ( strpos( $status, ' - ' ) !== false ) { + $parts = explode( ' - ', $status, 2 ); + return array( + 'type' => trim( $parts[0] ), + 'reason' => trim( $parts[1] ), + ); + } + + return array( + 'type' => $status, + 'reason' => '', + ); + } + + /** + * Manually fail a processing job. + * + * ## OPTIONS + * + * + * : The job ID to fail. + * + * [--reason=] + * : Reason for failure. + * --- + * default: manual + * --- + * + * ## EXAMPLES + * + * # Fail a stuck job + * wp datamachine jobs fail 844 + * + * # Fail with a reason + * wp datamachine jobs fail 844 --reason="timeout" + * + * @subcommand fail + */ + public function fail( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) || ! is_numeric( $args[0] ) || (int) $args[0] <= 0 ) { + WP_CLI::error( 'Job ID is required and must be a positive integer.' ); + return; + } + + $result = $this->abilities->executeFailJob( + array( + 'job_id' => (int) $args[0], + 'reason' => $assoc_args['reason'] ?? 'manual', + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + WP_CLI::success( $result['message'] ); + } + + /** + * Retry a failed or stuck job. + * + * Marks the job as failed and optionally requeues its prompt + * if a queued_prompt_backup exists in engine_data. + * + * ## OPTIONS + * + * + * : The job ID to retry. + * + * [--force] + * : Allow retrying any status, not just failed/processing. + * + * ## EXAMPLES + * + * # Retry a failed job + * wp datamachine jobs retry 844 + * + * # Force retry a completed job + * wp datamachine jobs retry 844 --force + * + * @subcommand retry + */ + public function retry( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) || ! is_numeric( $args[0] ) || (int) $args[0] <= 0 ) { + WP_CLI::error( 'Job ID is required and must be a positive integer.' ); + return; + } + + $result = $this->abilities->executeRetryJob( + array( + 'job_id' => (int) $args[0], + 'force' => isset( $assoc_args['force'] ), + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + WP_CLI::success( $result['message'] ); + + if ( ! empty( $result['prompt_requeued'] ) ) { + WP_CLI::log( 'Prompt was requeued to the flow.' ); + } + } diff --git a/inc/Cli/Commands/JobsCommand/show.php b/inc/Cli/Commands/JobsCommand/show.php new file mode 100644 index 000000000..879890763 --- /dev/null +++ b/inc/Cli/Commands/JobsCommand/show.php @@ -0,0 +1,343 @@ +//! show — extracted from JobsCommand.php. + + + /** + * List jobs with optional status filter. + * + * ## OPTIONS + * + * [--status=] + * : Filter by status (pending, processing, completed, failed, agent_skipped, completed_no_items). + * + * [--flow=] + * : Filter by flow ID. + * + * [--source=] + * : Filter by source (pipeline, system). + * + * [--since=] + * : Show jobs created after this time. Accepts ISO datetime or relative strings (e.g., "1 hour ago", "today", "yesterday"). + * + * [--limit=] + * : Number of jobs to show. + * --- + * default: 20 + * --- + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * - ids + * - count + * --- + * + * [--fields=] + * : Limit output to specific fields (comma-separated). + * + * ## EXAMPLES + * + * # List recent jobs + * wp datamachine jobs list + * + * # List processing jobs + * wp datamachine jobs list --status=processing + * + * # List jobs for a specific flow + * wp datamachine jobs list --flow=98 --limit=50 + * + * # Output as CSV + * wp datamachine jobs list --format=csv + * + * # Output only IDs (space-separated) + * wp datamachine jobs list --format=ids + * + * # Count total jobs + * wp datamachine jobs list --format=count + * + * # JSON output + * wp datamachine jobs list --format=json + * + * # Show failed jobs from the last 2 hours + * wp datamachine jobs list --status=failed --since="2 hours ago" + * + * # Show all jobs since midnight + * wp datamachine jobs list --since=today + * + * @subcommand list + */ + public function list_jobs( array $args, array $assoc_args ): void { + $status = $assoc_args['status'] ?? null; + $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; + $limit = (int) ( $assoc_args['limit'] ?? 20 ); + $format = $assoc_args['format'] ?? 'table'; + + if ( $limit < 1 ) { + $limit = 20; + } + if ( $limit > 500 ) { + $limit = 500; + } + + $scoping = AgentResolver::buildScopingInput( $assoc_args ); + + $input = array_merge( + $scoping, + array( + 'per_page' => $limit, + 'offset' => 0, + 'orderby' => 'j.job_id', + 'order' => 'DESC', + ) + ); + + if ( $status ) { + $input['status'] = $status; + } + + if ( $flow_id ) { + $input['flow_id'] = $flow_id; + } + + $since = $assoc_args['since'] ?? null; + if ( $since ) { + $timestamp = strtotime( $since ); + if ( false === $timestamp ) { + WP_CLI::error( sprintf( 'Invalid --since value: "%s". Use ISO datetime or relative string (e.g., "1 hour ago", "today").', $since ) ); + return; + } + $input['since'] = gmdate( 'Y-m-d H:i:s', $timestamp ); + } + + $result = $this->abilities->executeGetJobs( $input ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + $jobs = $result['jobs'] ?? array(); + + if ( empty( $jobs ) ) { + WP_CLI::warning( 'No jobs found.' ); + return; + } + + // Filter by source if specified. + $source_filter = $assoc_args['source'] ?? null; + if ( $source_filter ) { + $jobs = array_filter( + $jobs, + function ( $j ) use ( $source_filter ) { + return ( $j['source'] ?? 'pipeline' ) === $source_filter; + } + ); + $jobs = array_values( $jobs ); + + if ( empty( $jobs ) ) { + WP_CLI::warning( sprintf( 'No %s jobs found.', $source_filter ) ); + return; + } + } + + // Transform jobs to flat row format. + $items = array_map( + function ( $j ) { + $source = $j['source'] ?? 'pipeline'; + $status_display = strlen( $j['status'] ?? '' ) > 40 ? substr( $j['status'], 0, 40 ) . '...' : ( $j['status'] ?? '' ); + + if ( 'system' === $source ) { + $flow_display = $j['label'] ?? $j['display_label'] ?? 'System Task'; + } else { + $flow_display = $j['flow_name'] ?? ( isset( $j['flow_id'] ) ? "Flow {$j['flow_id']}" : '' ); + } + + return array( + 'id' => $j['job_id'] ?? '', + 'source' => $source, + 'flow' => $flow_display, + 'status' => $status_display, + 'created' => $j['created_at'] ?? '', + 'completed' => $j['completed_at'] ?? '-', + ); + }, + $jobs + ); + + $this->format_items( $items, $this->default_fields, $assoc_args, 'id' ); + + if ( 'table' === $format ) { + WP_CLI::log( sprintf( 'Showing %d jobs.', count( $jobs ) ) ); + } + } + + /** + * Show detailed information about a specific job. + * + * ## OPTIONS + * + * + * : The job ID to display. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Show job details + * wp datamachine jobs show 844 + * + * # Show job as JSON (includes full engine_data) + * wp datamachine jobs show 844 --format=json + * + * @subcommand show + */ + public function show( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) ) { + WP_CLI::error( 'Job ID is required.' ); + return; + } + + $job_id = $args[0]; + + if ( ! is_numeric( $job_id ) || (int) $job_id <= 0 ) { + WP_CLI::error( 'Job ID must be a positive integer.' ); + return; + } + + $result = $this->abilities->executeGetJobs( array( 'job_id' => (int) $job_id ) ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + $jobs = $result['jobs'] ?? array(); + + if ( empty( $jobs ) ) { + WP_CLI::error( sprintf( 'Job %d not found.', (int) $job_id ) ); + return; + } + + $job = $jobs[0]; + $format = $assoc_args['format'] ?? 'table'; + + if ( 'json' === $format ) { + WP_CLI::log( wp_json_encode( $job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + if ( 'yaml' === $format ) { + WP_CLI::log( \Spyc::YAMLDump( $job, false, false, true ) ); + return; + } + + $this->outputJobTable( $job ); + } + + /** + * Extract a summary of engine_data for CLI display. + * + * Iterates all top-level keys and formats each value by type: + * scalars display directly (strings truncated at 120 chars), + * arrays show item count and serialized size, bools/nulls display + * as literals. No hardcoded key list — works for any job type. + * + * @param array $engine_data Full engine data array. + * @return array Key-value pairs for display. + */ + private function extractEngineDataSummary( array $engine_data ): array { + $summary = array(); + + foreach ( $engine_data as $key => $value ) { + $label = ucwords( str_replace( '_', ' ', $key ) ); + + if ( is_array( $value ) ) { + $count = count( $value ); + $json = wp_json_encode( $value ); + $size = strlen( $json ); + $summary[ $label ] = sprintf( 'array (%d items, %s)', $count, size_format( $size ) ); + } elseif ( is_bool( $value ) ) { + $summary[ $label ] = $value ? 'true' : 'false'; + } elseif ( is_null( $value ) ) { + $summary[ $label ] = '(null)'; + } elseif ( is_string( $value ) && strlen( $value ) > 120 ) { + $summary[ $label ] = substr( $value, 0, 117 ) . '...'; + } else { + $summary[ $label ] = (string) $value; + } + } + + return $summary; + } + + /** + * Show job status summary grouped by status. + * + * ## OPTIONS + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * [--fields=] + * : Limit output to specific fields (comma-separated). + * + * ## EXAMPLES + * + * # Show status summary + * wp datamachine jobs summary + * + * # Output as CSV + * wp datamachine jobs summary --format=csv + * + * # JSON output + * wp datamachine jobs summary --format=json + * + * @subcommand summary + */ + public function summary( array $args, array $assoc_args ): void { + $result = $this->abilities->executeGetJobsSummary( array() ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ?? 'Unknown error occurred' ); + return; + } + + $summary = $result['summary'] ?? array(); + + if ( empty( $summary ) ) { + WP_CLI::warning( 'No job summary data available.' ); + return; + } + + // Transform summary to row format. + $items = array(); + foreach ( $summary as $status => $count ) { + $items[] = array( + 'status' => $status, + 'count' => $count, + ); + } + + $this->format_items( $items, array( 'status', 'count' ), $assoc_args ); + } diff --git a/inc/Cli/Commands/MemoryCommand.php b/inc/Cli/Commands/MemoryCommand.php index 9cf5b2ee7..f00495de4 100644 --- a/inc/Cli/Commands/MemoryCommand.php +++ b/inc/Cli/Commands/MemoryCommand.php @@ -37,1005 +37,16 @@ class MemoryCommand extends BaseCommand { */ private array $valid_modes = array( 'set', 'append' ); - /** - * Read agent memory — full file or a specific section. - * - * ## OPTIONS - * - * [
] - * : Section name to read (without ##). If omitted, returns full file. - * - * [--agent=] - * : Agent slug or numeric ID. When provided, reads that agent's memory - * instead of the current user's agent. - * - * ## EXAMPLES - * - * # Read full memory file - * wp datamachine agent read - * - * # Read a specific section - * wp datamachine agent read "Fleet" - * - * # Read lessons learned - * wp datamachine agent read "Lessons Learned" - * - * # Read memory for a specific agent - * wp datamachine agent read --agent=studio - * - * # Read memory for a specific user - * wp datamachine agent read --user=2 - * - * @subcommand read - */ - public function read( array $args, array $assoc_args ): void { - $section = $args[0] ?? null; - $input = $this->resolveMemoryScoping( $assoc_args ); - - if ( null !== $section ) { - $input['section'] = $section; - } - - $result = AgentMemoryAbilities::getMemory( $input ); - - if ( ! $result['success'] ) { - $message = $result['message'] ?? 'Failed to read memory.'; - if ( ! empty( $result['available_sections'] ) ) { - $message .= "\nAvailable sections: " . implode( ', ', $result['available_sections'] ); - } - WP_CLI::error( $message ); - return; - } - - WP_CLI::log( $result['content'] ?? '' ); - } - - /** - * List all sections in agent memory. - * - * ## OPTIONS - * - * [--agent=] - * : Agent slug or numeric ID. - * - * [--format=] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * --- - * - * ## EXAMPLES - * - * # List memory sections - * wp datamachine agent sections - * - * # List as JSON - * wp datamachine agent sections --format=json - * - * # List sections for a specific agent - * wp datamachine agent sections --agent=studio - * - * @subcommand sections - */ - public function sections( array $args, array $assoc_args ): void { - $scoping = $this->resolveMemoryScoping( $assoc_args ); - $result = AgentMemoryAbilities::listSections( $scoping ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ?? 'Failed to list sections.' ); - return; - } - - $sections = $result['sections'] ?? array(); - - if ( empty( $sections ) ) { - WP_CLI::log( 'No sections found in memory file.' ); - return; - } - - $items = array_map( - function ( $section ) { - return array( 'section' => $section ); - }, - $sections - ); - - $this->format_items( $items, array( 'section' ), $assoc_args ); - } - - /** - * Write to a section of agent memory. - * - * ## OPTIONS - * - *
- * : Section name (without ##). Created if it does not exist. - * - * - * : Content to write. Use quotes for multi-word content. - * - * [--agent=] - * : Agent slug or numeric ID. - * - * [--mode=] - * : Write mode. - * --- - * default: set - * options: - * - set - * - append - * --- - * - * ## EXAMPLES - * - * # Replace a section - * wp datamachine agent write "State" "- Data Machine v0.30.0 installed" - * - * # Append to a section - * wp datamachine agent write "Lessons Learned" "- Always check file permissions" --mode=append - * - * # Create a new section - * wp datamachine agent write "New Section" "Initial content" - * - * # Write to a specific agent's memory - * wp datamachine agent write "State" "- Studio agent active" --agent=studio - * - * @subcommand write - */ - public function write( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) || empty( $args[1] ) ) { - WP_CLI::error( 'Both section name and content are required.' ); - return; - } - - $section = $args[0]; - $content = $args[1]; - $mode = $assoc_args['mode'] ?? 'set'; - - if ( ! in_array( $mode, $this->valid_modes, true ) ) { - WP_CLI::error( sprintf( 'Invalid mode "%s". Must be one of: %s', $mode, implode( ', ', $this->valid_modes ) ) ); - return; - } - - $scoping = $this->resolveMemoryScoping( $assoc_args ); - - $result = AgentMemoryAbilities::updateMemory( - array_merge( - $scoping, - array( - 'section' => $section, - 'content' => $content, - 'mode' => $mode, - ) - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ?? 'Failed to write memory.' ); - return; - } - - WP_CLI::success( $result['message'] ); - } - - /** - * Search agent memory content. - * - * ## OPTIONS - * - * - * : Search term (case-insensitive). - * - * [--agent=] - * : Agent slug or numeric ID. - * - * [--section=
] - * : Limit search to a specific section. - * - * ## EXAMPLES - * - * # Search all memory - * wp datamachine agent search "homeboy" - * - * # Search within a section - * wp datamachine agent search "docker" --section="Lessons Learned" - * - * # Search a specific agent's memory - * wp datamachine agent search "socials" --agent=studio - * - * @subcommand search - */ - public function search( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) ) { - WP_CLI::error( 'Search query is required.' ); - return; - } - - $query = $args[0]; - $section = $assoc_args['section'] ?? null; - $scoping = $this->resolveMemoryScoping( $assoc_args ); - - $result = AgentMemoryAbilities::searchMemory( - array_merge( - $scoping, - array( - 'query' => $query, - 'section' => $section, - ) - ) - ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ?? 'Search failed.' ); - return; - } - - if ( empty( $result['matches'] ) ) { - WP_CLI::log( sprintf( 'No matches for "%s" in agent memory.', $query ) ); - return; - } - - foreach ( $result['matches'] as $match ) { - WP_CLI::log( sprintf( '--- [%s] line %d ---', $match['section'], $match['line'] ) ); - WP_CLI::log( $match['context'] ); - WP_CLI::log( '' ); - } - - WP_CLI::success( sprintf( '%d match(es) found.', $result['match_count'] ) ); - } - - /** - * Daily memory operations. - * - * ## OPTIONS - * - * - * : Action to perform: list, read, write, append, delete, search. - * - * [] - * : Date in YYYY-MM-DD format. Defaults to today for write/append. - * - * [] - * : Content for write/append actions. - * - * [--agent=] - * : Agent slug or numeric ID. - * - * ## EXAMPLES - * - * # List all daily memory files - * wp datamachine agent daily list - * - * # Read today's daily memory - * wp datamachine agent daily read - * - * # Read a specific date - * wp datamachine agent daily read 2026-02-24 - * - * # Write to today's daily memory (replaces content) - * wp datamachine agent daily write "## Session notes" - * - * # Append to a specific date - * wp datamachine agent daily append 2026-02-24 "- Additional discovery" - * - * # Delete a daily file - * wp datamachine agent daily delete 2026-02-24 - * - * # Search daily memory - * wp datamachine agent daily search "homeboy" - * - * # Search with date range - * wp datamachine agent daily search "deploy" --from=2026-02-01 --to=2026-02-28 - * - * @subcommand daily - */ - public function daily( array $args, array $assoc_args ): void { - if ( empty( $args ) ) { - WP_CLI::error( 'Usage: wp datamachine agent daily [date] [content]' ); - return; - } - - $action = $args[0]; - $agent_id = AgentResolver::resolve( $assoc_args ); - $user_id = ( null === $agent_id ) ? UserResolver::resolve( $assoc_args ) : 0; - $daily = new DailyMemory( $user_id, $agent_id ?? 0 ); - - switch ( $action ) { - case 'list': - $this->daily_list( $daily, $assoc_args ); - break; - case 'read': - $date = $args[1] ?? gmdate( 'Y-m-d' ); - $this->daily_read( $daily, $date ); - break; - case 'write': - $this->daily_write( $daily, $args ); - break; - case 'append': - $this->daily_append( $daily, $args ); - break; - case 'delete': - $date = $args[1] ?? null; - if ( ! $date ) { - WP_CLI::error( 'Date is required for delete. Usage: wp datamachine agent daily delete 2026-02-24' ); - return; - } - $this->daily_delete( $daily, $date ); - break; - case 'search': - $search_query = $args[1] ?? null; - if ( ! $search_query ) { - WP_CLI::error( 'Search query is required. Usage: wp datamachine agent daily search "query" [--from=...] [--to=...]' ); - return; - } - $this->daily_search( $daily, $search_query, $assoc_args ); - break; - default: - WP_CLI::error( "Unknown daily action: {$action}. Use: list, read, write, append, delete, search" ); - } - } - - /** - * List daily memory files. - */ - private function daily_list( DailyMemory $daily, array $assoc_args ): void { - $result = $daily->list_all(); - $months = $result['months']; - - if ( empty( $months ) ) { - WP_CLI::log( 'No daily memory files found.' ); - return; - } - - $items = array(); - foreach ( $months as $month_key => $days ) { - foreach ( $days as $day ) { - list( $year, $month ) = explode( '/', $month_key ); - $items[] = array( - 'date' => "{$year}-{$month}-{$day}", - 'month' => $month_key, - ); - } - } - - // Sort descending by date. - usort( - $items, - function ( $a, $b ) { - return strcmp( $b['date'], $a['date'] ); - } - ); - - $this->format_items( $items, array( 'date', 'month' ), $assoc_args ); - WP_CLI::log( sprintf( 'Total: %d daily memory file(s).', count( $items ) ) ); - } - - /** - * Read a daily memory file. - */ - private function daily_read( DailyMemory $daily, string $date ): void { - $parts = DailyMemory::parse_date( $date ); - if ( ! $parts ) { - WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); - return; - } - - $result = $daily->read( $parts['year'], $parts['month'], $parts['day'] ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ); - return; - } - - WP_CLI::log( $result['content'] ); - } - - /** - * Write (replace) a daily memory file. - */ - private function daily_write( DailyMemory $daily, array $args ): void { - // write [date] — date defaults to today. - if ( count( $args ) < 2 ) { - WP_CLI::error( 'Content is required. Usage: wp datamachine agent daily write [date] ' ); - return; - } - - // If 3 args: write date content. If 2 args: write content (today). - if ( count( $args ) >= 3 ) { - $date = $args[1]; - $content = $args[2]; - } else { - $date = gmdate( 'Y-m-d' ); - $content = $args[1]; - } - - $parts = DailyMemory::parse_date( $date ); - if ( ! $parts ) { - WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); - return; - } - - $result = $daily->write( $parts['year'], $parts['month'], $parts['day'], $content ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ); - return; - } - - WP_CLI::success( $result['message'] ); - } - - /** - * Append to a daily memory file. - */ - private function daily_append( DailyMemory $daily, array $args ): void { - // append [date] — date defaults to today. - if ( count( $args ) < 2 ) { - WP_CLI::error( 'Content is required. Usage: wp datamachine agent daily append [date] ' ); - return; - } - - if ( count( $args ) >= 3 ) { - $date = $args[1]; - $content = $args[2]; - } else { - $date = gmdate( 'Y-m-d' ); - $content = $args[1]; - } - - $parts = DailyMemory::parse_date( $date ); - if ( ! $parts ) { - WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); - return; - } - - $result = $daily->append( $parts['year'], $parts['month'], $parts['day'], $content ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ); - return; - } - - WP_CLI::success( $result['message'] ); - } - - /** - * Search daily memory files. - */ - private function daily_search( DailyMemory $daily, string $query, array $assoc_args ): void { - $from = $assoc_args['from'] ?? null; - $to = $assoc_args['to'] ?? null; - - $result = $daily->search( $query, $from, $to ); - - if ( empty( $result['matches'] ) ) { - WP_CLI::log( sprintf( 'No matches for "%s" in daily memory.', $query ) ); - return; - } - - foreach ( $result['matches'] as $match ) { - WP_CLI::log( sprintf( '--- [%s] line %d ---', $match['date'], $match['line'] ) ); - WP_CLI::log( $match['context'] ); - WP_CLI::log( '' ); - } - - WP_CLI::success( sprintf( '%d match(es) found.', $result['match_count'] ) ); - } - - /** - * Delete a daily memory file. - */ - private function daily_delete( DailyMemory $daily, string $date ): void { - $parts = DailyMemory::parse_date( $date ); - if ( ! $parts ) { - WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); - return; - } - - $result = $daily->delete( $parts['year'], $parts['month'], $parts['day'] ); - - if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ); - return; - } - - WP_CLI::success( $result['message'] ); - } - // ========================================================================= // Agent Files — multi-file operations (SOUL.md, USER.md, MEMORY.md, etc.) // ========================================================================= - /** - * Agent files operations. - * - * Manage all agent memory files (SOUL.md, USER.md, MEMORY.md, etc.). - * Supports listing, reading, writing, and staleness detection. - * - * ## OPTIONS - * - * - * : Action to perform: list, read, write, check. - * - * [] - * : Filename for read/write actions (e.g., SOUL.md, USER.md). - * - * [--agent=] - * : Agent slug or numeric ID. When provided, operates on that agent's - * files instead of the current user's agent. Required for managing - * shared agents in multi-agent setups. - * - * [--days=] - * : Staleness threshold in days for the check action. - * --- - * default: 7 - * --- - * - * [--format=] - * : Output format for list/check actions. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * --- - * - * ## EXAMPLES - * - * # List all agent files with timestamps and sizes - * wp datamachine agent files list - * - * # List files for a specific agent - * wp datamachine agent files list --agent=studio - * - * # Read an agent file - * wp datamachine agent files read SOUL.md - * - * # Read a specific agent's SOUL.md - * wp datamachine agent files read SOUL.md --agent=studio - * - * # Write to an agent file via stdin - * cat new-soul.md | wp datamachine agent files write SOUL.md - * - * # Write to a specific agent's file via stdin - * cat soul.md | wp datamachine agent files write SOUL.md --agent=studio - * - * # Check for stale files (not updated in 7 days) - * wp datamachine agent files check - * - * # Check with custom threshold - * wp datamachine agent files check --days=14 - * - * # Check a specific agent's files - * wp datamachine agent files check --agent=studio - * - * @subcommand files - */ - public function files( array $args, array $assoc_args ): void { - if ( empty( $args ) ) { - WP_CLI::error( 'Usage: wp datamachine agent files [filename]' ); - return; - } - - $action = $args[0]; - $agent_id = AgentResolver::resolve( $assoc_args ); - $user_id = ( null === $agent_id ) ? UserResolver::resolve( $assoc_args ) : 0; - - switch ( $action ) { - case 'list': - $this->files_list( $assoc_args, $user_id, $agent_id ); - break; - case 'read': - $filename = $args[1] ?? null; - if ( ! $filename ) { - WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files read ' ); - return; - } - $this->files_read( $filename, $user_id, $agent_id ); - break; - case 'write': - $filename = $args[1] ?? null; - if ( ! $filename ) { - WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files write ' ); - return; - } - $this->files_write( $filename, $user_id, $agent_id ); - break; - case 'check': - $this->files_check( $assoc_args, $user_id, $agent_id ); - break; - default: - WP_CLI::error( "Unknown files action: {$action}. Use: list, read, write, check" ); - } - } - - /** - * List all agent files with metadata. - * - * @param array $assoc_args Command arguments. - */ - private function files_list( array $assoc_args, int $user_id = 0, ?int $agent_id = null ): void { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - - if ( ! is_dir( $agent_dir ) ) { - WP_CLI::error( 'Agent directory does not exist.' ); - return; - } - - $files = glob( $agent_dir . '/*.md' ); - - if ( empty( $files ) ) { - WP_CLI::log( 'No agent files found.' ); - return; - } - - $items = array(); - $now = time(); - - foreach ( $files as $file ) { - $mtime = filemtime( $file ); - $age_days = floor( ( $now - $mtime ) / 86400 ); - - $items[] = array( - 'file' => basename( $file ), - 'size' => size_format( filesize( $file ) ), - 'modified' => wp_date( 'Y-m-d H:i:s', $mtime ), - 'age' => $age_days . 'd', - ); - } - - $this->format_items( $items, array( 'file', 'size', 'modified', 'age' ), $assoc_args ); - } - - /** - * Read an agent file by name. - * - * @param string $filename File name (e.g., SOUL.md). - */ - private function files_read( string $filename, int $user_id = 0, ?int $agent_id = null ): void { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $filepath = $agent_dir . '/' . $this->sanitize_agent_filename( $filename ); - - if ( ! file_exists( $filepath ) ) { - $available = $this->list_agent_filenames( $user_id, $agent_id ); - WP_CLI::error( sprintf( 'File "%s" not found. Available files: %s', $filename, implode( ', ', $available ) ) ); - return; - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - WP_CLI::log( file_get_contents( $filepath ) ); - } - - /** - * Write to an agent file from stdin. - * - * @param string $filename File name (e.g., SOUL.md). - */ - private function files_write( string $filename, int $user_id = 0, ?int $agent_id = null ): void { - $fs = FilesystemHelper::get(); - $safe_name = $this->sanitize_agent_filename( $filename ); - - // Only allow .md files. - if ( '.md' !== substr( $safe_name, -3 ) ) { - WP_CLI::error( 'Only .md files can be written to the agent directory.' ); - return; - } - - // Editability check — warn for read-only files. - $edit_cap = \DataMachine\Engine\AI\MemoryFileRegistry::get_edit_capability( $safe_name ); - if ( false === $edit_cap ) { - WP_CLI::error( sprintf( 'File %s is read-only. It is auto-generated and can only be extended via PHP filters.', $safe_name ) ); - return; - } - - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $filepath = $agent_dir . '/' . $safe_name; - - // Read from stdin. - $content = $fs->get_contents( 'php://stdin' ); - - if ( false === $content || '' === trim( $content ) ) { - WP_CLI::error( 'No content received from stdin. Pipe content in: echo "content" | wp datamachine agent files write SOUL.md' ); - return; - } - - $directory_manager = new DirectoryManager(); - $directory_manager->ensure_directory_exists( $agent_dir ); - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - $written = file_put_contents( $filepath, $content ); - - if ( false === $written ) { - WP_CLI::error( sprintf( 'Failed to write file: %s', $safe_name ) ); - return; - } - - FilesystemHelper::make_group_writable( $filepath ); - - WP_CLI::success( sprintf( 'Wrote %s (%s).', $safe_name, size_format( $written ) ) ); - } - - /** - * Check agent files for staleness. - * - * @param array $assoc_args Command arguments. - */ - private function files_check( array $assoc_args, int $user_id = 0, ?int $agent_id = null ): void { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $threshold_days = (int) ( $assoc_args['days'] ?? 7 ); - - if ( ! is_dir( $agent_dir ) ) { - WP_CLI::error( 'Agent directory does not exist.' ); - return; - } - - $files = glob( $agent_dir . '/*.md' ); - - if ( empty( $files ) ) { - WP_CLI::log( 'No agent files found.' ); - return; - } - - $items = array(); - $now = time(); - $threshold = $now - ( $threshold_days * 86400 ); - $stale = 0; - - foreach ( $files as $file ) { - $mtime = filemtime( $file ); - $age_days = floor( ( $now - $mtime ) / 86400 ); - $is_stale = $mtime < $threshold; - - if ( $is_stale ) { - ++$stale; - } - - $items[] = array( - 'file' => basename( $file ), - 'modified' => wp_date( 'Y-m-d H:i:s', $mtime ), - 'age' => $age_days . 'd', - 'status' => $is_stale ? 'STALE' : 'OK', - ); - } - - $this->format_items( $items, array( 'file', 'modified', 'age', 'status' ), $assoc_args ); - - if ( $stale > 0 ) { - WP_CLI::warning( sprintf( '%d file(s) not updated in %d+ days. Review for accuracy.', $stale, $threshold_days ) ); - } else { - WP_CLI::success( sprintf( 'All %d file(s) updated within the last %d days.', count( $files ), $threshold_days ) ); - } - } - /** * Get the agent directory path. * * @return string */ - /** - * Resolve memory scoping from CLI flags. - * - * Returns an input array with agent_id (preferred) or user_id for - * memory ability calls. Agent scoping takes precedence over user scoping. - * - * @param array $assoc_args Command arguments. - * @return array Input parameters with user_id and/or agent_id. - */ - private function resolveMemoryScoping( array $assoc_args ): array { - $agent_id = AgentResolver::resolve( $assoc_args ); - - if ( null !== $agent_id ) { - return array( 'agent_id' => $agent_id ); - } - - return array( 'user_id' => UserResolver::resolve( $assoc_args ) ); - } - - private function get_agent_dir( int $user_id = 0, ?int $agent_id = null ): string { - $directory_manager = new DirectoryManager(); - - if ( null !== $agent_id && $agent_id > 0 ) { - $slug = $directory_manager->resolve_agent_slug( array( 'agent_id' => $agent_id ) ); - return $directory_manager->get_agent_identity_directory( $slug ); - } - - return $directory_manager->get_agent_identity_directory_for_user( $user_id ); - } - - /** - * Sanitize an agent filename (allow only alphanumeric, hyphens, underscores, dots). - * - * @param string $filename Raw filename. - * @return string Sanitized filename. - */ - private function sanitize_agent_filename( string $filename ): string { - return preg_replace( '/[^a-zA-Z0-9._-]/', '', basename( $filename ) ); - } - - /** - * List available agent filenames. - * - * @return string[] - */ - private function list_agent_filenames( int $user_id = 0, ?int $agent_id = null ): array { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $files = glob( $agent_dir . '/*.md' ); - return array_map( 'basename', $files ? $files : array() ); - } - // ========================================================================= // Agent Paths — discovery for external consumers // ========================================================================= - - /** - * Show resolved file paths for all agent memory layers. - * - * External consumers (Kimaki, Claude Code, setup scripts) use this to - * discover the correct file paths instead of hardcoding them. - * Outputs absolute paths, relative paths (from site root), and layer directories. - * - * ## OPTIONS - * - * [--agent=] - * : Agent slug to resolve paths for. When provided, bypasses - * user-to-agent lookup and resolves directly by slug. - * Required for multi-agent setups where a user owns multiple agents. - * - * [--format=] - * : Output format. - * --- - * default: json - * options: - * - json - * - table - * --- - * - * [--relative] - * : Output paths relative to the WordPress root (for config file injection). - * - * ## EXAMPLES - * - * # Get all resolved paths as JSON (for setup scripts) - * wp datamachine agent paths --format=json - * - * # Get paths for a specific agent (multi-agent) - * wp datamachine agent paths --agent=chubes-bot - * - * # Get relative paths for config file injection - * wp datamachine agent paths --relative - * - * # Table view for debugging - * wp datamachine agent paths --format=table - * - * @subcommand paths - */ - public function paths( array $args, array $assoc_args ): void { - $directory_manager = new DirectoryManager(); - $explicit_slug = \WP_CLI\Utils\get_flag_value( $assoc_args, 'agent', null ); - - if ( null !== $explicit_slug ) { - // Direct slug resolution — multi-agent safe. - $agent_slug = $directory_manager->resolve_agent_slug( array( 'agent_slug' => $explicit_slug ) ); - $agent_dir = $directory_manager->get_agent_identity_directory( $agent_slug ); - - // Look up the agent's owner for the user layer. - $effective_user_id = 0; - if ( class_exists( '\\DataMachine\\Core\\Database\\Agents\\Agents' ) ) { - $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); - $agent_row = $agents_repo->get_by_slug( $agent_slug ); - if ( $agent_row ) { - $effective_user_id = (int) $agent_row['owner_id']; - } - } - - if ( 0 === $effective_user_id ) { - $effective_user_id = DirectoryManager::get_default_agent_user_id(); - } - } else { - // Legacy user-based resolution (single-agent compat). - $user_id = UserResolver::resolve( $assoc_args ); - $effective_user_id = $directory_manager->get_effective_user_id( $user_id ); - $agent_slug = $directory_manager->get_agent_slug_for_user( $effective_user_id ); - $agent_dir = $directory_manager->get_agent_identity_directory_for_user( $effective_user_id ); - } - - $shared_dir = $directory_manager->get_shared_directory(); - $user_dir = $directory_manager->get_user_directory( $effective_user_id ); - $network_dir = $directory_manager->get_network_directory(); - - $site_root = untrailingslashit( ABSPATH ); - $relative = \WP_CLI\Utils\get_flag_value( $assoc_args, 'relative', false ); - - // Core files in injection order (matches CoreMemoryFilesDirective). - $core_files = array( - array( - 'file' => 'SITE.md', - 'layer' => 'shared', - 'directory' => $shared_dir, - ), - array( - 'file' => 'SOUL.md', - 'layer' => 'agent', - 'directory' => $agent_dir, - ), - array( - 'file' => 'MEMORY.md', - 'layer' => 'agent', - 'directory' => $agent_dir, - ), - array( - 'file' => 'USER.md', - 'layer' => 'user', - 'directory' => $user_dir, - ), - array( - 'file' => 'NETWORK.md', - 'layer' => 'network', - 'directory' => $network_dir, - ), - ); - - $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'json' ); - - if ( 'json' === $format ) { - $layers = array( - 'shared' => $shared_dir, - 'agent' => $agent_dir, - 'user' => $user_dir, - 'network' => $network_dir, - ); - - $files = array(); - $relative_files = array(); - - foreach ( $core_files as $entry ) { - $abs_path = trailingslashit( $entry['directory'] ) . $entry['file']; - $rel_path = str_replace( $site_root . '/', '', $abs_path ); - $exists = file_exists( $abs_path ); - - $files[ $entry['file'] ] = array( - 'layer' => $entry['layer'], - 'path' => $abs_path, - 'relative' => $rel_path, - 'exists' => $exists, - ); - - if ( $exists ) { - $relative_files[] = $rel_path; - } - } - - $output = array( - 'agent_slug' => $agent_slug, - 'user_id' => $effective_user_id, - 'layers' => $layers, - 'files' => $files, - 'relative_files' => $relative_files, - ); - - WP_CLI::line( wp_json_encode( $output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); - } else { - $items = array(); - foreach ( $core_files as $entry ) { - $abs_path = trailingslashit( $entry['directory'] ) . $entry['file']; - $rel_path = str_replace( $site_root . '/', '', $abs_path ); - - $items[] = array( - 'file' => $entry['file'], - 'layer' => $entry['layer'], - 'path' => $relative ? $rel_path : $abs_path, - 'exists' => file_exists( $abs_path ) ? 'yes' : 'no', - ); - } - - $this->format_items( $items, array( 'file', 'layer', 'path', 'exists' ), $assoc_args ); - } - } } diff --git a/inc/Cli/Commands/MemoryCommand/agent_files_multi.php b/inc/Cli/Commands/MemoryCommand/agent_files_multi.php new file mode 100644 index 000000000..9619e22f4 --- /dev/null +++ b/inc/Cli/Commands/MemoryCommand/agent_files_multi.php @@ -0,0 +1,481 @@ +//! agent_files_multi — extracted from MemoryCommand.php. + + + /** + * Agent files operations. + * + * Manage all agent memory files (SOUL.md, USER.md, MEMORY.md, etc.). + * Supports listing, reading, writing, and staleness detection. + * + * ## OPTIONS + * + * + * : Action to perform: list, read, write, check. + * + * [] + * : Filename for read/write actions (e.g., SOUL.md, USER.md). + * + * [--agent=] + * : Agent slug or numeric ID. When provided, operates on that agent's + * files instead of the current user's agent. Required for managing + * shared agents in multi-agent setups. + * + * [--days=] + * : Staleness threshold in days for the check action. + * --- + * default: 7 + * --- + * + * [--format=] + * : Output format for list/check actions. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * # List all agent files with timestamps and sizes + * wp datamachine agent files list + * + * # List files for a specific agent + * wp datamachine agent files list --agent=studio + * + * # Read an agent file + * wp datamachine agent files read SOUL.md + * + * # Read a specific agent's SOUL.md + * wp datamachine agent files read SOUL.md --agent=studio + * + * # Write to an agent file via stdin + * cat new-soul.md | wp datamachine agent files write SOUL.md + * + * # Write to a specific agent's file via stdin + * cat soul.md | wp datamachine agent files write SOUL.md --agent=studio + * + * # Check for stale files (not updated in 7 days) + * wp datamachine agent files check + * + * # Check with custom threshold + * wp datamachine agent files check --days=14 + * + * # Check a specific agent's files + * wp datamachine agent files check --agent=studio + * + * @subcommand files + */ + public function files( array $args, array $assoc_args ): void { + if ( empty( $args ) ) { + WP_CLI::error( 'Usage: wp datamachine agent files [filename]' ); + return; + } + + $action = $args[0]; + $agent_id = AgentResolver::resolve( $assoc_args ); + $user_id = ( null === $agent_id ) ? UserResolver::resolve( $assoc_args ) : 0; + + switch ( $action ) { + case 'list': + $this->files_list( $assoc_args, $user_id, $agent_id ); + break; + case 'read': + $filename = $args[1] ?? null; + if ( ! $filename ) { + WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files read ' ); + return; + } + $this->files_read( $filename, $user_id, $agent_id ); + break; + case 'write': + $filename = $args[1] ?? null; + if ( ! $filename ) { + WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files write ' ); + return; + } + $this->files_write( $filename, $user_id, $agent_id ); + break; + case 'check': + $this->files_check( $assoc_args, $user_id, $agent_id ); + break; + default: + WP_CLI::error( "Unknown files action: {$action}. Use: list, read, write, check" ); + } + } + + /** + * List all agent files with metadata. + * + * @param array $assoc_args Command arguments. + */ + private function files_list( array $assoc_args, int $user_id = 0, ?int $agent_id = null ): void { + $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); + + if ( ! is_dir( $agent_dir ) ) { + WP_CLI::error( 'Agent directory does not exist.' ); + return; + } + + $files = glob( $agent_dir . '/*.md' ); + + if ( empty( $files ) ) { + WP_CLI::log( 'No agent files found.' ); + return; + } + + $items = array(); + $now = time(); + + foreach ( $files as $file ) { + $mtime = filemtime( $file ); + $age_days = floor( ( $now - $mtime ) / 86400 ); + + $items[] = array( + 'file' => basename( $file ), + 'size' => size_format( filesize( $file ) ), + 'modified' => wp_date( 'Y-m-d H:i:s', $mtime ), + 'age' => $age_days . 'd', + ); + } + + $this->format_items( $items, array( 'file', 'size', 'modified', 'age' ), $assoc_args ); + } + + /** + * Read an agent file by name. + * + * @param string $filename File name (e.g., SOUL.md). + */ + private function files_read( string $filename, int $user_id = 0, ?int $agent_id = null ): void { + $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); + $filepath = $agent_dir . '/' . $this->sanitize_agent_filename( $filename ); + + if ( ! file_exists( $filepath ) ) { + $available = $this->list_agent_filenames( $user_id, $agent_id ); + WP_CLI::error( sprintf( 'File "%s" not found. Available files: %s', $filename, implode( ', ', $available ) ) ); + return; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + WP_CLI::log( file_get_contents( $filepath ) ); + } + + /** + * Write to an agent file from stdin. + * + * @param string $filename File name (e.g., SOUL.md). + */ + private function files_write( string $filename, int $user_id = 0, ?int $agent_id = null ): void { + $fs = FilesystemHelper::get(); + $safe_name = $this->sanitize_agent_filename( $filename ); + + // Only allow .md files. + if ( '.md' !== substr( $safe_name, -3 ) ) { + WP_CLI::error( 'Only .md files can be written to the agent directory.' ); + return; + } + + // Editability check — warn for read-only files. + $edit_cap = \DataMachine\Engine\AI\MemoryFileRegistry::get_edit_capability( $safe_name ); + if ( false === $edit_cap ) { + WP_CLI::error( sprintf( 'File %s is read-only. It is auto-generated and can only be extended via PHP filters.', $safe_name ) ); + return; + } + + $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); + $filepath = $agent_dir . '/' . $safe_name; + + // Read from stdin. + $content = $fs->get_contents( 'php://stdin' ); + + if ( false === $content || '' === trim( $content ) ) { + WP_CLI::error( 'No content received from stdin. Pipe content in: echo "content" | wp datamachine agent files write SOUL.md' ); + return; + } + + $directory_manager = new DirectoryManager(); + $directory_manager->ensure_directory_exists( $agent_dir ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $written = file_put_contents( $filepath, $content ); + + if ( false === $written ) { + WP_CLI::error( sprintf( 'Failed to write file: %s', $safe_name ) ); + return; + } + + FilesystemHelper::make_group_writable( $filepath ); + + WP_CLI::success( sprintf( 'Wrote %s (%s).', $safe_name, size_format( $written ) ) ); + } + + /** + * Check agent files for staleness. + * + * @param array $assoc_args Command arguments. + */ + private function files_check( array $assoc_args, int $user_id = 0, ?int $agent_id = null ): void { + $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); + $threshold_days = (int) ( $assoc_args['days'] ?? 7 ); + + if ( ! is_dir( $agent_dir ) ) { + WP_CLI::error( 'Agent directory does not exist.' ); + return; + } + + $files = glob( $agent_dir . '/*.md' ); + + if ( empty( $files ) ) { + WP_CLI::log( 'No agent files found.' ); + return; + } + + $items = array(); + $now = time(); + $threshold = $now - ( $threshold_days * 86400 ); + $stale = 0; + + foreach ( $files as $file ) { + $mtime = filemtime( $file ); + $age_days = floor( ( $now - $mtime ) / 86400 ); + $is_stale = $mtime < $threshold; + + if ( $is_stale ) { + ++$stale; + } + + $items[] = array( + 'file' => basename( $file ), + 'modified' => wp_date( 'Y-m-d H:i:s', $mtime ), + 'age' => $age_days . 'd', + 'status' => $is_stale ? 'STALE' : 'OK', + ); + } + + $this->format_items( $items, array( 'file', 'modified', 'age', 'status' ), $assoc_args ); + + if ( $stale > 0 ) { + WP_CLI::warning( sprintf( '%d file(s) not updated in %d+ days. Review for accuracy.', $stale, $threshold_days ) ); + } else { + WP_CLI::success( sprintf( 'All %d file(s) updated within the last %d days.', count( $files ), $threshold_days ) ); + } + } + + /** + * Resolve memory scoping from CLI flags. + * + * Returns an input array with agent_id (preferred) or user_id for + * memory ability calls. Agent scoping takes precedence over user scoping. + * + * @param array $assoc_args Command arguments. + * @return array Input parameters with user_id and/or agent_id. + */ + private function resolveMemoryScoping( array $assoc_args ): array { + $agent_id = AgentResolver::resolve( $assoc_args ); + + if ( null !== $agent_id ) { + return array( 'agent_id' => $agent_id ); + } + + return array( 'user_id' => UserResolver::resolve( $assoc_args ) ); + } + + private function get_agent_dir( int $user_id = 0, ?int $agent_id = null ): string { + $directory_manager = new DirectoryManager(); + + if ( null !== $agent_id && $agent_id > 0 ) { + $slug = $directory_manager->resolve_agent_slug( array( 'agent_id' => $agent_id ) ); + return $directory_manager->get_agent_identity_directory( $slug ); + } + + return $directory_manager->get_agent_identity_directory_for_user( $user_id ); + } + + /** + * Sanitize an agent filename (allow only alphanumeric, hyphens, underscores, dots). + * + * @param string $filename Raw filename. + * @return string Sanitized filename. + */ + private function sanitize_agent_filename( string $filename ): string { + return preg_replace( '/[^a-zA-Z0-9._-]/', '', basename( $filename ) ); + } + + /** + * List available agent filenames. + * + * @return string[] + */ + private function list_agent_filenames( int $user_id = 0, ?int $agent_id = null ): array { + $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); + $files = glob( $agent_dir . '/*.md' ); + return array_map( 'basename', $files ? $files : array() ); + } + + /** + * Show resolved file paths for all agent memory layers. + * + * External consumers (Kimaki, Claude Code, setup scripts) use this to + * discover the correct file paths instead of hardcoding them. + * Outputs absolute paths, relative paths (from site root), and layer directories. + * + * ## OPTIONS + * + * [--agent=] + * : Agent slug to resolve paths for. When provided, bypasses + * user-to-agent lookup and resolves directly by slug. + * Required for multi-agent setups where a user owns multiple agents. + * + * [--format=] + * : Output format. + * --- + * default: json + * options: + * - json + * - table + * --- + * + * [--relative] + * : Output paths relative to the WordPress root (for config file injection). + * + * ## EXAMPLES + * + * # Get all resolved paths as JSON (for setup scripts) + * wp datamachine agent paths --format=json + * + * # Get paths for a specific agent (multi-agent) + * wp datamachine agent paths --agent=chubes-bot + * + * # Get relative paths for config file injection + * wp datamachine agent paths --relative + * + * # Table view for debugging + * wp datamachine agent paths --format=table + * + * @subcommand paths + */ + public function paths( array $args, array $assoc_args ): void { + $directory_manager = new DirectoryManager(); + $explicit_slug = \WP_CLI\Utils\get_flag_value( $assoc_args, 'agent', null ); + + if ( null !== $explicit_slug ) { + // Direct slug resolution — multi-agent safe. + $agent_slug = $directory_manager->resolve_agent_slug( array( 'agent_slug' => $explicit_slug ) ); + $agent_dir = $directory_manager->get_agent_identity_directory( $agent_slug ); + + // Look up the agent's owner for the user layer. + $effective_user_id = 0; + if ( class_exists( '\\DataMachine\\Core\\Database\\Agents\\Agents' ) ) { + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + $agent_row = $agents_repo->get_by_slug( $agent_slug ); + if ( $agent_row ) { + $effective_user_id = (int) $agent_row['owner_id']; + } + } + + if ( 0 === $effective_user_id ) { + $effective_user_id = DirectoryManager::get_default_agent_user_id(); + } + } else { + // Legacy user-based resolution (single-agent compat). + $user_id = UserResolver::resolve( $assoc_args ); + $effective_user_id = $directory_manager->get_effective_user_id( $user_id ); + $agent_slug = $directory_manager->get_agent_slug_for_user( $effective_user_id ); + $agent_dir = $directory_manager->get_agent_identity_directory_for_user( $effective_user_id ); + } + + $shared_dir = $directory_manager->get_shared_directory(); + $user_dir = $directory_manager->get_user_directory( $effective_user_id ); + $network_dir = $directory_manager->get_network_directory(); + + $site_root = untrailingslashit( ABSPATH ); + $relative = \WP_CLI\Utils\get_flag_value( $assoc_args, 'relative', false ); + + // Core files in injection order (matches CoreMemoryFilesDirective). + $core_files = array( + array( + 'file' => 'SITE.md', + 'layer' => 'shared', + 'directory' => $shared_dir, + ), + array( + 'file' => 'SOUL.md', + 'layer' => 'agent', + 'directory' => $agent_dir, + ), + array( + 'file' => 'MEMORY.md', + 'layer' => 'agent', + 'directory' => $agent_dir, + ), + array( + 'file' => 'USER.md', + 'layer' => 'user', + 'directory' => $user_dir, + ), + array( + 'file' => 'NETWORK.md', + 'layer' => 'network', + 'directory' => $network_dir, + ), + ); + + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'json' ); + + if ( 'json' === $format ) { + $layers = array( + 'shared' => $shared_dir, + 'agent' => $agent_dir, + 'user' => $user_dir, + 'network' => $network_dir, + ); + + $files = array(); + $relative_files = array(); + + foreach ( $core_files as $entry ) { + $abs_path = trailingslashit( $entry['directory'] ) . $entry['file']; + $rel_path = str_replace( $site_root . '/', '', $abs_path ); + $exists = file_exists( $abs_path ); + + $files[ $entry['file'] ] = array( + 'layer' => $entry['layer'], + 'path' => $abs_path, + 'relative' => $rel_path, + 'exists' => $exists, + ); + + if ( $exists ) { + $relative_files[] = $rel_path; + } + } + + $output = array( + 'agent_slug' => $agent_slug, + 'user_id' => $effective_user_id, + 'layers' => $layers, + 'files' => $files, + 'relative_files' => $relative_files, + ); + + WP_CLI::line( wp_json_encode( $output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + } else { + $items = array(); + foreach ( $core_files as $entry ) { + $abs_path = trailingslashit( $entry['directory'] ) . $entry['file']; + $rel_path = str_replace( $site_root . '/', '', $abs_path ); + + $items[] = array( + 'file' => $entry['file'], + 'layer' => $entry['layer'], + 'path' => $relative ? $rel_path : $abs_path, + 'exists' => file_exists( $abs_path ) ? 'yes' : 'no', + ); + } + + $this->format_items( $items, array( 'file', 'layer', 'path', 'exists' ), $assoc_args ); + } + } diff --git a/inc/Cli/Commands/MemoryCommand/daily.php b/inc/Cli/Commands/MemoryCommand/daily.php new file mode 100644 index 000000000..71b5f63aa --- /dev/null +++ b/inc/Cli/Commands/MemoryCommand/daily.php @@ -0,0 +1,182 @@ +//! daily — extracted from MemoryCommand.php. + + + /** + * Daily memory operations. + * + * ## OPTIONS + * + * + * : Action to perform: list, read, write, append, delete, search. + * + * [] + * : Date in YYYY-MM-DD format. Defaults to today for write/append. + * + * [] + * : Content for write/append actions. + * + * [--agent=] + * : Agent slug or numeric ID. + * + * ## EXAMPLES + * + * # List all daily memory files + * wp datamachine agent daily list + * + * # Read today's daily memory + * wp datamachine agent daily read + * + * # Read a specific date + * wp datamachine agent daily read 2026-02-24 + * + * # Write to today's daily memory (replaces content) + * wp datamachine agent daily write "## Session notes" + * + * # Append to a specific date + * wp datamachine agent daily append 2026-02-24 "- Additional discovery" + * + * # Delete a daily file + * wp datamachine agent daily delete 2026-02-24 + * + * # Search daily memory + * wp datamachine agent daily search "homeboy" + * + * # Search with date range + * wp datamachine agent daily search "deploy" --from=2026-02-01 --to=2026-02-28 + * + * @subcommand daily + */ + public function daily( array $args, array $assoc_args ): void { + if ( empty( $args ) ) { + WP_CLI::error( 'Usage: wp datamachine agent daily [date] [content]' ); + return; + } + + $action = $args[0]; + $agent_id = AgentResolver::resolve( $assoc_args ); + $user_id = ( null === $agent_id ) ? UserResolver::resolve( $assoc_args ) : 0; + $daily = new DailyMemory( $user_id, $agent_id ?? 0 ); + + switch ( $action ) { + case 'list': + $this->daily_list( $daily, $assoc_args ); + break; + case 'read': + $date = $args[1] ?? gmdate( 'Y-m-d' ); + $this->daily_read( $daily, $date ); + break; + case 'write': + $this->daily_write( $daily, $args ); + break; + case 'append': + $this->daily_append( $daily, $args ); + break; + case 'delete': + $date = $args[1] ?? null; + if ( ! $date ) { + WP_CLI::error( 'Date is required for delete. Usage: wp datamachine agent daily delete 2026-02-24' ); + return; + } + $this->daily_delete( $daily, $date ); + break; + case 'search': + $search_query = $args[1] ?? null; + if ( ! $search_query ) { + WP_CLI::error( 'Search query is required. Usage: wp datamachine agent daily search "query" [--from=...] [--to=...]' ); + return; + } + $this->daily_search( $daily, $search_query, $assoc_args ); + break; + default: + WP_CLI::error( "Unknown daily action: {$action}. Use: list, read, write, append, delete, search" ); + } + } + + /** + * List daily memory files. + */ + private function daily_list( DailyMemory $daily, array $assoc_args ): void { + $result = $daily->list_all(); + $months = $result['months']; + + if ( empty( $months ) ) { + WP_CLI::log( 'No daily memory files found.' ); + return; + } + + $items = array(); + foreach ( $months as $month_key => $days ) { + foreach ( $days as $day ) { + list( $year, $month ) = explode( '/', $month_key ); + $items[] = array( + 'date' => "{$year}-{$month}-{$day}", + 'month' => $month_key, + ); + } + } + + // Sort descending by date. + usort( + $items, + function ( $a, $b ) { + return strcmp( $b['date'], $a['date'] ); + } + ); + + $this->format_items( $items, array( 'date', 'month' ), $assoc_args ); + WP_CLI::log( sprintf( 'Total: %d daily memory file(s).', count( $items ) ) ); + } + + /** + * Append to a daily memory file. + */ + private function daily_append( DailyMemory $daily, array $args ): void { + // append [date] — date defaults to today. + if ( count( $args ) < 2 ) { + WP_CLI::error( 'Content is required. Usage: wp datamachine agent daily append [date] ' ); + return; + } + + if ( count( $args ) >= 3 ) { + $date = $args[1]; + $content = $args[2]; + } else { + $date = gmdate( 'Y-m-d' ); + $content = $args[1]; + } + + $parts = DailyMemory::parse_date( $date ); + if ( ! $parts ) { + WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); + return; + } + + $result = $daily->append( $parts['year'], $parts['month'], $parts['day'], $content ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ); + return; + } + + WP_CLI::success( $result['message'] ); + } + + /** + * Delete a daily memory file. + */ + private function daily_delete( DailyMemory $daily, string $date ): void { + $parts = DailyMemory::parse_date( $date ); + if ( ! $parts ) { + WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); + return; + } + + $result = $daily->delete( $parts['year'], $parts['month'], $parts['day'] ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ); + return; + } + + WP_CLI::success( $result['message'] ); + } diff --git a/inc/Cli/Commands/MemoryCommand/search.php b/inc/Cli/Commands/MemoryCommand/search.php new file mode 100644 index 000000000..0ade321f0 --- /dev/null +++ b/inc/Cli/Commands/MemoryCommand/search.php @@ -0,0 +1,91 @@ +//! search — extracted from MemoryCommand.php. + + + /** + * Search agent memory content. + * + * ## OPTIONS + * + * + * : Search term (case-insensitive). + * + * [--agent=] + * : Agent slug or numeric ID. + * + * [--section=
] + * : Limit search to a specific section. + * + * ## EXAMPLES + * + * # Search all memory + * wp datamachine agent search "homeboy" + * + * # Search within a section + * wp datamachine agent search "docker" --section="Lessons Learned" + * + * # Search a specific agent's memory + * wp datamachine agent search "socials" --agent=studio + * + * @subcommand search + */ + public function search( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) ) { + WP_CLI::error( 'Search query is required.' ); + return; + } + + $query = $args[0]; + $section = $assoc_args['section'] ?? null; + $scoping = $this->resolveMemoryScoping( $assoc_args ); + + $result = AgentMemoryAbilities::searchMemory( + array_merge( + $scoping, + array( + 'query' => $query, + 'section' => $section, + ) + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ?? 'Search failed.' ); + return; + } + + if ( empty( $result['matches'] ) ) { + WP_CLI::log( sprintf( 'No matches for "%s" in agent memory.', $query ) ); + return; + } + + foreach ( $result['matches'] as $match ) { + WP_CLI::log( sprintf( '--- [%s] line %d ---', $match['section'], $match['line'] ) ); + WP_CLI::log( $match['context'] ); + WP_CLI::log( '' ); + } + + WP_CLI::success( sprintf( '%d match(es) found.', $result['match_count'] ) ); + } + + /** + * Search daily memory files. + */ + private function daily_search( DailyMemory $daily, string $query, array $assoc_args ): void { + $from = $assoc_args['from'] ?? null; + $to = $assoc_args['to'] ?? null; + + $result = $daily->search( $query, $from, $to ); + + if ( empty( $result['matches'] ) ) { + WP_CLI::log( sprintf( 'No matches for "%s" in daily memory.', $query ) ); + return; + } + + foreach ( $result['matches'] as $match ) { + WP_CLI::log( sprintf( '--- [%s] line %d ---', $match['date'], $match['line'] ) ); + WP_CLI::log( $match['context'] ); + WP_CLI::log( '' ); + } + + WP_CLI::success( sprintf( '%d match(es) found.', $result['match_count'] ) ); + } diff --git a/inc/Cli/Commands/MemoryCommand/sections.php b/inc/Cli/Commands/MemoryCommand/sections.php new file mode 100644 index 000000000..f4eacfe36 --- /dev/null +++ b/inc/Cli/Commands/MemoryCommand/sections.php @@ -0,0 +1,133 @@ +//! sections — extracted from MemoryCommand.php. + + + /** + * Read agent memory — full file or a specific section. + * + * ## OPTIONS + * + * [
] + * : Section name to read (without ##). If omitted, returns full file. + * + * [--agent=] + * : Agent slug or numeric ID. When provided, reads that agent's memory + * instead of the current user's agent. + * + * ## EXAMPLES + * + * # Read full memory file + * wp datamachine agent read + * + * # Read a specific section + * wp datamachine agent read "Fleet" + * + * # Read lessons learned + * wp datamachine agent read "Lessons Learned" + * + * # Read memory for a specific agent + * wp datamachine agent read --agent=studio + * + * # Read memory for a specific user + * wp datamachine agent read --user=2 + * + * @subcommand read + */ + public function read( array $args, array $assoc_args ): void { + $section = $args[0] ?? null; + $input = $this->resolveMemoryScoping( $assoc_args ); + + if ( null !== $section ) { + $input['section'] = $section; + } + + $result = AgentMemoryAbilities::getMemory( $input ); + + if ( ! $result['success'] ) { + $message = $result['message'] ?? 'Failed to read memory.'; + if ( ! empty( $result['available_sections'] ) ) { + $message .= "\nAvailable sections: " . implode( ', ', $result['available_sections'] ); + } + WP_CLI::error( $message ); + return; + } + + WP_CLI::log( $result['content'] ?? '' ); + } + + /** + * List all sections in agent memory. + * + * ## OPTIONS + * + * [--agent=] + * : Agent slug or numeric ID. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * # List memory sections + * wp datamachine agent sections + * + * # List as JSON + * wp datamachine agent sections --format=json + * + * # List sections for a specific agent + * wp datamachine agent sections --agent=studio + * + * @subcommand sections + */ + public function sections( array $args, array $assoc_args ): void { + $scoping = $this->resolveMemoryScoping( $assoc_args ); + $result = AgentMemoryAbilities::listSections( $scoping ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ?? 'Failed to list sections.' ); + return; + } + + $sections = $result['sections'] ?? array(); + + if ( empty( $sections ) ) { + WP_CLI::log( 'No sections found in memory file.' ); + return; + } + + $items = array_map( + function ( $section ) { + return array( 'section' => $section ); + }, + $sections + ); + + $this->format_items( $items, array( 'section' ), $assoc_args ); + } + + /** + * Read a daily memory file. + */ + private function daily_read( DailyMemory $daily, string $date ): void { + $parts = DailyMemory::parse_date( $date ); + if ( ! $parts ) { + WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); + return; + } + + $result = $daily->read( $parts['year'], $parts['month'], $parts['day'] ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ); + return; + } + + WP_CLI::log( $result['content'] ); + } diff --git a/inc/Cli/Commands/MemoryCommand/write.php b/inc/Cli/Commands/MemoryCommand/write.php new file mode 100644 index 000000000..47f0574ef --- /dev/null +++ b/inc/Cli/Commands/MemoryCommand/write.php @@ -0,0 +1,112 @@ +//! write — extracted from MemoryCommand.php. + + + /** + * Write to a section of agent memory. + * + * ## OPTIONS + * + *
+ * : Section name (without ##). Created if it does not exist. + * + * + * : Content to write. Use quotes for multi-word content. + * + * [--agent=] + * : Agent slug or numeric ID. + * + * [--mode=] + * : Write mode. + * --- + * default: set + * options: + * - set + * - append + * --- + * + * ## EXAMPLES + * + * # Replace a section + * wp datamachine agent write "State" "- Data Machine v0.30.0 installed" + * + * # Append to a section + * wp datamachine agent write "Lessons Learned" "- Always check file permissions" --mode=append + * + * # Create a new section + * wp datamachine agent write "New Section" "Initial content" + * + * # Write to a specific agent's memory + * wp datamachine agent write "State" "- Studio agent active" --agent=studio + * + * @subcommand write + */ + public function write( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) || empty( $args[1] ) ) { + WP_CLI::error( 'Both section name and content are required.' ); + return; + } + + $section = $args[0]; + $content = $args[1]; + $mode = $assoc_args['mode'] ?? 'set'; + + if ( ! in_array( $mode, $this->valid_modes, true ) ) { + WP_CLI::error( sprintf( 'Invalid mode "%s". Must be one of: %s', $mode, implode( ', ', $this->valid_modes ) ) ); + return; + } + + $scoping = $this->resolveMemoryScoping( $assoc_args ); + + $result = AgentMemoryAbilities::updateMemory( + array_merge( + $scoping, + array( + 'section' => $section, + 'content' => $content, + 'mode' => $mode, + ) + ) + ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ?? 'Failed to write memory.' ); + return; + } + + WP_CLI::success( $result['message'] ); + } + + /** + * Write (replace) a daily memory file. + */ + private function daily_write( DailyMemory $daily, array $args ): void { + // write [date] — date defaults to today. + if ( count( $args ) < 2 ) { + WP_CLI::error( 'Content is required. Usage: wp datamachine agent daily write [date] ' ); + return; + } + + // If 3 args: write date content. If 2 args: write content (today). + if ( count( $args ) >= 3 ) { + $date = $args[1]; + $content = $args[2]; + } else { + $date = gmdate( 'Y-m-d' ); + $content = $args[1]; + } + + $parts = DailyMemory::parse_date( $date ); + if ( ! $parts ) { + WP_CLI::error( sprintf( 'Invalid date format: %s. Use YYYY-MM-DD.', $date ) ); + return; + } + + $result = $daily->write( $parts['year'], $parts['month'], $parts['day'], $content ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['message'] ); + return; + } + + WP_CLI::success( $result['message'] ); + } diff --git a/inc/Cli/Commands/PipelinesCommand.php b/inc/Cli/Commands/PipelinesCommand.php index 62825ef3d..fb7e78e42 100644 --- a/inc/Cli/Commands/PipelinesCommand.php +++ b/inc/Cli/Commands/PipelinesCommand.php @@ -417,7 +417,7 @@ private function extractPipelineLocation( array $flows ): string { } $summary = implode( ' | ', array_unique( $parts ) ); - return $summary ?: '—'; + return $summary ? $summary : '—'; } /** diff --git a/inc/Cli/Commands/ProcessedItemsCommand.php b/inc/Cli/Commands/ProcessedItemsCommand.php index f44208237..b9d3c2484 100644 --- a/inc/Cli/Commands/ProcessedItemsCommand.php +++ b/inc/Cli/Commands/ProcessedItemsCommand.php @@ -84,6 +84,7 @@ public function audit( array $args, array $assoc_args ): void { $where_sql = ! empty( $where_clauses ) ? 'WHERE ' . implode( ' AND ', $where_clauses ) : ''; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $results = $wpdb->get_results( $wpdb->prepare( "SELECT @@ -101,6 +102,7 @@ public function audit( array $args, array $assoc_args ): void { ), ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL if ( empty( $results ) ) { WP_CLI::log( 'No processed items found.' ); @@ -131,13 +133,13 @@ public function audit( array $args, array $assoc_args ): void { } $rows[] = array( - 'flow_id' => $flow_id, - 'flow_name' => $flow['flow_name'] ?? '?', - 'pipeline_id' => $flow['pipeline_id'] ?? '?', - 'handler' => $row['source_type'], - 'processed' => $processed, - 'first_seen' => $row['first_processed'], - 'last_seen' => $row['last_processed'], + 'flow_id' => $flow_id, + 'flow_name' => $flow['flow_name'] ?? '?', + 'pipeline_id' => $flow['pipeline_id'] ?? '?', + 'handler' => $row['source_type'], + 'processed' => $processed, + 'first_seen' => $row['first_processed'], + 'last_seen' => $row['last_processed'], ); } @@ -298,7 +300,7 @@ private function clear_with_filters( array $assoc_args ): void { $flow_patterns = array(); foreach ( $flows as $flow ) { - $flow_patterns[] = "flow_step_id LIKE %s"; + $flow_patterns[] = 'flow_step_id LIKE %s'; $values[] = '%_' . $flow['flow_id']; } $where_parts[] = '(' . implode( ' OR ', $flow_patterns ) . ')'; @@ -323,9 +325,11 @@ private function clear_with_filters( array $assoc_args ): void { // Count first. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i {$where_sql}", ...$values ) ); + // phpcs:enable WordPress.DB.PreparedSQL if ( 0 === $count ) { WP_CLI::log( 'No processed items match the criteria.' ); @@ -368,9 +372,11 @@ private function clear_with_filters( array $assoc_args ): void { // Delete. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM %i {$where_sql}", ...$values ) ); + // phpcs:enable WordPress.DB.PreparedSQL if ( false === $deleted ) { WP_CLI::error( 'Database error during deletion: ' . $wpdb->last_error ); diff --git a/inc/Cli/Commands/RetentionCommand.php b/inc/Cli/Commands/RetentionCommand.php index 11cdb2a6e..59ab633a2 100644 --- a/inc/Cli/Commands/RetentionCommand.php +++ b/inc/Cli/Commands/RetentionCommand.php @@ -69,7 +69,7 @@ public function show( array $args, array $assoc_args ): void { $policy_items = array(); foreach ( $policies as $domain => $policy ) { - $size_info = $sizes[ $domain ] ?? array(); + $size_info = $sizes[ $domain ] ?? array(); $policy_items[] = array( 'domain' => $domain, 'retention' => $policy['retention'], @@ -158,8 +158,8 @@ public function run( array $args, array $assoc_args ): void { } // 3. Logs. - $log_days = (int) apply_filters( 'datamachine_log_max_age_days', 7 ); - $count = $this->count_old_logs( $log_days ); + $log_days = (int) apply_filters( 'datamachine_log_max_age_days', 7 ); + $count = $this->count_old_logs( $log_days ); $results[] = array( 'domain' => 'Pipeline logs', 'threshold' => $log_days . ' days', @@ -186,8 +186,8 @@ public function run( array $args, array $assoc_args ): void { } // 5. Action Scheduler actions + logs. - $as_days = (int) apply_filters( 'datamachine_as_actions_max_age_days', 7 ); - $as_count = $this->count_old_as_actions( $as_days ); + $as_days = (int) apply_filters( 'datamachine_as_actions_max_age_days', 7 ); + $as_count = $this->count_old_as_actions( $as_days ); $results[] = array( 'domain' => 'AS actions + logs', 'threshold' => $as_days . ' days', @@ -286,6 +286,7 @@ private function get_table_sizes(): array { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- Table name from $wpdb->prefix, not user input. $wpdb->prepare( "SELECT table_name, table_rows, ROUND((data_length + index_length) / 1024 / 1024, 1) AS size_mb @@ -295,6 +296,7 @@ private function get_table_sizes(): array { ...$unique_tables ) ); + // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders $table_data = array(); if ( $results ) { @@ -351,6 +353,7 @@ private function count_old_as_actions( int $older_than_days ): int { $logs_table = $wpdb->prefix . 'actionscheduler_logs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $actions_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$actions_table} @@ -359,8 +362,10 @@ private function count_old_as_actions( int $older_than_days ): int { $cutoff ) ); + // phpcs:enable WordPress.DB.PreparedSQL // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $logs_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$logs_table} l @@ -370,6 +375,7 @@ private function count_old_as_actions( int $older_than_days ): int { $cutoff ) ); + // phpcs:enable WordPress.DB.PreparedSQL return $actions_count + $logs_count; } diff --git a/inc/Cli/Commands/TestCommand.php b/inc/Cli/Commands/TestCommand.php index f7cc79314..c97b07246 100644 --- a/inc/Cli/Commands/TestCommand.php +++ b/inc/Cli/Commands/TestCommand.php @@ -140,7 +140,7 @@ private function listHandlers( array $assoc_args ): void { } $items[] = array( - 'slug' => $slug, + 'slug' => $slug, 'label' => $handler['label'] ?? '', 'type' => $handler_type, ); @@ -229,10 +229,10 @@ private function describeHandler( string $handler_slug, array $assoc_args ): voi * @param array $assoc_args Command arguments. */ private function runTest( ?string $handler_slug, array $assoc_args ): void { - $format = $assoc_args['format'] ?? 'table'; + $format = $assoc_args['format'] ?? 'table'; $config_json = $assoc_args['config'] ?? null; - $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; - $limit = (int) ( $assoc_args['limit'] ?? 5 ); + $flow_id = isset( $assoc_args['flow'] ) ? (int) $assoc_args['flow'] : null; + $limit = (int) ( $assoc_args['limit'] ?? 5 ); $config = array(); if ( $config_json ) { @@ -331,7 +331,7 @@ private function renderTableOutput( array $result ): void { WP_CLI::log( '' ); // Build table rows. - $items = array(); + $items = array(); $all_metadata_keys = array(); foreach ( $packets as $index => $packet ) { @@ -342,7 +342,7 @@ private function renderTableOutput( array $result ): void { // Extract domain from source_url. $source_display = ''; if ( $source_url ) { - $parsed = wp_parse_url( $source_url ); + $parsed = wp_parse_url( $source_url ); $source_display = $parsed['host'] ?? $source_url; } diff --git a/inc/Core/ActionScheduler/ActionsCleanup.php b/inc/Core/ActionScheduler/ActionsCleanup.php index d8ea4765f..efb4377f3 100644 --- a/inc/Core/ActionScheduler/ActionsCleanup.php +++ b/inc/Core/ActionScheduler/ActionsCleanup.php @@ -48,6 +48,7 @@ function () { // Delete AS log entries for old completed/failed/canceled actions first (FK-safe order). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $logs_deleted = $wpdb->query( $wpdb->prepare( "DELETE l FROM {$logs_table} l @@ -57,9 +58,11 @@ function () { $cutoff_datetime ) ); + // phpcs:enable WordPress.DB.PreparedSQL // Delete the completed/failed/canceled actions themselves. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $actions_deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$actions_table} @@ -68,6 +71,7 @@ function () { $cutoff_datetime ) ); + // phpcs:enable WordPress.DB.PreparedSQL $total_deleted = ( false !== $logs_deleted ? $logs_deleted : 0 ) + ( false !== $actions_deleted ? $actions_deleted : 0 ); diff --git a/inc/Core/Admin/FlowFormatter.php b/inc/Core/Admin/FlowFormatter.php index aba71fd72..712c8c720 100644 --- a/inc/Core/Admin/FlowFormatter.php +++ b/inc/Core/Admin/FlowFormatter.php @@ -29,7 +29,7 @@ class FlowFormatter { * Cached service instances to avoid re-creation per flow in batch formatting. */ private static ?HandlerAbilities $handler_abilities_cache = null; - private static ?object $settings_display_cache = null; + private static ?object $settings_display_cache = null; public static function format_flow_for_response( array $flow, ?array $latest_job = null, ?array $next_runs = null ): array { $flow_config = $flow['flow_config'] ?? array(); diff --git a/inc/Core/Auth/AgentAuthCallback.php b/inc/Core/Auth/AgentAuthCallback.php index 68edbe912..3640edfb2 100644 --- a/inc/Core/Auth/AgentAuthCallback.php +++ b/inc/Core/Auth/AgentAuthCallback.php @@ -169,6 +169,7 @@ public function handle_callback( \WP_REST_Request $request ) { * @return \WP_REST_Response */ public function list_external_tokens( \WP_REST_Request $request ): \WP_REST_Response { + unset( $request ); $tokens = get_option( self::OPTION_KEY, array() ); $result = array(); diff --git a/inc/Core/Auth/AgentAuthMiddleware.php b/inc/Core/Auth/AgentAuthMiddleware.php index 91e068d6f..6ecd7765a 100644 --- a/inc/Core/Auth/AgentAuthMiddleware.php +++ b/inc/Core/Auth/AgentAuthMiddleware.php @@ -42,6 +42,7 @@ class AgentAuthMiddleware { * Register the authentication filter. */ public function __construct() { + add_action('rest_api_init', array( $this, 'rest_api_init' )); add_filter( 'rest_authentication_errors', array( $this, 'authenticate' ), 90 ); } @@ -147,11 +148,11 @@ public function authenticate( $result ) { 'debug', 'Agent auth: token authenticated', array( - 'agent_id' => $agent_id, - 'agent_slug' => $agent['agent_slug'], - 'owner_id' => $owner_id, - 'token_id' => $token_id, - 'token_label' => $token_record['label'] ?? '', + 'agent_id' => $agent_id, + 'agent_slug' => $agent['agent_slug'], + 'owner_id' => $owner_id, + 'token_id' => $token_id, + 'token_label' => $token_record['label'] ?? '', 'has_cap_restrictions' => null !== $token_capabilities, ) ); diff --git a/inc/Core/Auth/AgentAuthorize.php b/inc/Core/Auth/AgentAuthorize.php index 53bf34eb0..e247d163b 100644 --- a/inc/Core/Auth/AgentAuthorize.php +++ b/inc/Core/Auth/AgentAuthorize.php @@ -190,7 +190,7 @@ public function handle_authorize_post( \WP_REST_Request $request ) { $nonce = $request->get_param( '_wpnonce' ); // Look up agent for redirect URI validation. - $agents_repo = new Agents(); + $agents_repo = new Agents(); $agent_for_uri = $agents_repo->get_by_slug( $agent_slug ); if ( $agent_for_uri ) { @@ -256,11 +256,11 @@ public function handle_authorize_post( \WP_REST_Request $request ) { 'info', 'Agent token issued via authorize flow', array( - 'agent_id' => (int) $agent['agent_id'], - 'agent_slug' => $agent['agent_slug'], - 'user_id' => $user_id, - 'token_id' => $result['token_id'], - 'label' => $token_label, + 'agent_id' => (int) $agent['agent_id'], + 'agent_slug' => $agent['agent_slug'], + 'user_id' => $user_id, + 'token_id' => $result['token_id'], + 'label' => $token_label, ) ); @@ -410,7 +410,7 @@ private function render_consent_screen( array $agent, string $redirect_uri, stri $owner_name = $owner ? esc_html( $owner->display_name ) : 'Unknown'; $user_name = esc_html( $user->display_name ); - $parsed_uri = wp_parse_url( $redirect_uri ); + $parsed_uri = wp_parse_url( $redirect_uri ); $uri_display = esc_html( ( $parsed_uri['host'] ?? '' ) . ( isset( $parsed_uri['port'] ) ? ':' . $parsed_uri['port'] : '' ) ); header( 'Content-Type: text/html; charset=utf-8' ); diff --git a/inc/Core/Database/Agents/AgentAccess.php b/inc/Core/Database/Agents/AgentAccess.php index aa57c1560..d75c45fe9 100644 --- a/inc/Core/Database/Agents/AgentAccess.php +++ b/inc/Core/Database/Agents/AgentAccess.php @@ -12,6 +12,7 @@ namespace DataMachine\Core\Database\Agents; use DataMachine\Core\Database\BaseRepository; +use DataMachine\Core\Database\Agents\Agents; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Core/Database/Agents/AgentTokens.php b/inc/Core/Database/Agents/AgentTokens.php index e22d98933..8030c3194 100644 --- a/inc/Core/Database/Agents/AgentTokens.php +++ b/inc/Core/Database/Agents/AgentTokens.php @@ -16,6 +16,7 @@ namespace DataMachine\Core\Database\Agents; use DataMachine\Core\Database\BaseRepository; +use DataMachine\Core\Database\Agents\Agents; if ( ! defined( 'ABSPATH' ) ) { exit; diff --git a/inc/Core/Database/Logs/LogRepository.php b/inc/Core/Database/Logs/LogRepository.php index 8387886d7..e837ce3ed 100644 --- a/inc/Core/Database/Logs/LogRepository.php +++ b/inc/Core/Database/Logs/LogRepository.php @@ -198,7 +198,7 @@ public function get_logs( array $filters = array() ): array { // Fetch items. $query_params = array_merge( $params, array( $per_page, $offset ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders -- Dynamic query construction with safe values. - $items = $this->wpdb->get_results( + $items = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", ...$query_params @@ -305,10 +305,12 @@ public function get_metadata( ?int $agent_id = null ): array { ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders } else { + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $row = $this->wpdb->get_row( "SELECT COUNT(*) AS total_entries, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM {$this->table_name} WHERE {$where}", ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -346,10 +348,12 @@ public function get_level_counts( ?int $agent_id = null ): array { ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders } else { + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $rows = $this->wpdb->get_results( "SELECT level, COUNT(*) AS cnt FROM {$this->table_name} WHERE {$where} GROUP BY level", ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/inc/Core/Database/PostIdentityIndex/PostIdentityIndex.php b/inc/Core/Database/PostIdentityIndex/PostIdentityIndex.php index 1e04dd58f..636fa8ed5 100644 --- a/inc/Core/Database/PostIdentityIndex/PostIdentityIndex.php +++ b/inc/Core/Database/PostIdentityIndex/PostIdentityIndex.php @@ -123,6 +123,7 @@ public function upsert( int $post_id, array $fields ): bool { $placeholders = implode( ', ', $formats ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- Table name from $wpdb->prefix, not user input. $result = $this->wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->wpdb->prepare( @@ -130,6 +131,7 @@ public function upsert( int $post_id, array $fields ): bool { ...array_values( $data ) ) ); + // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders if ( false === $result ) { $this->log_db_error( 'PostIdentityIndex::upsert', array( 'post_id' => $post_id ) ); @@ -181,6 +183,7 @@ public function find_by_date_and_venue( string $event_date, ?int $venue_term_id if ( null !== $venue_term_id && $venue_term_id > 0 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE event_date = %s AND venue_term_id = %d LIMIT %d", @@ -189,10 +192,20 @@ public function find_by_date_and_venue( string $event_date, ?int $venue_term_id $limit ), ARRAY_A - ) ?: array(); + ) ? $this->wpdb->get_results( + $this->wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE event_date = %s AND venue_term_id = %d LIMIT %d", + $event_date, + $venue_term_id, + $limit + ), + ARRAY_A + ) : array(); + // phpcs:enable WordPress.DB.PreparedSQL } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE event_date = %s LIMIT %d", @@ -200,7 +213,15 @@ public function find_by_date_and_venue( string $event_date, ?int $venue_term_id $limit ), ARRAY_A - ) ?: array(); + ) ? $this->wpdb->get_results( + $this->wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE event_date = %s LIMIT %d", + $event_date, + $limit + ), + ARRAY_A + ) : array(); + // phpcs:enable WordPress.DB.PreparedSQL } /** @@ -218,6 +239,7 @@ public function find_by_date_and_title_hash( string $event_date, string $title_h } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE event_date = %s AND title_hash = %s LIMIT 1", @@ -226,8 +248,9 @@ public function find_by_date_and_title_hash( string $event_date, string $title_h ), ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL - return $row ?: null; + return $row ? $row : null; } /** @@ -246,6 +269,7 @@ public function find_by_ticket_url_and_date( string $ticket_url, string $event_d } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE ticket_url = %s AND event_date = %s LIMIT 1", @@ -254,8 +278,9 @@ public function find_by_ticket_url_and_date( string $ticket_url, string $event_d ), ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL - return $row ?: null; + return $row ? $row : null; } /** @@ -272,6 +297,7 @@ public function find_by_source_url( string $source_url ): ?array { } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE source_url = %s LIMIT 1", @@ -279,8 +305,9 @@ public function find_by_source_url( string $source_url ): ?array { ), ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL - return $row ?: null; + return $row ? $row : null; } /** @@ -298,6 +325,7 @@ public function find_with_ticket_url_on_date( string $event_date, int $limit = 5 } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE event_date = %s AND ticket_url IS NOT NULL AND ticket_url != '' LIMIT %d", @@ -305,7 +333,15 @@ public function find_with_ticket_url_on_date( string $event_date, int $limit = 5 $limit ), ARRAY_A - ) ?: array(); + ) ? $this->wpdb->get_results( + $this->wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE event_date = %s AND ticket_url IS NOT NULL AND ticket_url != '' LIMIT %d", + $event_date, + $limit + ), + ARRAY_A + ) : array(); + // phpcs:enable WordPress.DB.PreparedSQL } /** @@ -323,6 +359,7 @@ public function find_by_date( string $event_date, int $limit = 50 ): array { } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE event_date = %s LIMIT %d", @@ -330,7 +367,15 @@ public function find_by_date( string $event_date, int $limit = 50 ): array { $limit ), ARRAY_A - ) ?: array(); + ) ? $this->wpdb->get_results( + $this->wpdb->prepare( + "SELECT * FROM {$this->table_name} WHERE event_date = %s LIMIT %d", + $event_date, + $limit + ), + ARRAY_A + ) : array(); + // phpcs:enable WordPress.DB.PreparedSQL } // ----------------------------------------------------------------------- @@ -366,6 +411,7 @@ public function find_missing_post_ids( string $post_type, int $limit = 500, int // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_col( $wpdb->prepare( + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$this->table_name} idx ON p.ID = idx.post_id WHERE p.post_type = %s @@ -378,8 +424,9 @@ public function find_missing_post_ids( string $post_type, int $limit = 500, int $offset ) ); + // phpcs:enable WordPress.DB.PreparedSQL - return array_map( 'intval', $results ?: array() ); + return array_map( 'intval', $results ? $results : array() ); } /** @@ -390,12 +437,14 @@ public function find_missing_post_ids( string $post_type, int $limit = 500, int */ public function delete_by_post_type( string $post_type ): int { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. $result = $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$this->table_name} WHERE post_type = %s", $post_type ) ); + // phpcs:enable WordPress.DB.PreparedSQL return false === $result ? 0 : $result; } diff --git a/inc/Core/OAuth/OAuth2Handler.php b/inc/Core/OAuth/OAuth2Handler.php index f8ed74aac..599c19490 100644 --- a/inc/Core/OAuth/OAuth2Handler.php +++ b/inc/Core/OAuth/OAuth2Handler.php @@ -13,6 +13,8 @@ namespace DataMachine\Core\OAuth; use DataMachine\Core\HttpClient; +use DataMachine\Core\OAuth\OAuth1Handler; +use DataMachine\Core\OAuth\OAuth1Handler; if ( ! defined( 'WPINC' ) ) { die; diff --git a/inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php b/inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php index 7e7bb74df..8eee80bf6 100644 --- a/inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php +++ b/inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php @@ -95,15 +95,15 @@ private function fetch_single_post( string $source_url, ExecutionContext $contex 'title' => $data['title'], 'content' => $data['content'], 'metadata' => array( - 'source_type' => 'wordpress_local', - 'item_identifier' => (string) $post_id, - 'original_id' => $post_id, - 'original_title' => $data['title'], - 'original_date_gmt' => $data['publish_date'], - 'post_type' => $data['post_type'], - 'post_status' => $data['post_status'], - 'site_name' => $data['site_name'], - '_engine_data' => array( + 'source_type' => 'wordpress_local', + 'item_identifier' => (string) $post_id, + 'original_id' => $post_id, + 'original_title' => $data['title'], + 'original_date_gmt' => $data['publish_date'], + 'post_type' => $data['post_type'], + 'post_status' => $data['post_status'], + 'site_name' => $data['site_name'], + '_engine_data' => array( 'source_url' => $data['permalink'] ?? '', 'image_file_path' => $image_file_path, ), diff --git a/inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php b/inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php index 62271d0b8..16ecfafb7 100644 --- a/inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php +++ b/inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php @@ -22,6 +22,7 @@ class WordPressMedia extends FetchHandler { use HandlerRegistrationTrait; + use DataMachine\Core\Steps\Fetch\Handlers\Files\Files; public function __construct() { parent::__construct( 'wordpress_media' ); diff --git a/inc/Core/Steps/Fetch/Tools/SkipItemTool.php b/inc/Core/Steps/Fetch/Tools/SkipItemTool.php index 0dfda8849..d3a56e73c 100644 --- a/inc/Core/Steps/Fetch/Tools/SkipItemTool.php +++ b/inc/Core/Steps/Fetch/Tools/SkipItemTool.php @@ -65,7 +65,7 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) // Get item identifier and source type from engine data (set by fetch handler) $item_identifier = $engine->get( 'item_identifier' ); $source_type = $engine->get( 'source_type' ); - $flow_step_id = $parameters['flow_step_id'] ?? $engine->get( 'flow_step_id' ); + $flow_step_id = $parameters['flow_step_id'] ?? $engine->get( 'flow_step_id' ); // Mark item as processed so it won't be refetched if ( $flow_step_id && $item_identifier && $source_type ) { @@ -82,11 +82,11 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) 'info', 'SkipItemTool: Item marked as processed (skipped)', array( - 'job_id' => $job_id, - 'flow_step_id' => $flow_step_id, - 'item_identifier' => $item_identifier, - 'source_type' => $source_type, - 'reason' => $reason, + 'job_id' => $job_id, + 'flow_step_id' => $flow_step_id, + 'item_identifier' => $item_identifier, + 'source_type' => $source_type, + 'reason' => $reason, ) ); } else { @@ -95,11 +95,11 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) 'warning', 'SkipItemTool: Could not mark item as processed - missing identifiers', array( - 'job_id' => $job_id, - 'flow_step_id' => $flow_step_id, - 'item_identifier' => $item_identifier, - 'source_type' => $source_type, - 'reason' => $reason, + 'job_id' => $job_id, + 'flow_step_id' => $flow_step_id, + 'item_identifier' => $item_identifier, + 'source_type' => $source_type, + 'reason' => $reason, ) ); } @@ -120,11 +120,11 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) ); return array( - 'success' => true, - 'message' => "Item skipped: {$reason}", - 'status' => $status->toString(), - 'item_identifier' => $item_identifier, - 'tool_name' => 'skip_item', + 'success' => true, + 'message' => "Item skipped: {$reason}", + 'status' => $status->toString(), + 'item_identifier' => $item_identifier, + 'tool_name' => 'skip_item', ); } } diff --git a/inc/Core/Steps/Publish/Handlers/PublishHandler.php b/inc/Core/Steps/Publish/Handlers/PublishHandler.php index 07bb0ad95..86579f5f7 100644 --- a/inc/Core/Steps/Publish/Handlers/PublishHandler.php +++ b/inc/Core/Steps/Publish/Handlers/PublishHandler.php @@ -18,6 +18,7 @@ use DataMachine\Core\EngineData; use DataMachine\Core\HttpClient; use DataMachine\Core\WordPress\PostTracking; +use DataMachine\Core\Steps\Fetch\Handlers\FetchHandler; defined( 'ABSPATH' ) || exit; diff --git a/inc/Core/Steps/Update/UpdateStep.php b/inc/Core/Steps/Update/UpdateStep.php index 213ae4e32..49e285169 100644 --- a/inc/Core/Steps/Update/UpdateStep.php +++ b/inc/Core/Steps/Update/UpdateStep.php @@ -367,5 +367,4 @@ private function findSuccessfulHandlerResultsBySlug( array $handler_slugs ): arr return $results; } - } diff --git a/inc/Engine/AI/Directives/ClientContextDirective.php b/inc/Engine/AI/Directives/ClientContextDirective.php index dc73bf284..29c97a726 100644 --- a/inc/Engine/AI/Directives/ClientContextDirective.php +++ b/inc/Engine/AI/Directives/ClientContextDirective.php @@ -129,7 +129,7 @@ public static function get_outputs( string $provider_name, array $tools, ?string $lines = array(); foreach ( $client_context as $key => $value ) { - $label = str_replace( '_', ' ', $key ); + $label = str_replace( '_', ' ', $key ); $lines[] = sprintf( '- %s: %s', $label, self::render_value( $value ) ); } @@ -138,7 +138,7 @@ public static function get_outputs( string $provider_name, array $tools, ?string } $content = "# Current Client Context\n\n" - . "The user is interacting with you from a frontend interface. " + . 'The user is interacting with you from a frontend interface. ' . "Here is their current context:\n\n" . implode( "\n", $lines ); diff --git a/inc/Engine/AI/System/SystemAgentServiceProvider.php b/inc/Engine/AI/System/SystemAgentServiceProvider.php index 541e67e97..27408e350 100644 --- a/inc/Engine/AI/System/SystemAgentServiceProvider.php +++ b/inc/Engine/AI/System/SystemAgentServiceProvider.php @@ -60,9 +60,9 @@ private function registerTaskHandlers(): void { * @return array Task handlers including built-in ones. */ public function getBuiltInTasks( array $tasks ): array { - $tasks['image_generation'] = ImageGenerationTask::class; - $tasks['image_optimization'] = ImageOptimizationTask::class; - $tasks['alt_text_generation'] = AltTextTask::class; + $tasks['image_generation'] = ImageGenerationTask::class; + $tasks['image_optimization'] = ImageOptimizationTask::class; + $tasks['alt_text_generation'] = AltTextTask::class; // github_create_issue moved to data-machine-code extension. $tasks['internal_linking'] = InternalLinkingTask::class; $tasks['daily_memory_generation'] = DailyMemoryTask::class; diff --git a/inc/Engine/AI/System/Tasks/AltTextTask.php b/inc/Engine/AI/System/Tasks/AltTextTask.php index c8cd94c7c..d4b6a4e14 100644 --- a/inc/Engine/AI/System/Tasks/AltTextTask.php +++ b/inc/Engine/AI/System/Tasks/AltTextTask.php @@ -16,6 +16,7 @@ use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\RequestBuilder; +use DataMachine\Engine\AI\System\Tasks\Traits\HasSupportsUndo; class AltTextTask extends SystemTask { diff --git a/inc/Engine/AI/System/Tasks/ImageGenerationTask.php b/inc/Engine/AI/System/Tasks/ImageGenerationTask.php index 199255001..f017c32b0 100644 --- a/inc/Engine/AI/System/Tasks/ImageGenerationTask.php +++ b/inc/Engine/AI/System/Tasks/ImageGenerationTask.php @@ -14,6 +14,7 @@ defined( 'ABSPATH' ) || exit; use DataMachine\Core\HttpClient; +use DataMachine\Engine\AI\System\Tasks\Traits\HasSupportsUndo; class ImageGenerationTask extends SystemTask { diff --git a/inc/Engine/AI/System/Tasks/ImageOptimizationTask.php b/inc/Engine/AI/System/Tasks/ImageOptimizationTask.php index 8a07d5830..e48b812b0 100644 --- a/inc/Engine/AI/System/Tasks/ImageOptimizationTask.php +++ b/inc/Engine/AI/System/Tasks/ImageOptimizationTask.php @@ -12,6 +12,7 @@ * @since 0.42.0 */ +use DataMachine\Engine\AI\System\Tasks\Traits\HasSupportsUndo; namespace DataMachine\Engine\AI\System\Tasks; defined( 'ABSPATH' ) || exit; @@ -71,7 +72,7 @@ public function execute( int $jobId, array $params ): void { $file_path = get_attached_file( $attachment_id ); if ( empty( $file_path ) || ! file_exists( $file_path ) ) { - $this->failJob( $jobId, 'Attachment file not found: ' . ( $file_path ?: 'empty path' ) ); + $this->failJob( $jobId, 'Attachment file not found: ' . ( $file_path ? $file_path : 'empty path' ) ); return; } @@ -90,19 +91,19 @@ public function execute( int $jobId, array $params ): void { $compress_result = $this->compressImage( $file_path, $mime_type, $quality, $attachment_id ); if ( $compress_result['success'] ) { - $results['compressed'] = true; - $results['new_size'] = $compress_result['new_size']; - $results['savings'] = $original_size - $compress_result['new_size']; - $results['savings_pct'] = $original_size > 0 ? round( ( $results['savings'] / $original_size ) * 100, 1 ) : 0; + $results['compressed'] = true; + $results['new_size'] = $compress_result['new_size']; + $results['savings'] = $original_size - $compress_result['new_size']; + $results['savings_pct'] = $original_size > 0 ? round( ( $results['savings'] / $original_size ) * 100, 1 ) : 0; $effects[] = array( - 'type' => 'attachment_file_modified', - 'target' => array( + 'type' => 'attachment_file_modified', + 'target' => array( 'attachment_id' => $attachment_id, 'file_path' => $file_path, ), - 'previous_size' => $original_size, - 'new_size' => $compress_result['new_size'], + 'previous_size' => $original_size, + 'new_size' => $compress_result['new_size'], ); // Update attachment metadata with new file size. diff --git a/inc/Engine/AI/System/Tasks/InternalLinkingTask.php b/inc/Engine/AI/System/Tasks/InternalLinkingTask.php index 57571175b..f9c7b9987 100644 --- a/inc/Engine/AI/System/Tasks/InternalLinkingTask.php +++ b/inc/Engine/AI/System/Tasks/InternalLinkingTask.php @@ -18,6 +18,7 @@ use DataMachine\Abilities\Content\ReplacePostBlocksAbility; use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\RequestBuilder; +use DataMachine\Engine\AI\System\Tasks\Traits\HasSupportsUndo; class InternalLinkingTask extends SystemTask { diff --git a/inc/Engine/AI/System/Tasks/MetaDescriptionTask.php b/inc/Engine/AI/System/Tasks/MetaDescriptionTask.php index d8c7a79e4..b68eadb1c 100644 --- a/inc/Engine/AI/System/Tasks/MetaDescriptionTask.php +++ b/inc/Engine/AI/System/Tasks/MetaDescriptionTask.php @@ -20,6 +20,7 @@ use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\RequestBuilder; +use DataMachine\Engine\AI\System\Tasks\Traits\HasSupportsUndo; class MetaDescriptionTask extends SystemTask { diff --git a/inc/Engine/AI/Tools/Global/AgentDailyMemory.php b/inc/Engine/AI/Tools/Global/AgentDailyMemory.php index 21d9d9265..23144d415 100644 --- a/inc/Engine/AI/Tools/Global/AgentDailyMemory.php +++ b/inc/Engine/AI/Tools/Global/AgentDailyMemory.php @@ -17,6 +17,8 @@ use DataMachine\Engine\AI\Tools\BaseTool; use DataMachine\Core\FilesRepository\DirectoryManager; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; +use DataMachine\Engine\AI\Tools\Global\AgentMemory; class AgentDailyMemory extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/AgentMemory.php b/inc/Engine/AI/Tools/Global/AgentMemory.php index 708a744ee..875406b5c 100644 --- a/inc/Engine/AI/Tools/Global/AgentMemory.php +++ b/inc/Engine/AI/Tools/Global/AgentMemory.php @@ -16,6 +16,7 @@ use DataMachine\Engine\AI\Tools\BaseTool; use DataMachine\Core\FilesRepository\DirectoryManager; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; class AgentMemory extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php b/inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php index 138fb84c4..90e48734b 100644 --- a/inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php +++ b/inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php @@ -15,6 +15,7 @@ use DataMachine\Core\HttpClient; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Abilities\Analytics\Traits\HasGetConfig; class AmazonAffiliateLink extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/InternalLinkAudit.php b/inc/Engine/AI/Tools/Global/InternalLinkAudit.php index 5392443d0..c1b09b3cc 100644 --- a/inc/Engine/AI/Tools/Global/InternalLinkAudit.php +++ b/inc/Engine/AI/Tools/Global/InternalLinkAudit.php @@ -19,6 +19,7 @@ defined( 'ABSPATH' ) || exit; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; class InternalLinkAudit extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/LocalSearch.php b/inc/Engine/AI/Tools/Global/LocalSearch.php index 6241d1faa..cc27ffead 100644 --- a/inc/Engine/AI/Tools/Global/LocalSearch.php +++ b/inc/Engine/AI/Tools/Global/LocalSearch.php @@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; class LocalSearch extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/WebFetch.php b/inc/Engine/AI/Tools/Global/WebFetch.php index e008665ad..8faaa0f07 100644 --- a/inc/Engine/AI/Tools/Global/WebFetch.php +++ b/inc/Engine/AI/Tools/Global/WebFetch.php @@ -10,6 +10,7 @@ use DataMachine\Core\HttpClient; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; class WebFetch extends BaseTool { diff --git a/inc/Engine/AI/Tools/Global/WordPressPostReader.php b/inc/Engine/AI/Tools/Global/WordPressPostReader.php index a15d3a8d4..aec6f5b8e 100644 --- a/inc/Engine/AI/Tools/Global/WordPressPostReader.php +++ b/inc/Engine/AI/Tools/Global/WordPressPostReader.php @@ -13,6 +13,7 @@ use DataMachine\Abilities\Fetch\GetWordPressPostAbility; use DataMachine\Engine\AI\Tools\BaseTool; +use DataMachine\Engine\AI\Tools\Global\Traits\HasIsConfigured; class WordPressPostReader extends BaseTool { diff --git a/inc/migrations.php b/inc/migrations.php index 3467b6a47..6ca57ea96 100644 --- a/inc/migrations.php +++ b/inc/migrations.php @@ -12,2289 +12,6 @@ defined( 'ABSPATH' ) || exit; -/** - * Migrate flow_config JSON from legacy singular handler keys to plural. - * - * Converts handler_slug → handler_slugs and handler_config → handler_configs - * in every step of every flow's flow_config JSON. Idempotent: skips rows - * that already use plural keys. - * - * @since 0.39.0 - */ -function datamachine_migrate_handler_keys_to_plural() { - $already_done = get_option( 'datamachine_handler_keys_migrated', false ); - if ( $already_done ) { - return; - } - - global $wpdb; - $table = $wpdb->prefix . 'datamachine_flows'; - - // Check table exists (fresh installs won't have legacy data). - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. - $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); - // phpcs:enable WordPress.DB.PreparedSQL - if ( ! $table_exists ) { - update_option( 'datamachine_handler_keys_migrated', true, true ); - return; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. - $rows = $wpdb->get_results( "SELECT flow_id, flow_config FROM {$table}", ARRAY_A ); - // phpcs:enable WordPress.DB.PreparedSQL - - if ( empty( $rows ) ) { - update_option( 'datamachine_handler_keys_migrated', true, true ); - return; - } - - $migrated = 0; - foreach ( $rows as $row ) { - $flow_config = json_decode( $row['flow_config'], true ); - if ( ! is_array( $flow_config ) ) { - continue; - } - - $changed = false; - foreach ( $flow_config as $step_id => &$step ) { - if ( ! is_array( $step ) ) { - continue; - } - - // Skip flow-level metadata keys. - if ( 'memory_files' === $step_id ) { - continue; - } - - // Already has plural keys — check if singular leftovers need cleanup. - if ( isset( $step['handler_slugs'] ) && is_array( $step['handler_slugs'] ) ) { - // Ensure handler_configs exists when handler_slugs does. - if ( ! isset( $step['handler_configs'] ) || ! is_array( $step['handler_configs'] ) ) { - $primary = $step['handler_slugs'][0] ?? ''; - $config = $step['handler_config'] ?? array(); - $step['handler_configs'] = ! empty( $primary ) ? array( $primary => $config ) : array(); - $changed = true; - } - // Remove any leftover singular keys. - if ( isset( $step['handler_slug'] ) ) { - unset( $step['handler_slug'] ); - $changed = true; - } - if ( isset( $step['handler_config'] ) ) { - unset( $step['handler_config'] ); - $changed = true; - } - continue; - } - - // Convert singular to plural. - $slug = $step['handler_slug'] ?? ''; - $config = $step['handler_config'] ?? array(); - - if ( ! empty( $slug ) ) { - $step['handler_slugs'] = array( $slug ); - $step['handler_configs'] = array( $slug => $config ); - } else { - // Self-configuring steps (agent_ping, webhook_gate, system_task). - $step_type = $step['step_type'] ?? ''; - if ( ! empty( $step_type ) && ! empty( $config ) ) { - $step['handler_slugs'] = array( $step_type ); - $step['handler_configs'] = array( $step_type => $config ); - } else { - $step['handler_slugs'] = array(); - $step['handler_configs'] = array(); - } - } - - unset( $step['handler_slug'], $step['handler_config'] ); - $changed = true; - } - unset( $step ); - - if ( $changed ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->update( - $table, - array( 'flow_config' => wp_json_encode( $flow_config ) ), - array( 'flow_id' => $row['flow_id'] ), - array( '%s' ), - array( '%d' ) - ); - ++$migrated; - } - } - - update_option( 'datamachine_handler_keys_migrated', true, true ); - - if ( $migrated > 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Migrated flow_config handler keys from singular to plural', - array( 'flows_updated' => $migrated ) - ); - } -} - -/** - * Auto-run DB migrations when code version is ahead of stored DB version. - * - * Deploys via rsync/homeboy don't trigger activation hooks, so new columns - * are silently missing until someone manually reactivates. This check runs - * on every request and calls the idempotent activation function when the - * deployed code version exceeds the stored DB schema version. - * - * Pattern used by WooCommerce, bbPress, and most plugins with custom tables. - * - * @since 0.35.0 - */ -function datamachine_maybe_run_migrations() { - $db_version = get_option( 'datamachine_db_version', '0.0.0' ); - - if ( version_compare( $db_version, DATAMACHINE_VERSION, '>=' ) ) { - return; - } - - datamachine_activate_for_site(); - update_option( 'datamachine_db_version', DATAMACHINE_VERSION, true ); -} add_action( 'init', 'datamachine_maybe_run_migrations', 5 ); -/** - * Build scaffold defaults for agent memory files using WordPress site data. - * - * Gathers site metadata, admin info, active plugins, content types, and - * environment details to populate agent files with useful context instead - * of empty placeholder comments. - * - * @since 0.32.0 - * @since 0.51.0 Accepts optional $agent_name for identity-aware SOUL.md scaffolding. - * - * @param string $agent_name Optional agent display name to include in SOUL.md identity. - * @return array Filename => content map for SOUL.md, USER.md, MEMORY.md. - */ -function datamachine_get_scaffold_defaults( string $agent_name = '' ): array { - // --- Site metadata --- - $site_name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'WordPress Site'; - $site_tagline = get_bloginfo( 'description' ); - $site_url = home_url(); - $timezone = wp_timezone_string(); - - // --- Active theme --- - $theme = wp_get_theme(); - $theme_name = $theme->get( 'Name' ) ? $theme->get( 'Name' ) : 'Unknown'; - - // --- Active plugins (exclude Data Machine itself) --- - $active_plugins = get_option( 'active_plugins', array() ); - - // On multisite, include network-activated plugins too. - if ( is_multisite() ) { - $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) ); - $active_plugins = array_unique( array_merge( $active_plugins, $network_plugins ) ); - } - - $plugin_names = array(); - - foreach ( $active_plugins as $plugin_file ) { - if ( 0 === strpos( $plugin_file, 'data-machine/' ) ) { - continue; - } - - $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; - if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { - $plugin_data = get_plugin_data( $plugin_path, false, false ); - $plugin_names[] = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); - } else { - $dir = dirname( $plugin_file ); - $plugin_names[] = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; - } - } - - // --- Content types with counts --- - $content_lines = array(); - $post_types = get_post_types( array( 'public' => true ), 'objects' ); - - foreach ( $post_types as $pt ) { - $count = wp_count_posts( $pt->name ); - $published = isset( $count->publish ) ? (int) $count->publish : 0; - - if ( $published > 0 || in_array( $pt->name, array( 'post', 'page' ), true ) ) { - $content_lines[] = sprintf( '%s: %d published', $pt->label, $published ); - } - } - - // --- Multisite --- - $multisite_line = ''; - if ( is_multisite() ) { - $site_count = get_blog_count(); - $multisite_line = sprintf( - "\n- **Network:** WordPress Multisite with %d site%s", - $site_count, - 1 === $site_count ? '' : 's' - ); - } - - // --- Admin user --- - $admin_email = get_option( 'admin_email', '' ); - $admin_user = $admin_email ? get_user_by( 'email', $admin_email ) : null; - $admin_name = $admin_user ? $admin_user->display_name : ''; - - // --- Versions --- - $wp_version = get_bloginfo( 'version' ); - $php_version = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION; - $dm_version = defined( 'DATAMACHINE_VERSION' ) ? DATAMACHINE_VERSION : 'unknown'; - $created = wp_date( 'Y-m-d' ); - - // --- Build SOUL.md context lines --- - $context_items = array(); - $context_items[] = sprintf( '- **Site:** %s', $site_name ); - - if ( $site_tagline ) { - $context_items[] = sprintf( '- **Tagline:** %s', $site_tagline ); - } - - $context_items[] = sprintf( '- **URL:** %s', $site_url ); - $context_items[] = sprintf( '- **Theme:** %s', $theme_name ); - - if ( $plugin_names ) { - $context_items[] = sprintf( '- **Plugins:** %s', implode( ', ', $plugin_names ) ); - } - - if ( $content_lines ) { - $context_items[] = sprintf( '- **Content:** %s', implode( ' · ', $content_lines ) ); - } - - $context_items[] = sprintf( '- **Timezone:** %s', $timezone ); - - $soul_context = implode( "\n", $context_items ) . $multisite_line; - - // --- SOUL.md --- - $identity_line = ! empty( $agent_name ) - ? "You are **{$agent_name}**, an AI assistant managing {$site_name}." - : "You are an AI assistant managing {$site_name}."; - - $identity_meta = ''; - if ( ! empty( $agent_name ) ) { - $identity_meta = "\n- **Name:** {$agent_name}"; - } - - $soul = << - -## Goals - -MD; - - // --- MEMORY.md --- - $memory = << - -## Context - -MD; - - return array( - 'SOUL.md' => $soul, - 'USER.md' => $user, - 'MEMORY.md' => $memory, - ); -} - -/** - * Build shared SITE.md content from WordPress site data. - * - * This is the single source of truth for site context injected into AI calls. - * Replaces the former SiteContext class + SiteContextDirective which injected - * a duplicate JSON blob at priority 80. Now SITE.md contains all the same - * data in markdown format, injected once via CoreMemoryFilesDirective. - * - * @since 0.36.1 - * @since 0.48.0 Enriched with post counts, taxonomy details, language, timezone, - * site structure, user roles, plugin descriptions, REST namespaces. - * @return string - */ -function datamachine_get_site_scaffold_content(): string { - $site_name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'WordPress Site'; - $site_description = get_bloginfo( 'description' ) ? get_bloginfo( 'description' ) : ''; - $site_url = home_url(); - $language = get_locale(); - $timezone = wp_timezone_string(); - $theme_name = wp_get_theme()->get( 'Name' ) ? wp_get_theme()->get( 'Name' ) : 'Unknown'; - $permalink = get_option( 'permalink_structure', '' ); - - // --- Active plugins with descriptions (exclude Data Machine) --- - $active_plugins = get_option( 'active_plugins', array() ); - - if ( is_multisite() ) { - $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) ); - $active_plugins = array_unique( array_merge( $active_plugins, $network_plugins ) ); - } - - $plugin_entries = array(); - foreach ( $active_plugins as $plugin_file ) { - $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; - if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { - $plugin_data = get_plugin_data( $plugin_path, false, false ); - $plugin_name = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); - $plugin_desc = ! empty( $plugin_data['Description'] ) ? $plugin_data['Description'] : ''; - } else { - $dir = dirname( $plugin_file ); - $plugin_name = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; - $plugin_desc = ''; - } - - if ( 'data-machine' === strtolower( (string) $plugin_name ) || 0 === strpos( $plugin_file, 'data-machine/' ) ) { - continue; - } - - $plugin_entries[] = array( - 'name' => $plugin_name, - 'desc' => $plugin_desc, - ); - } - - // --- Post types with counts --- - $post_types = get_post_types( array( 'public' => true ), 'objects' ); - $post_type_lines = array(); - foreach ( $post_types as $pt ) { - $count = wp_count_posts( $pt->name ); - $published = isset( $count->publish ) ? (int) $count->publish : 0; - $hier = $pt->hierarchical ? 'hierarchical' : 'flat'; - $post_type_lines[] = sprintf( '| %s | %s | %d | %s |', $pt->label, $pt->name, $published, $hier ); - } - - // --- Taxonomies with term counts --- - $taxonomies = get_taxonomies( array( 'public' => true ), 'objects' ); - $taxonomy_lines = array(); - foreach ( $taxonomies as $tax ) { - $term_count = wp_count_terms( array( - 'taxonomy' => $tax->name, - 'hide_empty' => false, - ) ); - if ( is_wp_error( $term_count ) ) { - $term_count = 0; - } - $hier = $tax->hierarchical ? 'hierarchical' : 'flat'; - $associated = implode( ', ', $tax->object_type ?? array() ); - $taxonomy_lines[] = sprintf( '| %s | %s | %d | %s | %s |', $tax->label, $tax->name, (int) $term_count, $hier, $associated ); - } - - // --- Key pages --- - $key_pages = array(); - - $front_page_id = (int) get_option( 'page_on_front', 0 ); - if ( $front_page_id > 0 ) { - $key_pages[] = sprintf( '- **Front page:** %s (ID %d)', get_the_title( $front_page_id ), $front_page_id ); - } - - $blog_page_id = (int) get_option( 'page_for_posts', 0 ); - if ( $blog_page_id > 0 ) { - $key_pages[] = sprintf( '- **Blog page:** %s (ID %d)', get_the_title( $blog_page_id ), $blog_page_id ); - } - - $privacy_page_id = (int) get_option( 'wp_page_for_privacy_policy', 0 ); - if ( $privacy_page_id > 0 ) { - $key_pages[] = sprintf( '- **Privacy page:** %s (ID %d)', get_the_title( $privacy_page_id ), $privacy_page_id ); - } - - $show_on_front = get_option( 'show_on_front', 'posts' ); - $key_pages[] = '- **Homepage displays:** ' . ( 'page' === $show_on_front ? 'static page' : 'latest posts' ); - - // --- Menus --- - $registered_menus = get_registered_nav_menus(); - $menu_locations = get_nav_menu_locations(); - $menu_lines = array(); - - foreach ( $registered_menus as $location => $description ) { - $assigned = 'unassigned'; - if ( ! empty( $menu_locations[ $location ] ) ) { - $menu_obj = wp_get_nav_menu_object( $menu_locations[ $location ] ); - $assigned = $menu_obj ? $menu_obj->name : 'unassigned'; - } - $menu_lines[] = sprintf( '- **%s** (%s): %s', $description, $location, $assigned ); - } - - // --- User roles --- - $wp_roles = wp_roles(); - $role_names = $wp_roles->get_names(); - $default_roles = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); - $custom_roles = array_diff( array_keys( $role_names ), $default_roles ); - $role_lines = array(); - - foreach ( $role_names as $slug => $name ) { - $user_count = count( get_users( array( 'role' => $slug, 'fields' => 'ID', 'number' => 1 ) ) ); - $is_custom = in_array( $slug, $custom_roles, true ) ? ' (custom)' : ''; - $role_lines[] = sprintf( '- %s (`%s`)%s', translate_user_role( $name ), $slug, $is_custom ); - } - - // --- REST API namespaces (custom only) --- - $rest_namespaces = array(); - $builtin_prefixes = array( 'wp/', 'oembed/', 'wp-site-health/' ); - - if ( function_exists( 'rest_get_server' ) && did_action( 'rest_api_init' ) ) { - $routes = rest_get_server()->get_namespaces(); - foreach ( $routes as $namespace ) { - $is_builtin = false; - foreach ( $builtin_prefixes as $prefix ) { - if ( 0 === strpos( $namespace . '/', $prefix ) || 'wp' === $namespace ) { - $is_builtin = true; - break; - } - } - if ( ! $is_builtin ) { - $rest_namespaces[] = $namespace; - } - } - } - - // --- Build SITE.md --- - $lines = array(); - $lines[] = '# SITE'; - $lines[] = ''; - $lines[] = '## Identity'; - $lines[] = '- **name:** ' . $site_name; - if ( ! empty( $site_description ) ) { - $lines[] = '- **description:** ' . $site_description; - } - $lines[] = '- **url:** ' . $site_url; - $lines[] = '- **theme:** ' . $theme_name; - $lines[] = '- **language:** ' . $language; - $lines[] = '- **timezone:** ' . $timezone; - if ( ! empty( $permalink ) ) { - $lines[] = '- **permalinks:** ' . $permalink; - } - $lines[] = '- **multisite:** ' . ( is_multisite() ? 'true' : 'false' ); - $lines[] = ''; - - // --- Site Structure --- - $lines[] = '## Site Structure'; - - foreach ( $key_pages as $page_line ) { - $lines[] = $page_line; - } - $lines[] = ''; - - if ( ! empty( $menu_lines ) ) { - $lines[] = '### Menus'; - foreach ( $menu_lines as $menu_line ) { - $lines[] = $menu_line; - } - $lines[] = ''; - } - - // --- Content Model --- - $lines[] = '## Post Types'; - $lines[] = '| Label | Slug | Published | Type |'; - $lines[] = '|-------|------|-----------|------|'; - foreach ( $post_type_lines as $line ) { - $lines[] = $line; - } - $lines[] = ''; - - $lines[] = '## Taxonomies'; - $lines[] = '| Label | Slug | Terms | Type | Post Types |'; - $lines[] = '|-------|------|-------|------|------------|'; - foreach ( $taxonomy_lines as $line ) { - $lines[] = $line; - } - $lines[] = ''; - - // --- User Roles --- - if ( ! empty( $custom_roles ) ) { - $lines[] = '## User Roles'; - foreach ( $role_lines as $role_line ) { - $lines[] = $role_line; - } - $lines[] = ''; - } - - // --- Active Plugins with descriptions --- - $lines[] = '## Active Plugins'; - if ( ! empty( $plugin_entries ) ) { - foreach ( $plugin_entries as $entry ) { - $desc_suffix = ''; - if ( ! empty( $entry['desc'] ) ) { - // Truncate long descriptions to keep SITE.md scannable. - $desc = wp_strip_all_tags( $entry['desc'] ); - if ( strlen( $desc ) > 120 ) { - $desc = substr( $desc, 0, 117 ) . '...'; - } - $desc_suffix = ' — ' . $desc; - } - $lines[] = '- **' . $entry['name'] . '**' . $desc_suffix; - } - } else { - $lines[] = '- (none)'; - } - - // --- REST API namespaces --- - if ( ! empty( $rest_namespaces ) ) { - $lines[] = ''; - $lines[] = '## REST API'; - $lines[] = '- **Custom namespaces:** ' . implode( ', ', $rest_namespaces ); - } - - $content = implode( "\n", $lines ) . "\n"; - - /** - * Filter the auto-generated SITE.md content. - * - * Allows plugins and themes to append or modify the site context - * that is injected into AI agent calls. SITE.md is read-only in the - * admin UI; this filter is the only extension point. - * - * @since 0.50.0 - * - * @param string $content The generated SITE.md markdown content. - */ - return apply_filters( 'datamachine_site_scaffold_content', $content ); -} - -/** - * Regenerate SITE.md on disk from current WordPress state. - * - * Called by invalidation hooks when site structure changes (plugins, - * themes, post types, taxonomies, options). Debounced via a short-lived - * transient to avoid excessive writes during bulk operations. - * - * SITE.md is read-only — it is fully regenerated from live WordPress data. - * To extend SITE.md content, use the `datamachine_site_scaffold_content` filter. - * - * @since 0.48.0 - * @since 0.50.0 Removed marker; SITE.md is now read-only. - * @return void - */ -function datamachine_regenerate_site_md(): void { - // Debounce: skip if we regenerated in the last 60 seconds. - if ( get_transient( 'datamachine_site_md_regenerating' ) ) { - return; - } - set_transient( 'datamachine_site_md_regenerating', 1, 60 ); - - // Check the setting — if disabled, skip regeneration. - if ( ! \DataMachine\Core\PluginSettings::get( 'site_context_enabled', true ) ) { - return; - } - - $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); - $shared_dir = $directory_manager->get_shared_directory(); - $site_md_path = trailingslashit( $shared_dir ) . 'SITE.md'; - - $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); - if ( ! $fs ) { - return; - } - - $content = datamachine_get_site_scaffold_content(); - - if ( ! is_dir( $shared_dir ) ) { - wp_mkdir_p( $shared_dir ); - } - - $fs->put_contents( $site_md_path, $content, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $site_md_path ); -} - -/** - * Register hooks that trigger SITE.md regeneration on structural changes. - * - * These are the same hooks that SiteContext used for cache invalidation, - * but now they regenerate the actual file on disk. The debounce in - * datamachine_regenerate_site_md() prevents excessive writes. - * - * @since 0.48.0 - * @return void - */ -function datamachine_register_site_md_invalidation(): void { - $callback = 'datamachine_regenerate_site_md'; - - // Plugin/theme structural changes — always regenerate. - add_action( 'switch_theme', $callback ); - add_action( 'activated_plugin', $callback ); - add_action( 'deactivated_plugin', $callback ); - - // Post lifecycle — updates published counts. - add_action( 'save_post', $callback ); - add_action( 'delete_post', $callback ); - add_action( 'wp_trash_post', $callback ); - add_action( 'untrash_post', $callback ); - - // Term lifecycle — updates term counts. - add_action( 'create_term', $callback ); - add_action( 'edit_term', $callback ); - add_action( 'delete_term', $callback ); - - // Site identity and structure changes. - add_action( 'update_option_blogname', $callback ); - add_action( 'update_option_blogdescription', $callback ); - add_action( 'update_option_home', $callback ); - add_action( 'update_option_siteurl', $callback ); - add_action( 'update_option_permalink_structure', $callback ); - add_action( 'update_option_page_on_front', $callback ); - add_action( 'update_option_page_for_posts', $callback ); - add_action( 'update_option_show_on_front', $callback ); - - // Menu changes. - add_action( 'wp_update_nav_menu', $callback ); - add_action( 'wp_delete_nav_menu', $callback ); - add_action( 'wp_update_nav_menu_item', $callback ); -} - -/** - * Regenerate NETWORK.md from live WordPress multisite data. - * - * Same pattern as datamachine_regenerate_site_md(): - * - 60-second debounce via transient - * - Respects site_context_enabled setting - * - Only runs on multisite installs - * - * NETWORK.md is read-only — fully regenerated from live multisite data. - * To extend NETWORK.md content, use the `datamachine_network_scaffold_content` filter. - * - * @since 0.49.1 - * @since 0.50.0 Removed marker; NETWORK.md is now read-only. - * @return void - */ -function datamachine_regenerate_network_md(): void { - if ( ! is_multisite() ) { - return; - } - - // Debounce: skip if we regenerated in the last 60 seconds. - // Use a network-wide transient so subsites don't each trigger a write. - if ( get_site_transient( 'datamachine_network_md_regenerating' ) ) { - return; - } - set_site_transient( 'datamachine_network_md_regenerating', 1, 60 ); - - // Check the setting — if disabled, skip regeneration. - if ( ! \DataMachine\Core\PluginSettings::get( 'site_context_enabled', true ) ) { - return; - } - - $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); - $network_dir = $directory_manager->get_network_directory(); - $network_md_path = trailingslashit( $network_dir ) . 'NETWORK.md'; - - $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); - if ( ! $fs ) { - return; - } - - $content = datamachine_get_network_scaffold_content(); - - if ( empty( $content ) ) { - return; - } - - if ( ! is_dir( $network_dir ) ) { - wp_mkdir_p( $network_dir ); - } - - $fs->put_contents( $network_md_path, $content, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_md_path ); -} - -/** - * Register hooks that trigger NETWORK.md regeneration on structural changes. - * - * Only registers on multisite installs. Covers site lifecycle, URL changes, - * network plugin activations, and theme switches. The debounce in - * datamachine_regenerate_network_md() prevents excessive writes. - * - * @since 0.49.1 - * @return void - */ -function datamachine_register_network_md_invalidation(): void { - if ( ! is_multisite() ) { - return; - } - - $callback = 'datamachine_regenerate_network_md'; - - // Site lifecycle — new sites, deleted sites. - add_action( 'wp_initialize_site', $callback ); - add_action( 'wp_delete_site', $callback ); - add_action( 'wp_uninitialize_site', $callback ); - - // Site identity changes — URL or name changes on any site. - add_action( 'update_option_siteurl', $callback ); - add_action( 'update_option_home', $callback ); - add_action( 'update_option_blogname', $callback ); - - // Plugin/theme structural changes — affects network plugin list. - add_action( 'activated_plugin', $callback ); - add_action( 'deactivated_plugin', $callback ); - add_action( 'switch_theme', $callback ); -} - -/** - * Migrate existing user_id-scoped agent files to layered architecture. - * - * Idempotent migration that: - * - Creates shared/ SITE.md - * - Creates agents/{slug}/ and users/{user_id}/ - * - Copies SOUL.md + MEMORY.md to agent layer - * - Copies USER.md to user layer - * - Creates datamachine_agents rows (one per user-owned legacy agent dir) - * - Backfills chat_sessions.agent_id - * - * @since 0.36.1 - * @return void - */ -function datamachine_migrate_to_layered_architecture(): void { - if ( get_option( 'datamachine_layered_arch_migrated', false ) ) { - return; - } - - $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); - $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); - - if ( ! $fs ) { - return; - } - - $legacy_agent_base = $directory_manager->get_agent_directory(); // .../datamachine-files/agent - $shared_dir = $directory_manager->get_shared_directory(); - - update_option( - 'datamachine_layered_arch_migration_backup', - array( - 'legacy_agent_base' => $legacy_agent_base, - 'migrated_at' => current_time( 'mysql', true ), - ), - false - ); - - if ( ! is_dir( $shared_dir ) ) { - wp_mkdir_p( $shared_dir ); - } - - $site_md = trailingslashit( $shared_dir ) . 'SITE.md'; - if ( ! file_exists( $site_md ) ) { - $fs->put_contents( $site_md, datamachine_get_site_scaffold_content(), FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $site_md ); - } - - $index_file = trailingslashit( $shared_dir ) . 'index.php'; - if ( ! file_exists( $index_file ) ) { - $fs->put_contents( $index_file, "user_login ) : 'user-' . $user_id; - $agent_name = $user ? $user->display_name : 'User ' . $user_id; - $agent_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); - - $agent_id = $agents_repo->create_if_missing( - $agent_slug, - $agent_name, - $user_id, - array( - 'model' => array( - 'default' => $agent_model, - ), - ) - ); - - $agent_identity_dir = $directory_manager->get_agent_identity_directory( $agent_slug ); - $user_dir = $directory_manager->get_user_directory( $user_id ); - - if ( ! is_dir( $agent_identity_dir ) ) { - wp_mkdir_p( $agent_identity_dir ); - } - if ( ! is_dir( $user_dir ) ) { - wp_mkdir_p( $user_dir ); - } - - $agent_index = trailingslashit( $agent_identity_dir ) . 'index.php'; - if ( ! file_exists( $agent_index ) ) { - $fs->put_contents( $agent_index, "put_contents( $user_index, "copy( $legacy_soul, $new_soul, true, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_soul ); - } - if ( file_exists( $legacy_memory ) && ! file_exists( $new_memory ) ) { - $fs->copy( $legacy_memory, $new_memory, true, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_memory ); - } - if ( file_exists( $legacy_user ) && ! file_exists( $new_user ) ) { - $fs->copy( $legacy_user, $new_user, true, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_user ); - } elseif ( ! file_exists( $new_user ) ) { - $user_profile_lines = array(); - $user_profile_lines[] = '# User Profile'; - $user_profile_lines[] = ''; - $user_profile_lines[] = '## About'; - $user_profile_lines[] = '- **Name:** ' . ( $user ? $user->display_name : 'User ' . $user_id ); - if ( $user && ! empty( $user->user_email ) ) { - $user_profile_lines[] = '- **Email:** ' . $user->user_email; - } - $user_profile_lines[] = '- **User ID:** ' . $user_id; - $user_profile_lines[] = ''; - $user_profile_lines[] = '## Preferences'; - $user_profile_lines[] = ''; - - $fs->put_contents( $new_user, implode( "\n", $user_profile_lines ) . "\n", FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_user ); - } - - if ( is_dir( $legacy_daily ) && ! is_dir( $new_daily ) ) { - datamachine_copy_directory_recursive( $legacy_daily, $new_daily ); - } - - // Backfill chat sessions for this user. - global $wpdb; - $chat_table = $chat_db->get_table_name(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( - $wpdb->prepare( - 'UPDATE %i SET agent_id = %d WHERE user_id = %d AND (agent_id IS NULL OR agent_id = 0)', - $chat_table, - $agent_id, - $user_id - ) - ); - } - } - - // Single-agent case: .md files live directly in agent/ with no numeric subdirs. - // This is the most common layout for sites that never had multi-user partitioning. - $legacy_md_files = glob( trailingslashit( $legacy_agent_base ) . '*.md' ); - - if ( ! empty( $legacy_md_files ) ) { - $default_user_id = \DataMachine\Core\FilesRepository\DirectoryManager::get_default_agent_user_id(); - $default_user = get_user_by( 'id', $default_user_id ); - $default_slug = $default_user ? sanitize_title( $default_user->user_login ) : 'user-' . $default_user_id; - $default_name = $default_user ? $default_user->display_name : 'User ' . $default_user_id; - $default_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); - - $agents_repo->create_if_missing( - $default_slug, - $default_name, - $default_user_id, - array( - 'model' => array( - 'default' => $default_model, - ), - ) - ); - - $default_identity_dir = $directory_manager->get_agent_identity_directory( $default_slug ); - $default_user_dir = $directory_manager->get_user_directory( $default_user_id ); - - if ( ! is_dir( $default_identity_dir ) ) { - wp_mkdir_p( $default_identity_dir ); - } - if ( ! is_dir( $default_user_dir ) ) { - wp_mkdir_p( $default_user_dir ); - } - - $default_agent_index = trailingslashit( $default_identity_dir ) . 'index.php'; - if ( ! file_exists( $default_agent_index ) ) { - $fs->put_contents( $default_agent_index, "put_contents( $default_user_index, "copy( $legacy_file, $dest, true, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $dest ); - } - } - - // Migrate daily memory directory. - $legacy_daily = trailingslashit( $legacy_agent_base ) . 'daily'; - $new_daily = trailingslashit( $default_identity_dir ) . 'daily'; - - if ( is_dir( $legacy_daily ) && ! is_dir( $new_daily ) ) { - datamachine_copy_directory_recursive( $legacy_daily, $new_daily ); - } - } - - update_option( 'datamachine_layered_arch_migrated', 1, false ); -} - -/** - * Copy directory contents recursively without deleting source. - * - * Existing destination files are preserved. - * - * @since 0.36.1 - * @param string $source_dir Source directory path. - * @param string $target_dir Target directory path. - * @return void - */ -function datamachine_copy_directory_recursive( string $source_dir, string $target_dir ): void { - if ( ! is_dir( $source_dir ) ) { - return; - } - - $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); - if ( ! $fs ) { - return; - } - - if ( ! is_dir( $target_dir ) ) { - wp_mkdir_p( $target_dir ); - } - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $source_dir, RecursiveDirectoryIterator::SKIP_DOTS ), - RecursiveIteratorIterator::SELF_FIRST - ); - - foreach ( $iterator as $item ) { - $source_path = $item->getPathname(); - $relative = ltrim( str_replace( $source_dir, '', $source_path ), DIRECTORY_SEPARATOR ); - $target_path = trailingslashit( $target_dir ) . $relative; - - if ( $item->isDir() ) { - if ( ! is_dir( $target_path ) ) { - wp_mkdir_p( $target_path ); - } - continue; - } - - if ( file_exists( $target_path ) ) { - continue; - } - - $parent = dirname( $target_path ); - if ( ! is_dir( $parent ) ) { - wp_mkdir_p( $parent ); - } - - $fs->copy( $source_path, $target_path, true, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $target_path ); - } -} - -/** - * Create default agent memory files if they don't exist. - * - * Called on activation and lazily on any request that reads agent files - * (via DirectoryManager::ensure_agent_files()). Existing files are never - * overwritten — only missing files are recreated from scaffold defaults. - * - * @since 0.30.0 - */ -function datamachine_ensure_default_memory_files() { - $ability = \DataMachine\Abilities\File\ScaffoldAbilities::get_ability(); - if ( ! $ability ) { - return; - } - - $default_user_id = \DataMachine\Core\FilesRepository\DirectoryManager::get_default_agent_user_id(); - - $ability->execute( array( 'layer' => 'agent', 'user_id' => $default_user_id ) ); - $ability->execute( array( 'layer' => 'user', 'user_id' => $default_user_id ) ); - - // Scaffold default context memory files (contexts/{context}.md). - datamachine_ensure_default_context_files( $default_user_id ); -} - -/** - * Scaffold default context memory files (contexts/{context}.md). - * - * Creates the contexts/ directory and writes default context files - * for each core execution context. Existing files are never overwritten. - * - * @since 0.58.0 - * - * @param int $user_id Default agent user ID. - */ -function datamachine_ensure_default_context_files( int $user_id ): void { - $dm = new \DataMachine\Core\FilesRepository\DirectoryManager(); - $contexts_dir = $dm->get_contexts_directory( array( 'user_id' => $user_id ) ); - - if ( ! $dm->ensure_directory_exists( $contexts_dir ) ) { - return; - } - - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - - $defaults = datamachine_get_default_context_files(); - - foreach ( $defaults as $slug => $content ) { - $filepath = trailingslashit( $contexts_dir ) . $slug . '.md'; - if ( file_exists( $filepath ) ) { - continue; - } - $wp_filesystem->put_contents( $filepath, $content, FS_CHMOD_FILE ); - } -} - -/** - * Get default context file contents. - * - * Each key is the context slug (filename without .md extension). - * These replace the former hardcoded ChatContextDirective, - * PipelineContextDirective, and SystemContextDirective PHP classes. - * - * @since 0.58.0 - * @return array Context slug => markdown content. - */ -function datamachine_get_default_context_files(): array { - $defaults = array( - 'chat' => datamachine_default_chat_context(), - 'pipeline' => datamachine_default_pipeline_context(), - 'system' => datamachine_default_system_context(), - ); - - /** - * Filter the default context file contents. - * - * Extensions can add their own context defaults (e.g. 'editor') - * or modify the core defaults before scaffolding. - * - * @since 0.58.0 - * - * @param array $defaults Context slug => markdown content. - */ - return apply_filters( 'datamachine_default_context_files', $defaults ); -} - -/** - * Default chat context (replaces ChatContextDirective). - * - * @since 0.58.0 - */ -function datamachine_default_chat_context(): string { - return <<<'MD' -# Chat Session Context - -This is a live chat session with a user in the Data Machine admin UI. You have tools to configure and manage workflows. Your identity, voice, and knowledge come from your memory files above. - -## Data Machine Architecture - -HANDLERS are the core intelligence. Fetch handlers extract and structure source data. Update/publish handlers apply changes with schema defaults for unconfigured fields. Each handler has a settings schema — only use documented fields. - -PIPELINES define workflow structure: step types in sequence (e.g., event_import → ai → upsert). The pipeline system_prompt defines AI behavior shared by all flows. - -FLOWS are configured pipeline instances. Each step needs a handler_slug and handler_config. When creating flows, match handler configurations from existing flows on the same pipeline. - -AI STEPS process data that handlers cannot automatically handle. Flow user_message is rarely needed; only for minimal source-specific overrides. - -## Discovery - -You receive a pipeline inventory with existing flows and their handlers. Use `api_query` for detailed configuration. Query existing flows before creating new ones to learn established patterns. - -## Configuration Rules - -- Only use documented handler_config fields — unknown fields are rejected. -- Use pipeline_step_id from the inventory to target steps. -- Unconfigured handler fields use schema defaults automatically. -- Act first — if the user gives executable instructions, execute them. - -## Scheduling - -- Scheduling uses intervals only (daily, hourly, etc.), not specific times of day. -- Valid intervals are provided in the tool definitions. Use update_flow to change schedules. - -## Execution Protocol - -- Only confirm task completion after a successful tool result. Never claim success on error. -- Check error_type on failure: not_found/permission → report, validation → fix and retry, system → retry once. -- If a tool rejects unknown fields, retry with only the valid fields listed in the error. -- Act decisively — execute tools directly for routine configuration. -- If uncertain about a value, use sensible defaults and note the assumption. -MD; -} - -/** - * Default pipeline context (replaces PipelineContextDirective). - * - * @since 0.58.0 - */ -function datamachine_default_pipeline_context(): string { - return <<<'MD' -# Pipeline Execution Context - -This is an automated pipeline step — not a chat session. You're processing data through a multi-step workflow. Your identity and knowledge come from your memory files above. Apply that context to the content you process. - -## How Pipelines Work - -- Each pipeline step has a specific purpose within the overall workflow -- Handler tools produce final results — execute once per workflow objective -- Analyze available data and context before taking action - -## Data Packet Structure - -You receive content as JSON data packets with these guaranteed fields: -- type: The step type that created this packet -- timestamp: When the packet was created - -Additional fields may include data, metadata, content, and handler-specific information. -MD; -} - -/** - * Default system context (replaces SystemContextDirective). - * - * @since 0.58.0 - */ -function datamachine_default_system_context(): string { - return <<<'MD' -# System Task Context - -This is a background system task — not a chat session. You are the internal agent responsible for automated housekeeping: generating session titles, summarizing content, and other system-level operations. - -Your identity and knowledge are already loaded from your memory files above. Use that context. - -## Task Behavior - -- Execute the task described in the user message below. -- Return exactly what the task asks for — no extra commentary, no meta-discussion. -- Apply your knowledge of this site, its voice, and its conventions from your memory files. - -## Session Title Generation - -When asked to generate a chat session title: create a concise, descriptive title (3-6 words) capturing the discussion essence. Return ONLY the title text, under 100 characters. -MD; -} - -/** - * Resolve agent display name from scaffolding context. - * - * Looks up the agent record from the provided context identifiers - * (agent_slug, agent_id, or user_id) and returns the display name. - * Returns empty string when no agent can be resolved. - * - * @since 0.51.0 - * - * @param array $context Scaffolding context with agent_slug, agent_id, or user_id. - * @return string Agent display name, or empty string. - */ -function datamachine_resolve_agent_name_from_context( array $context ): string { - if ( ! class_exists( '\\DataMachine\\Core\\Database\\Agents\\Agents' ) ) { - return ''; - } - - $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); - - // 1) Explicit agent_slug. - if ( ! empty( $context['agent_slug'] ) ) { - $agent = $agents_repo->get_by_slug( sanitize_title( (string) $context['agent_slug'] ) ); - if ( ! empty( $agent['agent_name'] ) ) { - return (string) $agent['agent_name']; - } - } - - // 2) Agent ID. - $agent_id = (int) ( $context['agent_id'] ?? 0 ); - if ( $agent_id > 0 ) { - $agent = $agents_repo->get_agent( $agent_id ); - if ( ! empty( $agent['agent_name'] ) ) { - return (string) $agent['agent_name']; - } - } - - // 3) User ID → owner lookup. - $user_id = (int) ( $context['user_id'] ?? 0 ); - if ( $user_id > 0 ) { - $agent = $agents_repo->get_by_owner_id( $user_id ); - if ( ! empty( $agent['agent_name'] ) ) { - return (string) $agent['agent_name']; - } - } - - return ''; -} - -/** - * Register default content generators for datamachine/scaffold-memory-file. - * - * Each generator handles one filename and builds content from the - * context array (user_id, agent_slug, etc.). Generators are composable - * via the `datamachine_scaffold_content` filter — plugins can override - * or extend any file's default content. - * - * @since 0.50.0 - */ -function datamachine_register_scaffold_generators(): void { - add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_user_content', 10, 3 ); - add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_soul_content', 10, 3 ); - add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_memory_content', 10, 3 ); - add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_daily_content', 10, 3 ); - add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_rules_content', 10, 3 ); -} add_action( 'plugins_loaded', 'datamachine_register_scaffold_generators', 5 ); - -/** - * Generate USER.md content from WordPress user profile data. - * - * @since 0.50.0 - * - * @param string $content Current content (empty if no prior generator). - * @param string $filename Filename being scaffolded. - * @param array $context Scaffolding context with user_id. - * @return string - */ -function datamachine_scaffold_user_content( string $content, string $filename, array $context ): string { - if ( 'USER.md' !== $filename || '' !== $content ) { - return $content; - } - - $user_id = (int) ( $context['user_id'] ?? 0 ); - if ( $user_id <= 0 ) { - return $content; - } - - $user = get_user_by( 'id', $user_id ); - if ( ! $user ) { - return $content; - } - - $about_lines = array(); - $about_lines[] = sprintf( '- **Name:** %s', $user->display_name ); - $about_lines[] = sprintf( '- **Username:** %s', $user->user_login ); - - $roles = $user->roles; - if ( ! empty( $roles ) ) { - $role_name = ucfirst( reset( $roles ) ); - $about_lines[] = sprintf( '- **Role:** %s', $role_name ); - } - - if ( ! empty( $user->user_registered ) ) { - $registered = wp_date( 'F Y', strtotime( $user->user_registered ) ); - $about_lines[] = sprintf( '- **Member since:** %s', $registered ); - } - - $post_count = count_user_posts( $user_id, 'post', true ); - if ( $post_count > 0 ) { - $about_lines[] = sprintf( '- **Published posts:** %d', $post_count ); - } - - $description = get_user_meta( $user_id, 'description', true ); - if ( ! empty( $description ) ) { - $clean_bio = wp_strip_all_tags( $description ); - $about_lines[] = sprintf( "\n%s", $clean_bio ); - } - - $about = implode( "\n", $about_lines ); - - return << - -## Goals - -MD; -} - -/** - * Generate SOUL.md content from site and agent context. - * - * Uses scaffolding context (agent_slug, agent_id) to resolve the agent's - * display name from the database and embed it in the identity section. - * Falls back to the generic template when no agent context is available. - * - * @since 0.50.0 - * @since 0.51.0 Resolves agent_name from context for identity-aware scaffolding. - * - * @param string $content Current content. - * @param string $filename Filename being scaffolded. - * @param array $context Scaffolding context with agent_slug, agent_id, or user_id. - * @return string - */ -function datamachine_scaffold_soul_content( string $content, string $filename, array $context ): string { - if ( 'SOUL.md' !== $filename || '' !== $content ) { - return $content; - } - - // Resolve agent identity from context. - $agent_name = datamachine_resolve_agent_name_from_context( $context ); - - $defaults = datamachine_get_scaffold_defaults( $agent_name ); - return $defaults['SOUL.md'] ?? ''; -} - -/** - * Generate MEMORY.md content from site context. - * - * @since 0.50.0 - * - * @param string $content Current content. - * @param string $filename Filename being scaffolded. - * @param array $context Scaffolding context. - * @return string - */ -function datamachine_scaffold_memory_content( string $content, string $filename, array $context ): string { - if ( 'MEMORY.md' !== $filename || '' !== $content ) { - return $content; - } - - $defaults = datamachine_get_scaffold_defaults(); - return $defaults['MEMORY.md'] ?? ''; -} - -/** - * Generate RULES.md scaffold content. - * - * Creates a starter template for site-wide behavioral constraints. - * RULES.md is admin-editable and applies to every agent on the site. - * - * @since 0.50.0 - * - * @param string $content Current content. - * @param string $filename Filename being scaffolded. - * @param array $context Scaffolding context. - * @return string - */ -function datamachine_scaffold_rules_content( string $content, string $filename, array $context ): string { - if ( 'RULES.md' !== $filename || '' !== $content ) { - return $content; - } - - $site_name = get_bloginfo( 'name' ) ?: 'this site'; - - return << 0 but no agent_id, looks up the agent - * via Agents::get_by_owner_id() and sets agent_id. Also bootstraps agent_access - * rows so owners have admin access to their agents. - * - * Idempotent: only processes rows where agent_id IS NULL and user_id > 0. - * Skipped entirely on fresh installs (no rows to backfill). - * - * @since 0.41.0 - */ -function datamachine_backfill_agent_ids(): void { - if ( get_option( 'datamachine_agent_ids_backfilled', false ) ) { - return; - } - - global $wpdb; - - $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); - $access_repo = new \DataMachine\Core\Database\Agents\AgentAccess(); - - $tables = array( - $wpdb->prefix . 'datamachine_pipelines', - $wpdb->prefix . 'datamachine_flows', - $wpdb->prefix . 'datamachine_jobs', - ); - - // Cache of user_id → agent_id to avoid repeated lookups. - $agent_map = array(); - $backfilled = 0; - - foreach ( $tables as $table ) { - // Check table exists. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); - if ( ! $table_exists ) { - continue; - } - - // Check agent_id column exists (migration may not have run yet). - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $col = $wpdb->get_var( - $wpdb->prepare( - "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'agent_id'", - DB_NAME, - $table - ) - ); - if ( null === $col ) { - continue; - } - - // Get distinct user_ids that need backfill. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - $user_ids = $wpdb->get_col( - "SELECT DISTINCT user_id FROM {$table} WHERE user_id > 0 AND agent_id IS NULL" - ); - // phpcs:enable WordPress.DB.PreparedSQL - - if ( empty( $user_ids ) ) { - continue; - } - - foreach ( $user_ids as $user_id ) { - $user_id = (int) $user_id; - - if ( ! isset( $agent_map[ $user_id ] ) ) { - $agent = $agents_repo->get_by_owner_id( $user_id ); - if ( $agent ) { - $agent_map[ $user_id ] = (int) $agent['agent_id']; - - // Bootstrap agent_access for owner. - $access_repo->bootstrap_owner_access( (int) $agent['agent_id'], $user_id ); - } else { - // Try to create agent for this user. - $created_id = datamachine_resolve_or_create_agent_id( $user_id ); - $agent_map[ $user_id ] = $created_id; - - if ( $created_id > 0 ) { - $access_repo->bootstrap_owner_access( $created_id, $user_id ); - } - } - } - - $agent_id = $agent_map[ $user_id ]; - if ( $agent_id <= 0 ) { - continue; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - $updated = $wpdb->query( - $wpdb->prepare( - "UPDATE {$table} SET agent_id = %d WHERE user_id = %d AND agent_id IS NULL", - $agent_id, - $user_id - ) - ); - // phpcs:enable WordPress.DB.PreparedSQL - - if ( false !== $updated ) { - $backfilled += $updated; - } - } - } - - update_option( 'datamachine_agent_ids_backfilled', true, true ); - - if ( $backfilled > 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Backfilled agent_id on existing pipelines, flows, and jobs', - array( - 'rows_updated' => $backfilled, - 'agent_map' => $agent_map, - ) - ); - } -} - -/** - * Assign orphaned resources to the sole agent on single-agent installs. - * - * Handles the case where pipelines, flows, and jobs were created before - * agent scoping existed (user_id=0, agent_id=NULL). If exactly one agent - * exists, assigns all unowned resources to it. - * - * Idempotent: runs once per install, skipped if multi-agent (>1 agent). - * - * @since 0.41.0 - */ -function datamachine_assign_orphaned_resources_to_sole_agent(): void { - if ( get_option( 'datamachine_orphaned_resources_assigned', false ) ) { - return; - } - - global $wpdb; - - $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); - - // 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->base_prefix . 'datamachine_agents' ) - ); - - if ( 1 !== $agent_count ) { - // 0 agents: nothing to assign to. >1 agents: ambiguous, skip. - update_option( 'datamachine_orphaned_resources_assigned', true, true ); - return; - } - - // 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->base_prefix . 'datamachine_agents' ) - ); - - if ( $agent_id <= 0 ) { - update_option( 'datamachine_orphaned_resources_assigned', true, true ); - return; - } - - $tables = array( - $wpdb->prefix . 'datamachine_pipelines', - $wpdb->prefix . 'datamachine_flows', - $wpdb->prefix . 'datamachine_jobs', - ); - - $total_assigned = 0; - - foreach ( $tables as $table ) { - // Check table exists. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); - if ( ! $table_exists ) { - continue; - } - - // Check agent_id column exists. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $col = $wpdb->get_var( - $wpdb->prepare( - "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'agent_id'", - DB_NAME, - $table - ) - ); - if ( null === $col ) { - continue; - } - - // Assign orphaned rows (agent_id IS NULL) to the sole agent. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. - $updated = $wpdb->query( - $wpdb->prepare( - "UPDATE {$table} SET agent_id = %d WHERE agent_id IS NULL", - $agent_id - ) - ); - // phpcs:enable WordPress.DB.PreparedSQL - - if ( false !== $updated ) { - $total_assigned += $updated; - } - } - - update_option( 'datamachine_orphaned_resources_assigned', true, true ); - - if ( $total_assigned > 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Assigned orphaned resources to sole agent', - array( - 'agent_id' => $agent_id, - 'rows_updated' => $total_assigned, - ) - ); - } -} - -/** - * Build NETWORK.md scaffold content from WordPress multisite data. - * - * Generates a markdown summary of the multisite network topology - * including all sites, network-activated plugins, and shared resources. - * Returns empty string on single-site installs. - * - * @since 0.48.0 - * @return string NETWORK.md content, or empty string if not multisite. - */ -function datamachine_get_network_scaffold_content(): string { - if ( ! is_multisite() ) { - return ''; - } - - $network = get_network(); - $network_name = $network ? $network->site_name : 'WordPress Network'; - $main_site_id = get_main_site_id(); - $main_site = get_site( $main_site_id ); - $main_url = $main_site ? $main_site->domain . $main_site->path : home_url(); - - // --- Sites --- - $sites = get_sites( array( 'number' => 100 ) ); - $site_count = get_blog_count(); - - $site_lines = array(); - foreach ( $sites as $site ) { - $blog_id = (int) $site->blog_id; - - switch_to_blog( $blog_id ); - $name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'Site ' . $blog_id; - $url = home_url(); - $theme = wp_get_theme()->get( 'Name' ) ? wp_get_theme()->get( 'Name' ) : 'Unknown'; - restore_current_blog(); - - $is_main = ( $blog_id === $main_site_id ) ? ' (main)' : ''; - $site_lines[] = sprintf( '| %s%s | %s | %s |', $name, $is_main, $url, $theme ); - } - - // --- Network-activated plugins --- - $network_plugins = get_site_option( 'active_sitewide_plugins', array() ); - $plugin_names = array(); - - foreach ( array_keys( $network_plugins ) as $plugin_file ) { - if ( 0 === strpos( $plugin_file, 'data-machine/' ) ) { - continue; - } - - $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; - if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { - $plugin_data = get_plugin_data( $plugin_path, false, false ); - $plugin_names[] = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); - } else { - $dir = dirname( $plugin_file ); - $plugin_names[] = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; - } - } - - // --- Build content --- - $lines = array(); - $lines[] = '# Network'; - $lines[] = ''; - $lines[] = '## Identity'; - $lines[] = '- **network_name:** ' . $network_name; - $lines[] = '- **primary_site:** ' . $main_url; - $lines[] = '- **sites_count:** ' . $site_count; - $lines[] = ''; - $lines[] = '## Sites'; - $lines[] = '| Site | URL | Theme |'; - $lines[] = '|------|-----|-------|'; - - foreach ( $site_lines as $line ) { - $lines[] = $line; - } - - $lines[] = ''; - $lines[] = '## Network Plugins'; - if ( ! empty( $plugin_names ) ) { - foreach ( $plugin_names as $name ) { - $lines[] = '- ' . $name; - } - } else { - $lines[] = '- (none)'; - } - - $lines[] = ''; - $lines[] = '## Shared Resources'; - $lines[] = '- **Users:** network-wide (see USER.md)'; - $lines[] = '- **Media:** per-site uploads'; - - $content = implode( "\n", $lines ) . "\n"; - - /** - * Filter the auto-generated NETWORK.md content. - * - * Allows plugins and themes to append or modify the network context - * that is injected into AI agent calls. NETWORK.md is read-only in - * the admin UI; this filter is the only extension point. - * - * @since 0.50.0 - * - * @param string $content The generated NETWORK.md markdown content. - */ - return apply_filters( 'datamachine_network_scaffold_content', $content ); -} - -/** - * Migrate USER.md from site-scoped to network-scoped paths on multisite. - * - * On multisite, USER.md was previously stored per-site (under each site's - * upload dir). Since WordPress users are network-wide, USER.md should live - * in the main site's uploads directory. - * - * This migration finds the richest (largest) USER.md across all subsites - * and copies it to the new network-scoped location. Also creates NETWORK.md - * if it doesn't exist. - * - * Idempotent. Skipped on single-site installs. - * - * @since 0.48.0 - * @return void - */ -function datamachine_migrate_user_md_to_network_scope(): void { - if ( get_option( 'datamachine_user_md_network_migrated', false ) ) { - return; - } - - // Single-site: nothing to migrate, just mark done. - if ( ! is_multisite() ) { - update_option( 'datamachine_user_md_network_migrated', true, true ); - return; - } - - $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); - $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); - - if ( ! $fs ) { - return; - } - - // get_user_directory() now returns the network-scoped path. - // We need to find USER.md files in old per-site locations and consolidate. - $sites = get_sites( array( 'number' => 100 ) ); - - // Get all WordPress users to check for USER.md across sites. - $users = get_users( array( - 'fields' => 'ID', - 'number' => 100, - ) ); - - $migrated_users = 0; - - foreach ( $users as $user_id ) { - $user_id = absint( $user_id ); - - // New network-scoped destination (from updated get_user_directory). - $network_user_dir = $directory_manager->get_user_directory( $user_id ); - $network_user_file = trailingslashit( $network_user_dir ) . 'USER.md'; - - // If the file already exists at the network location, skip. - if ( file_exists( $network_user_file ) ) { - continue; - } - - // Search all subsites for the richest USER.md for this user. - $best_content = ''; - $best_size = 0; - - foreach ( $sites as $site ) { - $blog_id = (int) $site->blog_id; - - switch_to_blog( $blog_id ); - $site_upload_dir = wp_upload_dir(); - restore_current_blog(); - - $site_user_file = trailingslashit( $site_upload_dir['basedir'] ) - . 'datamachine-files/users/' . $user_id . '/USER.md'; - - if ( file_exists( $site_user_file ) ) { - $size = filesize( $site_user_file ); - if ( $size > $best_size ) { - $best_size = $size; - $best_content = $fs->get_contents( $site_user_file ); - } - } - } - - if ( ! empty( $best_content ) ) { - if ( ! is_dir( $network_user_dir ) ) { - wp_mkdir_p( $network_user_dir ); - } - - $index_file = trailingslashit( $network_user_dir ) . 'index.php'; - if ( ! file_exists( $index_file ) ) { - $fs->put_contents( $index_file, "put_contents( $network_user_file, $best_content, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_user_file ); - ++$migrated_users; - } - } - - // Create NETWORK.md if it doesn't exist. - $network_dir = $directory_manager->get_network_directory(); - if ( ! is_dir( $network_dir ) ) { - wp_mkdir_p( $network_dir ); - } - - $network_md = trailingslashit( $network_dir ) . 'NETWORK.md'; - if ( ! file_exists( $network_md ) ) { - $content = datamachine_get_network_scaffold_content(); - if ( ! empty( $content ) ) { - $fs->put_contents( $network_md, $content, FS_CHMOD_FILE ); - \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_md ); - } - } - - $network_index = trailingslashit( $network_dir ) . 'index.php'; - if ( ! file_exists( $network_index ) ) { - $fs->put_contents( $network_index, " 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Migrated USER.md to network-scoped paths', - array( 'users_migrated' => $migrated_users ) - ); - } -} - -/** - * Re-schedule all flows with non-manual scheduling on plugin activation. - * - * Ensures scheduled flows resume after plugin reactivation. - */ -function datamachine_activate_scheduled_flows() { - if ( ! function_exists( 'as_schedule_recurring_action' ) ) { - return; - } - - global $wpdb; - $table_name = $wpdb->prefix . 'datamachine_flows'; - - // Check if table exists (fresh install won't have flows yet) - if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) { - return; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $flows = $wpdb->get_results( $wpdb->prepare( 'SELECT flow_id, scheduling_config FROM %i', $table_name ), ARRAY_A ); - - if ( empty( $flows ) ) { - return; - } - - $scheduled_count = 0; - - foreach ( $flows as $flow ) { - $flow_id = (int) $flow['flow_id']; - $scheduling_config = json_decode( $flow['scheduling_config'], true ); - - if ( empty( $scheduling_config ) || empty( $scheduling_config['interval'] ) ) { - continue; - } - - $interval = $scheduling_config['interval']; - - if ( 'manual' === $interval ) { - continue; - } - - // Delegate to FlowScheduling — single source of truth for scheduling - // logic including stagger offsets, interval validation, and AS registration. - $result = \DataMachine\Api\Flows\FlowScheduling::handle_scheduling_update( - $flow_id, - $scheduling_config - ); - - if ( ! is_wp_error( $result ) ) { - ++$scheduled_count; - } - } - - if ( $scheduled_count > 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Flows re-scheduled on plugin activation', - array( - 'scheduled_count' => $scheduled_count, - ) - ); - } -} - -/** - * 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, - ) - ); - } -} - -/** - * Drop orphaned per-site agent tables after network migration. - * - * After datamachine_migrate_agents_to_network_scope() has consolidated - * all agent data into the network-scoped tables (base_prefix), the - * per-site copies (e.g. c8c_7_datamachine_agents) serve no purpose. - * They can't be queried (all repositories use base_prefix) and their - * presence is confusing. - * - * This function drops the orphaned per-site agent, access, and token - * tables for every subsite. Idempotent — safe to call multiple times. - * Only runs on multisite after the network migration flag is set. - * - * @since 0.43.0 - */ -function datamachine_drop_orphaned_agent_tables() { - if ( ! is_multisite() ) { - return; - } - - if ( ! get_site_option( 'datamachine_agents_network_migrated' ) ) { - return; - } - - if ( get_site_option( 'datamachine_orphaned_agent_tables_dropped' ) ) { - return; - } - - global $wpdb; - - $table_suffixes = array( - 'datamachine_agents', - 'datamachine_agent_access', - 'datamachine_agent_tokens', - ); - - $sites = get_sites( array( 'fields' => 'ids' ) ); - $dropped = 0; - - foreach ( $sites as $blog_id ) { - $site_prefix = $wpdb->get_blog_prefix( $blog_id ); - - // Skip the main site — its prefix IS the base_prefix, - // so these are the canonical network tables. - if ( $site_prefix === $wpdb->base_prefix ) { - continue; - } - - foreach ( $table_suffixes as $suffix ) { - $table_name = $site_prefix . $suffix; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); - - if ( $exists ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $wpdb->query( "DROP TABLE `{$table_name}`" ); - ++$dropped; - } - } - } - - update_site_option( 'datamachine_orphaned_agent_tables_dropped', true ); - - if ( $dropped > 0 ) { - do_action( - 'datamachine_log', - 'info', - 'Dropped orphaned per-site agent tables after network migration', - array( 'tables_dropped' => $dropped ) - ); - } -} diff --git a/inc/migrations/build_content.php b/inc/migrations/build_content.php new file mode 100644 index 000000000..13b60c454 --- /dev/null +++ b/inc/migrations/build_content.php @@ -0,0 +1,451 @@ +//! build_content — extracted from migrations.php. + + +/** + * Migrate USER.md from site-scoped to network-scoped paths on multisite. + * + * On multisite, USER.md was previously stored per-site (under each site's + * upload dir). Since WordPress users are network-wide, USER.md should live + * in the main site's uploads directory. + * + * This migration finds the richest (largest) USER.md across all subsites + * and copies it to the new network-scoped location. Also creates NETWORK.md + * if it doesn't exist. + * + * Idempotent. Skipped on single-site installs. + * + * @since 0.48.0 + * @return void + */ +function datamachine_migrate_user_md_to_network_scope(): void { + if ( get_option( 'datamachine_user_md_network_migrated', false ) ) { + return; + } + + // Single-site: nothing to migrate, just mark done. + if ( ! is_multisite() ) { + update_option( 'datamachine_user_md_network_migrated', true, true ); + return; + } + + $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + + if ( ! $fs ) { + return; + } + + // get_user_directory() now returns the network-scoped path. + // We need to find USER.md files in old per-site locations and consolidate. + $sites = get_sites( array( 'number' => 100 ) ); + + // Get all WordPress users to check for USER.md across sites. + $users = get_users( array( + 'fields' => 'ID', + 'number' => 100, + ) ); + + $migrated_users = 0; + + foreach ( $users as $user_id ) { + $user_id = absint( $user_id ); + + // New network-scoped destination (from updated get_user_directory). + $network_user_dir = $directory_manager->get_user_directory( $user_id ); + $network_user_file = trailingslashit( $network_user_dir ) . 'USER.md'; + + // If the file already exists at the network location, skip. + if ( file_exists( $network_user_file ) ) { + continue; + } + + // Search all subsites for the richest USER.md for this user. + $best_content = ''; + $best_size = 0; + + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + + switch_to_blog( $blog_id ); + $site_upload_dir = wp_upload_dir(); + restore_current_blog(); + + $site_user_file = trailingslashit( $site_upload_dir['basedir'] ) + . 'datamachine-files/users/' . $user_id . '/USER.md'; + + if ( file_exists( $site_user_file ) ) { + $size = filesize( $site_user_file ); + if ( $size > $best_size ) { + $best_size = $size; + $best_content = $fs->get_contents( $site_user_file ); + } + } + } + + if ( ! empty( $best_content ) ) { + if ( ! is_dir( $network_user_dir ) ) { + wp_mkdir_p( $network_user_dir ); + } + + $index_file = trailingslashit( $network_user_dir ) . 'index.php'; + if ( ! file_exists( $index_file ) ) { + $fs->put_contents( $index_file, "put_contents( $network_user_file, $best_content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_user_file ); + ++$migrated_users; + } + } + + // Create NETWORK.md if it doesn't exist. + $network_dir = $directory_manager->get_network_directory(); + if ( ! is_dir( $network_dir ) ) { + wp_mkdir_p( $network_dir ); + } + + $network_md = trailingslashit( $network_dir ) . 'NETWORK.md'; + if ( ! file_exists( $network_md ) ) { + $content = datamachine_get_network_scaffold_content(); + if ( ! empty( $content ) ) { + $fs->put_contents( $network_md, $content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_md ); + } + } + + $network_index = trailingslashit( $network_dir ) . 'index.php'; + if ( ! file_exists( $network_index ) ) { + $fs->put_contents( $network_index, " 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Migrated USER.md to network-scoped paths', + array( 'users_migrated' => $migrated_users ) + ); + } +} + +/** + * Re-schedule all flows with non-manual scheduling on plugin activation. + * + * Ensures scheduled flows resume after plugin reactivation. + */ +function datamachine_activate_scheduled_flows() { + if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + return; + } + + global $wpdb; + $table_name = $wpdb->prefix . 'datamachine_flows'; + + // Check if table exists (fresh install won't have flows yet) + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $flows = $wpdb->get_results( $wpdb->prepare( 'SELECT flow_id, scheduling_config FROM %i', $table_name ), ARRAY_A ); + + if ( empty( $flows ) ) { + return; + } + + $scheduled_count = 0; + + foreach ( $flows as $flow ) { + $flow_id = (int) $flow['flow_id']; + $scheduling_config = json_decode( $flow['scheduling_config'], true ); + + if ( empty( $scheduling_config ) || empty( $scheduling_config['interval'] ) ) { + continue; + } + + $interval = $scheduling_config['interval']; + + if ( 'manual' === $interval ) { + continue; + } + + // Delegate to FlowScheduling — single source of truth for scheduling + // logic including stagger offsets, interval validation, and AS registration. + $result = \DataMachine\Api\Flows\FlowScheduling::handle_scheduling_update( + $flow_id, + $scheduling_config + ); + + if ( ! is_wp_error( $result ) ) { + ++$scheduled_count; + } + } + + if ( $scheduled_count > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Flows re-scheduled on plugin activation', + array( + 'scheduled_count' => $scheduled_count, + ) + ); + } +} + +/** + * 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, + ) + ); + } +} + +/** + * Drop orphaned per-site agent tables after network migration. + * + * After datamachine_migrate_agents_to_network_scope() has consolidated + * all agent data into the network-scoped tables (base_prefix), the + * per-site copies (e.g. c8c_7_datamachine_agents) serve no purpose. + * They can't be queried (all repositories use base_prefix) and their + * presence is confusing. + * + * This function drops the orphaned per-site agent, access, and token + * tables for every subsite. Idempotent — safe to call multiple times. + * Only runs on multisite after the network migration flag is set. + * + * @since 0.43.0 + */ +function datamachine_drop_orphaned_agent_tables() { + if ( ! is_multisite() ) { + return; + } + + if ( ! get_site_option( 'datamachine_agents_network_migrated' ) ) { + return; + } + + if ( get_site_option( 'datamachine_orphaned_agent_tables_dropped' ) ) { + return; + } + + global $wpdb; + + $table_suffixes = array( + 'datamachine_agents', + 'datamachine_agent_access', + 'datamachine_agent_tokens', + ); + + $sites = get_sites( array( 'fields' => 'ids' ) ); + $dropped = 0; + + foreach ( $sites as $blog_id ) { + $site_prefix = $wpdb->get_blog_prefix( $blog_id ); + + // Skip the main site — its prefix IS the base_prefix, + // so these are the canonical network tables. + if ( $site_prefix === $wpdb->base_prefix ) { + continue; + } + + foreach ( $table_suffixes as $suffix ) { + $table_name = $site_prefix . $suffix; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); + + if ( $exists ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE `{$table_name}`" ); + ++$dropped; + } + } + } + + update_site_option( 'datamachine_orphaned_agent_tables_dropped', true ); + + if ( $dropped > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Dropped orphaned per-site agent tables after network migration', + array( 'tables_dropped' => $dropped ) + ); + } +} diff --git a/inc/migrations/datamachine.php b/inc/migrations/datamachine.php new file mode 100644 index 000000000..c6cbd3cfa --- /dev/null +++ b/inc/migrations/datamachine.php @@ -0,0 +1,1292 @@ +//! datamachine — extracted from migrations.php. + + +/** + * Migrate flow_config JSON from legacy singular handler keys to plural. + * + * Converts handler_slug → handler_slugs and handler_config → handler_configs + * in every step of every flow's flow_config JSON. Idempotent: skips rows + * that already use plural keys. + * + * @since 0.39.0 + */ +function datamachine_migrate_handler_keys_to_plural() { + $already_done = get_option( 'datamachine_handler_keys_migrated', false ); + if ( $already_done ) { + return; + } + + global $wpdb; + $table = $wpdb->prefix . 'datamachine_flows'; + + // Check table exists (fresh installs won't have legacy data). + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. + $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + // phpcs:enable WordPress.DB.PreparedSQL + if ( ! $table_exists ) { + update_option( 'datamachine_handler_keys_migrated', true, true ); + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. + $rows = $wpdb->get_results( "SELECT flow_id, flow_config FROM {$table}", ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL + + if ( empty( $rows ) ) { + update_option( 'datamachine_handler_keys_migrated', true, true ); + return; + } + + $migrated = 0; + foreach ( $rows as $row ) { + $flow_config = json_decode( $row['flow_config'], true ); + if ( ! is_array( $flow_config ) ) { + continue; + } + + $changed = false; + foreach ( $flow_config as $step_id => &$step ) { + if ( ! is_array( $step ) ) { + continue; + } + + // Skip flow-level metadata keys. + if ( 'memory_files' === $step_id ) { + continue; + } + + // Already has plural keys — check if singular leftovers need cleanup. + if ( isset( $step['handler_slugs'] ) && is_array( $step['handler_slugs'] ) ) { + // Ensure handler_configs exists when handler_slugs does. + if ( ! isset( $step['handler_configs'] ) || ! is_array( $step['handler_configs'] ) ) { + $primary = $step['handler_slugs'][0] ?? ''; + $config = $step['handler_config'] ?? array(); + $step['handler_configs'] = ! empty( $primary ) ? array( $primary => $config ) : array(); + $changed = true; + } + // Remove any leftover singular keys. + if ( isset( $step['handler_slug'] ) ) { + unset( $step['handler_slug'] ); + $changed = true; + } + if ( isset( $step['handler_config'] ) ) { + unset( $step['handler_config'] ); + $changed = true; + } + continue; + } + + // Convert singular to plural. + $slug = $step['handler_slug'] ?? ''; + $config = $step['handler_config'] ?? array(); + + if ( ! empty( $slug ) ) { + $step['handler_slugs'] = array( $slug ); + $step['handler_configs'] = array( $slug => $config ); + } else { + // Self-configuring steps (agent_ping, webhook_gate, system_task). + $step_type = $step['step_type'] ?? ''; + if ( ! empty( $step_type ) && ! empty( $config ) ) { + $step['handler_slugs'] = array( $step_type ); + $step['handler_configs'] = array( $step_type => $config ); + } else { + $step['handler_slugs'] = array(); + $step['handler_configs'] = array(); + } + } + + unset( $step['handler_slug'], $step['handler_config'] ); + $changed = true; + } + unset( $step ); + + if ( $changed ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->update( + $table, + array( 'flow_config' => wp_json_encode( $flow_config ) ), + array( 'flow_id' => $row['flow_id'] ), + array( '%s' ), + array( '%d' ) + ); + ++$migrated; + } + } + + update_option( 'datamachine_handler_keys_migrated', true, true ); + + if ( $migrated > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Migrated flow_config handler keys from singular to plural', + array( 'flows_updated' => $migrated ) + ); + } +} + +/** + * Auto-run DB migrations when code version is ahead of stored DB version. + * + * Deploys via rsync/homeboy don't trigger activation hooks, so new columns + * are silently missing until someone manually reactivates. This check runs + * on every request and calls the idempotent activation function when the + * deployed code version exceeds the stored DB schema version. + * + * Pattern used by WooCommerce, bbPress, and most plugins with custom tables. + * + * @since 0.35.0 + */ +function datamachine_maybe_run_migrations() { + $db_version = get_option( 'datamachine_db_version', '0.0.0' ); + + if ( version_compare( $db_version, DATAMACHINE_VERSION, '>=' ) ) { + return; + } + + datamachine_activate_for_site(); + update_option( 'datamachine_db_version', DATAMACHINE_VERSION, true ); +} + +/** + * Build scaffold defaults for agent memory files using WordPress site data. + * + * Gathers site metadata, admin info, active plugins, content types, and + * environment details to populate agent files with useful context instead + * of empty placeholder comments. + * + * @since 0.32.0 + * @since 0.51.0 Accepts optional $agent_name for identity-aware SOUL.md scaffolding. + * + * @param string $agent_name Optional agent display name to include in SOUL.md identity. + * @return array Filename => content map for SOUL.md, USER.md, MEMORY.md. + */ +function datamachine_get_scaffold_defaults( string $agent_name = '' ): array { + // --- Site metadata --- + $site_name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'WordPress Site'; + $site_tagline = get_bloginfo( 'description' ); + $site_url = home_url(); + $timezone = wp_timezone_string(); + + // --- Active theme --- + $theme = wp_get_theme(); + $theme_name = $theme->get( 'Name' ) ? $theme->get( 'Name' ) : 'Unknown'; + + // --- Active plugins (exclude Data Machine itself) --- + $active_plugins = get_option( 'active_plugins', array() ); + + // On multisite, include network-activated plugins too. + if ( is_multisite() ) { + $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) ); + $active_plugins = array_unique( array_merge( $active_plugins, $network_plugins ) ); + } + + $plugin_names = array(); + + foreach ( $active_plugins as $plugin_file ) { + if ( 0 === strpos( $plugin_file, 'data-machine/' ) ) { + continue; + } + + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; + if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { + $plugin_data = get_plugin_data( $plugin_path, false, false ); + $plugin_names[] = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); + } else { + $dir = dirname( $plugin_file ); + $plugin_names[] = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; + } + } + + // --- Content types with counts --- + $content_lines = array(); + $post_types = get_post_types( array( 'public' => true ), 'objects' ); + + foreach ( $post_types as $pt ) { + $count = wp_count_posts( $pt->name ); + $published = isset( $count->publish ) ? (int) $count->publish : 0; + + if ( $published > 0 || in_array( $pt->name, array( 'post', 'page' ), true ) ) { + $content_lines[] = sprintf( '%s: %d published', $pt->label, $published ); + } + } + + // --- Multisite --- + $multisite_line = ''; + if ( is_multisite() ) { + $site_count = get_blog_count(); + $multisite_line = sprintf( + "\n- **Network:** WordPress Multisite with %d site%s", + $site_count, + 1 === $site_count ? '' : 's' + ); + } + + // --- Admin user --- + $admin_email = get_option( 'admin_email', '' ); + $admin_user = $admin_email ? get_user_by( 'email', $admin_email ) : null; + $admin_name = $admin_user ? $admin_user->display_name : ''; + + // --- Versions --- + $wp_version = get_bloginfo( 'version' ); + $php_version = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION; + $dm_version = defined( 'DATAMACHINE_VERSION' ) ? DATAMACHINE_VERSION : 'unknown'; + $created = wp_date( 'Y-m-d' ); + + // --- Build SOUL.md context lines --- + $context_items = array(); + $context_items[] = sprintf( '- **Site:** %s', $site_name ); + + if ( $site_tagline ) { + $context_items[] = sprintf( '- **Tagline:** %s', $site_tagline ); + } + + $context_items[] = sprintf( '- **URL:** %s', $site_url ); + $context_items[] = sprintf( '- **Theme:** %s', $theme_name ); + + if ( $plugin_names ) { + $context_items[] = sprintf( '- **Plugins:** %s', implode( ', ', $plugin_names ) ); + } + + if ( $content_lines ) { + $context_items[] = sprintf( '- **Content:** %s', implode( ' · ', $content_lines ) ); + } + + $context_items[] = sprintf( '- **Timezone:** %s', $timezone ); + + $soul_context = implode( "\n", $context_items ) . $multisite_line; + + // --- SOUL.md --- + $identity_line = ! empty( $agent_name ) + ? "You are **{$agent_name}**, an AI assistant managing {$site_name}." + : "You are an AI assistant managing {$site_name}."; + + $identity_meta = ''; + if ( ! empty( $agent_name ) ) { + $identity_meta = "\n- **Name:** {$agent_name}"; + } + + $soul = << + +## Goals + +MD; + + // --- MEMORY.md --- + $memory = << + +## Context + +MD; + + return array( + 'SOUL.md' => $soul, + 'USER.md' => $user, + 'MEMORY.md' => $memory, + ); +} + +/** + * Build shared SITE.md content from WordPress site data. + * + * This is the single source of truth for site context injected into AI calls. + * Replaces the former SiteContext class + SiteContextDirective which injected + * a duplicate JSON blob at priority 80. Now SITE.md contains all the same + * data in markdown format, injected once via CoreMemoryFilesDirective. + * + * @since 0.36.1 + * @since 0.48.0 Enriched with post counts, taxonomy details, language, timezone, + * site structure, user roles, plugin descriptions, REST namespaces. + * @return string + */ +function datamachine_get_site_scaffold_content(): string { + $site_name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'WordPress Site'; + $site_description = get_bloginfo( 'description' ) ? get_bloginfo( 'description' ) : ''; + $site_url = home_url(); + $language = get_locale(); + $timezone = wp_timezone_string(); + $theme_name = wp_get_theme()->get( 'Name' ) ? wp_get_theme()->get( 'Name' ) : 'Unknown'; + $permalink = get_option( 'permalink_structure', '' ); + + // --- Active plugins with descriptions (exclude Data Machine) --- + $active_plugins = get_option( 'active_plugins', array() ); + + if ( is_multisite() ) { + $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) ); + $active_plugins = array_unique( array_merge( $active_plugins, $network_plugins ) ); + } + + $plugin_entries = array(); + foreach ( $active_plugins as $plugin_file ) { + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; + if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { + $plugin_data = get_plugin_data( $plugin_path, false, false ); + $plugin_name = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); + $plugin_desc = ! empty( $plugin_data['Description'] ) ? $plugin_data['Description'] : ''; + } else { + $dir = dirname( $plugin_file ); + $plugin_name = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; + $plugin_desc = ''; + } + + if ( 'data-machine' === strtolower( (string) $plugin_name ) || 0 === strpos( $plugin_file, 'data-machine/' ) ) { + continue; + } + + $plugin_entries[] = array( + 'name' => $plugin_name, + 'desc' => $plugin_desc, + ); + } + + // --- Post types with counts --- + $post_types = get_post_types( array( 'public' => true ), 'objects' ); + $post_type_lines = array(); + foreach ( $post_types as $pt ) { + $count = wp_count_posts( $pt->name ); + $published = isset( $count->publish ) ? (int) $count->publish : 0; + $hier = $pt->hierarchical ? 'hierarchical' : 'flat'; + $post_type_lines[] = sprintf( '| %s | %s | %d | %s |', $pt->label, $pt->name, $published, $hier ); + } + + // --- Taxonomies with term counts --- + $taxonomies = get_taxonomies( array( 'public' => true ), 'objects' ); + $taxonomy_lines = array(); + foreach ( $taxonomies as $tax ) { + $term_count = wp_count_terms( array( + 'taxonomy' => $tax->name, + 'hide_empty' => false, + ) ); + if ( is_wp_error( $term_count ) ) { + $term_count = 0; + } + $hier = $tax->hierarchical ? 'hierarchical' : 'flat'; + $associated = implode( ', ', $tax->object_type ?? array() ); + $taxonomy_lines[] = sprintf( '| %s | %s | %d | %s | %s |', $tax->label, $tax->name, (int) $term_count, $hier, $associated ); + } + + // --- Key pages --- + $key_pages = array(); + + $front_page_id = (int) get_option( 'page_on_front', 0 ); + if ( $front_page_id > 0 ) { + $key_pages[] = sprintf( '- **Front page:** %s (ID %d)', get_the_title( $front_page_id ), $front_page_id ); + } + + $blog_page_id = (int) get_option( 'page_for_posts', 0 ); + if ( $blog_page_id > 0 ) { + $key_pages[] = sprintf( '- **Blog page:** %s (ID %d)', get_the_title( $blog_page_id ), $blog_page_id ); + } + + $privacy_page_id = (int) get_option( 'wp_page_for_privacy_policy', 0 ); + if ( $privacy_page_id > 0 ) { + $key_pages[] = sprintf( '- **Privacy page:** %s (ID %d)', get_the_title( $privacy_page_id ), $privacy_page_id ); + } + + $show_on_front = get_option( 'show_on_front', 'posts' ); + $key_pages[] = '- **Homepage displays:** ' . ( 'page' === $show_on_front ? 'static page' : 'latest posts' ); + + // --- Menus --- + $registered_menus = get_registered_nav_menus(); + $menu_locations = get_nav_menu_locations(); + $menu_lines = array(); + + foreach ( $registered_menus as $location => $description ) { + $assigned = 'unassigned'; + if ( ! empty( $menu_locations[ $location ] ) ) { + $menu_obj = wp_get_nav_menu_object( $menu_locations[ $location ] ); + $assigned = $menu_obj ? $menu_obj->name : 'unassigned'; + } + $menu_lines[] = sprintf( '- **%s** (%s): %s', $description, $location, $assigned ); + } + + // --- User roles --- + $wp_roles = wp_roles(); + $role_names = $wp_roles->get_names(); + $default_roles = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); + $custom_roles = array_diff( array_keys( $role_names ), $default_roles ); + $role_lines = array(); + + foreach ( $role_names as $slug => $name ) { + $user_count = count( get_users( array( 'role' => $slug, 'fields' => 'ID', 'number' => 1 ) ) ); + $is_custom = in_array( $slug, $custom_roles, true ) ? ' (custom)' : ''; + $role_lines[] = sprintf( '- %s (`%s`)%s', translate_user_role( $name ), $slug, $is_custom ); + } + + // --- REST API namespaces (custom only) --- + $rest_namespaces = array(); + $builtin_prefixes = array( 'wp/', 'oembed/', 'wp-site-health/' ); + + if ( function_exists( 'rest_get_server' ) && did_action( 'rest_api_init' ) ) { + $routes = rest_get_server()->get_namespaces(); + foreach ( $routes as $namespace ) { + $is_builtin = false; + foreach ( $builtin_prefixes as $prefix ) { + if ( 0 === strpos( $namespace . '/', $prefix ) || 'wp' === $namespace ) { + $is_builtin = true; + break; + } + } + if ( ! $is_builtin ) { + $rest_namespaces[] = $namespace; + } + } + } + + // --- Build SITE.md --- + $lines = array(); + $lines[] = '# SITE'; + $lines[] = ''; + $lines[] = '## Identity'; + $lines[] = '- **name:** ' . $site_name; + if ( ! empty( $site_description ) ) { + $lines[] = '- **description:** ' . $site_description; + } + $lines[] = '- **url:** ' . $site_url; + $lines[] = '- **theme:** ' . $theme_name; + $lines[] = '- **language:** ' . $language; + $lines[] = '- **timezone:** ' . $timezone; + if ( ! empty( $permalink ) ) { + $lines[] = '- **permalinks:** ' . $permalink; + } + $lines[] = '- **multisite:** ' . ( is_multisite() ? 'true' : 'false' ); + $lines[] = ''; + + // --- Site Structure --- + $lines[] = '## Site Structure'; + + foreach ( $key_pages as $page_line ) { + $lines[] = $page_line; + } + $lines[] = ''; + + if ( ! empty( $menu_lines ) ) { + $lines[] = '### Menus'; + foreach ( $menu_lines as $menu_line ) { + $lines[] = $menu_line; + } + $lines[] = ''; + } + + // --- Content Model --- + $lines[] = '## Post Types'; + $lines[] = '| Label | Slug | Published | Type |'; + $lines[] = '|-------|------|-----------|------|'; + foreach ( $post_type_lines as $line ) { + $lines[] = $line; + } + $lines[] = ''; + + $lines[] = '## Taxonomies'; + $lines[] = '| Label | Slug | Terms | Type | Post Types |'; + $lines[] = '|-------|------|-------|------|------------|'; + foreach ( $taxonomy_lines as $line ) { + $lines[] = $line; + } + $lines[] = ''; + + // --- User Roles --- + if ( ! empty( $custom_roles ) ) { + $lines[] = '## User Roles'; + foreach ( $role_lines as $role_line ) { + $lines[] = $role_line; + } + $lines[] = ''; + } + + // --- Active Plugins with descriptions --- + $lines[] = '## Active Plugins'; + if ( ! empty( $plugin_entries ) ) { + foreach ( $plugin_entries as $entry ) { + $desc_suffix = ''; + if ( ! empty( $entry['desc'] ) ) { + // Truncate long descriptions to keep SITE.md scannable. + $desc = wp_strip_all_tags( $entry['desc'] ); + if ( strlen( $desc ) > 120 ) { + $desc = substr( $desc, 0, 117 ) . '...'; + } + $desc_suffix = ' — ' . $desc; + } + $lines[] = '- **' . $entry['name'] . '**' . $desc_suffix; + } + } else { + $lines[] = '- (none)'; + } + + // --- REST API namespaces --- + if ( ! empty( $rest_namespaces ) ) { + $lines[] = ''; + $lines[] = '## REST API'; + $lines[] = '- **Custom namespaces:** ' . implode( ', ', $rest_namespaces ); + } + + $content = implode( "\n", $lines ) . "\n"; + + /** + * Filter the auto-generated SITE.md content. + * + * Allows plugins and themes to append or modify the site context + * that is injected into AI agent calls. SITE.md is read-only in the + * admin UI; this filter is the only extension point. + * + * @since 0.50.0 + * + * @param string $content The generated SITE.md markdown content. + */ + return apply_filters( 'datamachine_site_scaffold_content', $content ); +} + +/** + * Migrate existing user_id-scoped agent files to layered architecture. + * + * Idempotent migration that: + * - Creates shared/ SITE.md + * - Creates agents/{slug}/ and users/{user_id}/ + * - Copies SOUL.md + MEMORY.md to agent layer + * - Copies USER.md to user layer + * - Creates datamachine_agents rows (one per user-owned legacy agent dir) + * - Backfills chat_sessions.agent_id + * + * @since 0.36.1 + * @return void + */ +function datamachine_migrate_to_layered_architecture(): void { + if ( get_option( 'datamachine_layered_arch_migrated', false ) ) { + return; + } + + $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + + if ( ! $fs ) { + return; + } + + $legacy_agent_base = $directory_manager->get_agent_directory(); // .../datamachine-files/agent + $shared_dir = $directory_manager->get_shared_directory(); + + update_option( + 'datamachine_layered_arch_migration_backup', + array( + 'legacy_agent_base' => $legacy_agent_base, + 'migrated_at' => current_time( 'mysql', true ), + ), + false + ); + + if ( ! is_dir( $shared_dir ) ) { + wp_mkdir_p( $shared_dir ); + } + + $site_md = trailingslashit( $shared_dir ) . 'SITE.md'; + if ( ! file_exists( $site_md ) ) { + $fs->put_contents( $site_md, datamachine_get_site_scaffold_content(), FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $site_md ); + } + + $index_file = trailingslashit( $shared_dir ) . 'index.php'; + if ( ! file_exists( $index_file ) ) { + $fs->put_contents( $index_file, "user_login ) : 'user-' . $user_id; + $agent_name = $user ? $user->display_name : 'User ' . $user_id; + $agent_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); + + $agent_id = $agents_repo->create_if_missing( + $agent_slug, + $agent_name, + $user_id, + array( + 'model' => array( + 'default' => $agent_model, + ), + ) + ); + + $agent_identity_dir = $directory_manager->get_agent_identity_directory( $agent_slug ); + $user_dir = $directory_manager->get_user_directory( $user_id ); + + if ( ! is_dir( $agent_identity_dir ) ) { + wp_mkdir_p( $agent_identity_dir ); + } + if ( ! is_dir( $user_dir ) ) { + wp_mkdir_p( $user_dir ); + } + + $agent_index = trailingslashit( $agent_identity_dir ) . 'index.php'; + if ( ! file_exists( $agent_index ) ) { + $fs->put_contents( $agent_index, "put_contents( $user_index, "copy( $legacy_soul, $new_soul, true, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_soul ); + } + if ( file_exists( $legacy_memory ) && ! file_exists( $new_memory ) ) { + $fs->copy( $legacy_memory, $new_memory, true, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_memory ); + } + if ( file_exists( $legacy_user ) && ! file_exists( $new_user ) ) { + $fs->copy( $legacy_user, $new_user, true, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_user ); + } elseif ( ! file_exists( $new_user ) ) { + $user_profile_lines = array(); + $user_profile_lines[] = '# User Profile'; + $user_profile_lines[] = ''; + $user_profile_lines[] = '## About'; + $user_profile_lines[] = '- **Name:** ' . ( $user ? $user->display_name : 'User ' . $user_id ); + if ( $user && ! empty( $user->user_email ) ) { + $user_profile_lines[] = '- **Email:** ' . $user->user_email; + } + $user_profile_lines[] = '- **User ID:** ' . $user_id; + $user_profile_lines[] = ''; + $user_profile_lines[] = '## Preferences'; + $user_profile_lines[] = ''; + + $fs->put_contents( $new_user, implode( "\n", $user_profile_lines ) . "\n", FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $new_user ); + } + + if ( is_dir( $legacy_daily ) && ! is_dir( $new_daily ) ) { + datamachine_copy_directory_recursive( $legacy_daily, $new_daily ); + } + + // Backfill chat sessions for this user. + global $wpdb; + $chat_table = $chat_db->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( + $wpdb->prepare( + 'UPDATE %i SET agent_id = %d WHERE user_id = %d AND (agent_id IS NULL OR agent_id = 0)', + $chat_table, + $agent_id, + $user_id + ) + ); + } + } + + // Single-agent case: .md files live directly in agent/ with no numeric subdirs. + // This is the most common layout for sites that never had multi-user partitioning. + $legacy_md_files = glob( trailingslashit( $legacy_agent_base ) . '*.md' ); + + if ( ! empty( $legacy_md_files ) ) { + $default_user_id = \DataMachine\Core\FilesRepository\DirectoryManager::get_default_agent_user_id(); + $default_user = get_user_by( 'id', $default_user_id ); + $default_slug = $default_user ? sanitize_title( $default_user->user_login ) : 'user-' . $default_user_id; + $default_name = $default_user ? $default_user->display_name : 'User ' . $default_user_id; + $default_model = \DataMachine\Core\PluginSettings::getContextModel( 'chat' ); + + $agents_repo->create_if_missing( + $default_slug, + $default_name, + $default_user_id, + array( + 'model' => array( + 'default' => $default_model, + ), + ) + ); + + $default_identity_dir = $directory_manager->get_agent_identity_directory( $default_slug ); + $default_user_dir = $directory_manager->get_user_directory( $default_user_id ); + + if ( ! is_dir( $default_identity_dir ) ) { + wp_mkdir_p( $default_identity_dir ); + } + if ( ! is_dir( $default_user_dir ) ) { + wp_mkdir_p( $default_user_dir ); + } + + $default_agent_index = trailingslashit( $default_identity_dir ) . 'index.php'; + if ( ! file_exists( $default_agent_index ) ) { + $fs->put_contents( $default_agent_index, "put_contents( $default_user_index, "copy( $legacy_file, $dest, true, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $dest ); + } + } + + // Migrate daily memory directory. + $legacy_daily = trailingslashit( $legacy_agent_base ) . 'daily'; + $new_daily = trailingslashit( $default_identity_dir ) . 'daily'; + + if ( is_dir( $legacy_daily ) && ! is_dir( $new_daily ) ) { + datamachine_copy_directory_recursive( $legacy_daily, $new_daily ); + } + } + + update_option( 'datamachine_layered_arch_migrated', 1, false ); +} + +/** + * Copy directory contents recursively without deleting source. + * + * Existing destination files are preserved. + * + * @since 0.36.1 + * @param string $source_dir Source directory path. + * @param string $target_dir Target directory path. + * @return void + */ +function datamachine_copy_directory_recursive( string $source_dir, string $target_dir ): void { + if ( ! is_dir( $source_dir ) ) { + return; + } + + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + if ( ! $fs ) { + return; + } + + if ( ! is_dir( $target_dir ) ) { + wp_mkdir_p( $target_dir ); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $source_dir, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $item ) { + $source_path = $item->getPathname(); + $relative = ltrim( str_replace( $source_dir, '', $source_path ), DIRECTORY_SEPARATOR ); + $target_path = trailingslashit( $target_dir ) . $relative; + + if ( $item->isDir() ) { + if ( ! is_dir( $target_path ) ) { + wp_mkdir_p( $target_path ); + } + continue; + } + + if ( file_exists( $target_path ) ) { + continue; + } + + $parent = dirname( $target_path ); + if ( ! is_dir( $parent ) ) { + wp_mkdir_p( $parent ); + } + + $fs->copy( $source_path, $target_path, true, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $target_path ); + } +} + +/** + * Get default context file contents. + * + * Each key is the context slug (filename without .md extension). + * These replace the former hardcoded ChatContextDirective, + * PipelineContextDirective, and SystemContextDirective PHP classes. + * + * @since 0.58.0 + * @return array Context slug => markdown content. + */ +function datamachine_get_default_context_files(): array { + $defaults = array( + 'chat' => datamachine_default_chat_context(), + 'pipeline' => datamachine_default_pipeline_context(), + 'system' => datamachine_default_system_context(), + ); + + /** + * Filter the default context file contents. + * + * Extensions can add their own context defaults (e.g. 'editor') + * or modify the core defaults before scaffolding. + * + * @since 0.58.0 + * + * @param array $defaults Context slug => markdown content. + */ + return apply_filters( 'datamachine_default_context_files', $defaults ); +} + +/** + * Resolve agent display name from scaffolding context. + * + * Looks up the agent record from the provided context identifiers + * (agent_slug, agent_id, or user_id) and returns the display name. + * Returns empty string when no agent can be resolved. + * + * @since 0.51.0 + * + * @param array $context Scaffolding context with agent_slug, agent_id, or user_id. + * @return string Agent display name, or empty string. + */ +function datamachine_resolve_agent_name_from_context( array $context ): string { + if ( ! class_exists( '\\DataMachine\\Core\\Database\\Agents\\Agents' ) ) { + return ''; + } + + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + + // 1) Explicit agent_slug. + if ( ! empty( $context['agent_slug'] ) ) { + $agent = $agents_repo->get_by_slug( sanitize_title( (string) $context['agent_slug'] ) ); + if ( ! empty( $agent['agent_name'] ) ) { + return (string) $agent['agent_name']; + } + } + + // 2) Agent ID. + $agent_id = (int) ( $context['agent_id'] ?? 0 ); + if ( $agent_id > 0 ) { + $agent = $agents_repo->get_agent( $agent_id ); + if ( ! empty( $agent['agent_name'] ) ) { + return (string) $agent['agent_name']; + } + } + + // 3) User ID → owner lookup. + $user_id = (int) ( $context['user_id'] ?? 0 ); + if ( $user_id > 0 ) { + $agent = $agents_repo->get_by_owner_id( $user_id ); + if ( ! empty( $agent['agent_name'] ) ) { + return (string) $agent['agent_name']; + } + } + + return ''; +} + +/** + * Backfill agent_id on pipelines, flows, and jobs from user_id → owner_id mapping. + * + * For existing rows that have user_id > 0 but no agent_id, looks up the agent + * via Agents::get_by_owner_id() and sets agent_id. Also bootstraps agent_access + * rows so owners have admin access to their agents. + * + * Idempotent: only processes rows where agent_id IS NULL and user_id > 0. + * Skipped entirely on fresh installs (no rows to backfill). + * + * @since 0.41.0 + */ +function datamachine_backfill_agent_ids(): void { + if ( get_option( 'datamachine_agent_ids_backfilled', false ) ) { + return; + } + + global $wpdb; + + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + $access_repo = new \DataMachine\Core\Database\Agents\AgentAccess(); + + $tables = array( + $wpdb->prefix . 'datamachine_pipelines', + $wpdb->prefix . 'datamachine_flows', + $wpdb->prefix . 'datamachine_jobs', + ); + + // Cache of user_id → agent_id to avoid repeated lookups. + $agent_map = array(); + $backfilled = 0; + + foreach ( $tables as $table ) { + // Check table exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( ! $table_exists ) { + continue; + } + + // Check agent_id column exists (migration may not have run yet). + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $col = $wpdb->get_var( + $wpdb->prepare( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'agent_id'", + DB_NAME, + $table + ) + ); + if ( null === $col ) { + continue; + } + + // Get distinct user_ids that need backfill. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + $user_ids = $wpdb->get_col( + "SELECT DISTINCT user_id FROM {$table} WHERE user_id > 0 AND agent_id IS NULL" + ); + // phpcs:enable WordPress.DB.PreparedSQL + + if ( empty( $user_ids ) ) { + continue; + } + + foreach ( $user_ids as $user_id ) { + $user_id = (int) $user_id; + + if ( ! isset( $agent_map[ $user_id ] ) ) { + $agent = $agents_repo->get_by_owner_id( $user_id ); + if ( $agent ) { + $agent_map[ $user_id ] = (int) $agent['agent_id']; + + // Bootstrap agent_access for owner. + $access_repo->bootstrap_owner_access( (int) $agent['agent_id'], $user_id ); + } else { + // Try to create agent for this user. + $created_id = datamachine_resolve_or_create_agent_id( $user_id ); + $agent_map[ $user_id ] = $created_id; + + if ( $created_id > 0 ) { + $access_repo->bootstrap_owner_access( $created_id, $user_id ); + } + } + } + + $agent_id = $agent_map[ $user_id ]; + if ( $agent_id <= 0 ) { + continue; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET agent_id = %d WHERE user_id = %d AND agent_id IS NULL", + $agent_id, + $user_id + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL + + if ( false !== $updated ) { + $backfilled += $updated; + } + } + } + + update_option( 'datamachine_agent_ids_backfilled', true, true ); + + if ( $backfilled > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Backfilled agent_id on existing pipelines, flows, and jobs', + array( + 'rows_updated' => $backfilled, + 'agent_map' => $agent_map, + ) + ); + } +} + +/** + * Assign orphaned resources to the sole agent on single-agent installs. + * + * Handles the case where pipelines, flows, and jobs were created before + * agent scoping existed (user_id=0, agent_id=NULL). If exactly one agent + * exists, assigns all unowned resources to it. + * + * Idempotent: runs once per install, skipped if multi-agent (>1 agent). + * + * @since 0.41.0 + */ +function datamachine_assign_orphaned_resources_to_sole_agent(): void { + if ( get_option( 'datamachine_orphaned_resources_assigned', false ) ) { + return; + } + + global $wpdb; + + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + + // 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->base_prefix . 'datamachine_agents' ) + ); + + if ( 1 !== $agent_count ) { + // 0 agents: nothing to assign to. >1 agents: ambiguous, skip. + update_option( 'datamachine_orphaned_resources_assigned', true, true ); + return; + } + + // 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->base_prefix . 'datamachine_agents' ) + ); + + if ( $agent_id <= 0 ) { + update_option( 'datamachine_orphaned_resources_assigned', true, true ); + return; + } + + $tables = array( + $wpdb->prefix . 'datamachine_pipelines', + $wpdb->prefix . 'datamachine_flows', + $wpdb->prefix . 'datamachine_jobs', + ); + + $total_assigned = 0; + + foreach ( $tables as $table ) { + // Check table exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( ! $table_exists ) { + continue; + } + + // Check agent_id column exists. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $col = $wpdb->get_var( + $wpdb->prepare( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'agent_id'", + DB_NAME, + $table + ) + ); + if ( null === $col ) { + continue; + } + + // Assign orphaned rows (agent_id IS NULL) to the sole agent. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix. + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET agent_id = %d WHERE agent_id IS NULL", + $agent_id + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL + + if ( false !== $updated ) { + $total_assigned += $updated; + } + } + + update_option( 'datamachine_orphaned_resources_assigned', true, true ); + + if ( $total_assigned > 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Assigned orphaned resources to sole agent', + array( + 'agent_id' => $agent_id, + 'rows_updated' => $total_assigned, + ) + ); + } +} + +/** + * Build NETWORK.md scaffold content from WordPress multisite data. + * + * Generates a markdown summary of the multisite network topology + * including all sites, network-activated plugins, and shared resources. + * Returns empty string on single-site installs. + * + * @since 0.48.0 + * @return string NETWORK.md content, or empty string if not multisite. + */ +function datamachine_get_network_scaffold_content(): string { + if ( ! is_multisite() ) { + return ''; + } + + $network = get_network(); + $network_name = $network ? $network->site_name : 'WordPress Network'; + $main_site_id = get_main_site_id(); + $main_site = get_site( $main_site_id ); + $main_url = $main_site ? $main_site->domain . $main_site->path : home_url(); + + // --- Sites --- + $sites = get_sites( array( 'number' => 100 ) ); + $site_count = get_blog_count(); + + $site_lines = array(); + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + + switch_to_blog( $blog_id ); + $name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'Site ' . $blog_id; + $url = home_url(); + $theme = wp_get_theme()->get( 'Name' ) ? wp_get_theme()->get( 'Name' ) : 'Unknown'; + restore_current_blog(); + + $is_main = ( $blog_id === $main_site_id ) ? ' (main)' : ''; + $site_lines[] = sprintf( '| %s%s | %s | %s |', $name, $is_main, $url, $theme ); + } + + // --- Network-activated plugins --- + $network_plugins = get_site_option( 'active_sitewide_plugins', array() ); + $plugin_names = array(); + + foreach ( array_keys( $network_plugins ) as $plugin_file ) { + if ( 0 === strpos( $plugin_file, 'data-machine/' ) ) { + continue; + } + + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; + if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { + $plugin_data = get_plugin_data( $plugin_path, false, false ); + $plugin_names[] = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); + } else { + $dir = dirname( $plugin_file ); + $plugin_names[] = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; + } + } + + // --- Build content --- + $lines = array(); + $lines[] = '# Network'; + $lines[] = ''; + $lines[] = '## Identity'; + $lines[] = '- **network_name:** ' . $network_name; + $lines[] = '- **primary_site:** ' . $main_url; + $lines[] = '- **sites_count:** ' . $site_count; + $lines[] = ''; + $lines[] = '## Sites'; + $lines[] = '| Site | URL | Theme |'; + $lines[] = '|------|-----|-------|'; + + foreach ( $site_lines as $line ) { + $lines[] = $line; + } + + $lines[] = ''; + $lines[] = '## Network Plugins'; + if ( ! empty( $plugin_names ) ) { + foreach ( $plugin_names as $name ) { + $lines[] = '- ' . $name; + } + } else { + $lines[] = '- (none)'; + } + + $lines[] = ''; + $lines[] = '## Shared Resources'; + $lines[] = '- **Users:** network-wide (see USER.md)'; + $lines[] = '- **Media:** per-site uploads'; + + $content = implode( "\n", $lines ) . "\n"; + + /** + * Filter the auto-generated NETWORK.md content. + * + * Allows plugins and themes to append or modify the network context + * that is injected into AI agent calls. NETWORK.md is read-only in + * the admin UI; this filter is the only extension point. + * + * @since 0.50.0 + * + * @param string $content The generated NETWORK.md markdown content. + */ + return apply_filters( 'datamachine_network_scaffold_content', $content ); +} diff --git a/inc/migrations/datamachine_default.php b/inc/migrations/datamachine_default.php new file mode 100644 index 000000000..32e2afe61 --- /dev/null +++ b/inc/migrations/datamachine_default.php @@ -0,0 +1,101 @@ +//! datamachine_default — extracted from migrations.php. + + +/** + * Default chat context (replaces ChatContextDirective). + * + * @since 0.58.0 + */ +function datamachine_default_chat_context(): string { + return <<<'MD' +# Chat Session Context + +This is a live chat session with a user in the Data Machine admin UI. You have tools to configure and manage workflows. Your identity, voice, and knowledge come from your memory files above. + +## Data Machine Architecture + +HANDLERS are the core intelligence. Fetch handlers extract and structure source data. Update/publish handlers apply changes with schema defaults for unconfigured fields. Each handler has a settings schema — only use documented fields. + +PIPELINES define workflow structure: step types in sequence (e.g., event_import → ai → upsert). The pipeline system_prompt defines AI behavior shared by all flows. + +FLOWS are configured pipeline instances. Each step needs a handler_slug and handler_config. When creating flows, match handler configurations from existing flows on the same pipeline. + +AI STEPS process data that handlers cannot automatically handle. Flow user_message is rarely needed; only for minimal source-specific overrides. + +## Discovery + +You receive a pipeline inventory with existing flows and their handlers. Use `api_query` for detailed configuration. Query existing flows before creating new ones to learn established patterns. + +## Configuration Rules + +- Only use documented handler_config fields — unknown fields are rejected. +- Use pipeline_step_id from the inventory to target steps. +- Unconfigured handler fields use schema defaults automatically. +- Act first — if the user gives executable instructions, execute them. + +## Scheduling + +- Scheduling uses intervals only (daily, hourly, etc.), not specific times of day. +- Valid intervals are provided in the tool definitions. Use update_flow to change schedules. + +## Execution Protocol + +- Only confirm task completion after a successful tool result. Never claim success on error. +- Check error_type on failure: not_found/permission → report, validation → fix and retry, system → retry once. +- If a tool rejects unknown fields, retry with only the valid fields listed in the error. +- Act decisively — execute tools directly for routine configuration. +- If uncertain about a value, use sensible defaults and note the assumption. +MD; +} + +/** + * Default pipeline context (replaces PipelineContextDirective). + * + * @since 0.58.0 + */ +function datamachine_default_pipeline_context(): string { + return <<<'MD' +# Pipeline Execution Context + +This is an automated pipeline step — not a chat session. You're processing data through a multi-step workflow. Your identity and knowledge come from your memory files above. Apply that context to the content you process. + +## How Pipelines Work + +- Each pipeline step has a specific purpose within the overall workflow +- Handler tools produce final results — execute once per workflow objective +- Analyze available data and context before taking action + +## Data Packet Structure + +You receive content as JSON data packets with these guaranteed fields: +- type: The step type that created this packet +- timestamp: When the packet was created + +Additional fields may include data, metadata, content, and handler-specific information. +MD; +} + +/** + * Default system context (replaces SystemContextDirective). + * + * @since 0.58.0 + */ +function datamachine_default_system_context(): string { + return <<<'MD' +# System Task Context + +This is a background system task — not a chat session. You are the internal agent responsible for automated housekeeping: generating session titles, summarizing content, and other system-level operations. + +Your identity and knowledge are already loaded from your memory files above. Use that context. + +## Task Behavior + +- Execute the task described in the user message below. +- Return exactly what the task asks for — no extra commentary, no meta-discussion. +- Apply your knowledge of this site, its voice, and its conventions from your memory files. + +## Session Title Generation + +When asked to generate a chat session title: create a concise, descriptive title (3-6 words) capturing the discussion essence. Return ONLY the title text, under 100 characters. +MD; +} diff --git a/inc/migrations/datamachine_ensure.php b/inc/migrations/datamachine_ensure.php new file mode 100644 index 000000000..c656c66c0 --- /dev/null +++ b/inc/migrations/datamachine_ensure.php @@ -0,0 +1,61 @@ +//! datamachine_ensure — extracted from migrations.php. + + +/** + * Create default agent memory files if they don't exist. + * + * Called on activation and lazily on any request that reads agent files + * (via DirectoryManager::ensure_agent_files()). Existing files are never + * overwritten — only missing files are recreated from scaffold defaults. + * + * @since 0.30.0 + */ +function datamachine_ensure_default_memory_files() { + $ability = \DataMachine\Abilities\File\ScaffoldAbilities::get_ability(); + if ( ! $ability ) { + return; + } + + $default_user_id = \DataMachine\Core\FilesRepository\DirectoryManager::get_default_agent_user_id(); + + $ability->execute( array( 'layer' => 'agent', 'user_id' => $default_user_id ) ); + $ability->execute( array( 'layer' => 'user', 'user_id' => $default_user_id ) ); + + // Scaffold default context memory files (contexts/{context}.md). + datamachine_ensure_default_context_files( $default_user_id ); +} + +/** + * Scaffold default context memory files (contexts/{context}.md). + * + * Creates the contexts/ directory and writes default context files + * for each core execution context. Existing files are never overwritten. + * + * @since 0.58.0 + * + * @param int $user_id Default agent user ID. + */ +function datamachine_ensure_default_context_files( int $user_id ): void { + $dm = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $contexts_dir = $dm->get_contexts_directory( array( 'user_id' => $user_id ) ); + + if ( ! $dm->ensure_directory_exists( $contexts_dir ) ) { + return; + } + + global $wp_filesystem; + if ( ! $wp_filesystem ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + \WP_Filesystem(); + } + + $defaults = datamachine_get_default_context_files(); + + foreach ( $defaults as $slug => $content ) { + $filepath = trailingslashit( $contexts_dir ) . $slug . '.md'; + if ( file_exists( $filepath ) ) { + continue; + } + $wp_filesystem->put_contents( $filepath, $content, FS_CHMOD_FILE ); + } +} diff --git a/inc/migrations/datamachine_regenerate.php b/inc/migrations/datamachine_regenerate.php new file mode 100644 index 000000000..852477883 --- /dev/null +++ b/inc/migrations/datamachine_regenerate.php @@ -0,0 +1,102 @@ +//! datamachine_regenerate — extracted from migrations.php. + + +/** + * Regenerate SITE.md on disk from current WordPress state. + * + * Called by invalidation hooks when site structure changes (plugins, + * themes, post types, taxonomies, options). Debounced via a short-lived + * transient to avoid excessive writes during bulk operations. + * + * SITE.md is read-only — it is fully regenerated from live WordPress data. + * To extend SITE.md content, use the `datamachine_site_scaffold_content` filter. + * + * @since 0.48.0 + * @since 0.50.0 Removed marker; SITE.md is now read-only. + * @return void + */ +function datamachine_regenerate_site_md(): void { + // Debounce: skip if we regenerated in the last 60 seconds. + if ( get_transient( 'datamachine_site_md_regenerating' ) ) { + return; + } + set_transient( 'datamachine_site_md_regenerating', 1, 60 ); + + // Check the setting — if disabled, skip regeneration. + if ( ! \DataMachine\Core\PluginSettings::get( 'site_context_enabled', true ) ) { + return; + } + + $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $shared_dir = $directory_manager->get_shared_directory(); + $site_md_path = trailingslashit( $shared_dir ) . 'SITE.md'; + + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + if ( ! $fs ) { + return; + } + + $content = datamachine_get_site_scaffold_content(); + + if ( ! is_dir( $shared_dir ) ) { + wp_mkdir_p( $shared_dir ); + } + + $fs->put_contents( $site_md_path, $content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $site_md_path ); +} + +/** + * Regenerate NETWORK.md from live WordPress multisite data. + * + * Same pattern as datamachine_regenerate_site_md(): + * - 60-second debounce via transient + * - Respects site_context_enabled setting + * - Only runs on multisite installs + * + * NETWORK.md is read-only — fully regenerated from live multisite data. + * To extend NETWORK.md content, use the `datamachine_network_scaffold_content` filter. + * + * @since 0.49.1 + * @since 0.50.0 Removed marker; NETWORK.md is now read-only. + * @return void + */ +function datamachine_regenerate_network_md(): void { + if ( ! is_multisite() ) { + return; + } + + // Debounce: skip if we regenerated in the last 60 seconds. + // Use a network-wide transient so subsites don't each trigger a write. + if ( get_site_transient( 'datamachine_network_md_regenerating' ) ) { + return; + } + set_site_transient( 'datamachine_network_md_regenerating', 1, 60 ); + + // Check the setting — if disabled, skip regeneration. + if ( ! \DataMachine\Core\PluginSettings::get( 'site_context_enabled', true ) ) { + return; + } + + $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $network_dir = $directory_manager->get_network_directory(); + $network_md_path = trailingslashit( $network_dir ) . 'NETWORK.md'; + + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + if ( ! $fs ) { + return; + } + + $content = datamachine_get_network_scaffold_content(); + + if ( empty( $content ) ) { + return; + } + + if ( ! is_dir( $network_dir ) ) { + wp_mkdir_p( $network_dir ); + } + + $fs->put_contents( $network_md_path, $content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_md_path ); +} diff --git a/inc/migrations/datamachine_register.php b/inc/migrations/datamachine_register.php new file mode 100644 index 000000000..868a88029 --- /dev/null +++ b/inc/migrations/datamachine_register.php @@ -0,0 +1,98 @@ +//! datamachine_register — extracted from migrations.php. + + +/** + * Register hooks that trigger SITE.md regeneration on structural changes. + * + * These are the same hooks that SiteContext used for cache invalidation, + * but now they regenerate the actual file on disk. The debounce in + * datamachine_regenerate_site_md() prevents excessive writes. + * + * @since 0.48.0 + * @return void + */ +function datamachine_register_site_md_invalidation(): void { + $callback = 'datamachine_regenerate_site_md'; + + // Plugin/theme structural changes — always regenerate. + add_action( 'switch_theme', $callback ); + add_action( 'activated_plugin', $callback ); + add_action( 'deactivated_plugin', $callback ); + + // Post lifecycle — updates published counts. + add_action( 'save_post', $callback ); + add_action( 'delete_post', $callback ); + add_action( 'wp_trash_post', $callback ); + add_action( 'untrash_post', $callback ); + + // Term lifecycle — updates term counts. + add_action( 'create_term', $callback ); + add_action( 'edit_term', $callback ); + add_action( 'delete_term', $callback ); + + // Site identity and structure changes. + add_action( 'update_option_blogname', $callback ); + add_action( 'update_option_blogdescription', $callback ); + add_action( 'update_option_home', $callback ); + add_action( 'update_option_siteurl', $callback ); + add_action( 'update_option_permalink_structure', $callback ); + add_action( 'update_option_page_on_front', $callback ); + add_action( 'update_option_page_for_posts', $callback ); + add_action( 'update_option_show_on_front', $callback ); + + // Menu changes. + add_action( 'wp_update_nav_menu', $callback ); + add_action( 'wp_delete_nav_menu', $callback ); + add_action( 'wp_update_nav_menu_item', $callback ); +} + +/** + * Register hooks that trigger NETWORK.md regeneration on structural changes. + * + * Only registers on multisite installs. Covers site lifecycle, URL changes, + * network plugin activations, and theme switches. The debounce in + * datamachine_regenerate_network_md() prevents excessive writes. + * + * @since 0.49.1 + * @return void + */ +function datamachine_register_network_md_invalidation(): void { + if ( ! is_multisite() ) { + return; + } + + $callback = 'datamachine_regenerate_network_md'; + + // Site lifecycle — new sites, deleted sites. + add_action( 'wp_initialize_site', $callback ); + add_action( 'wp_delete_site', $callback ); + add_action( 'wp_uninitialize_site', $callback ); + + // Site identity changes — URL or name changes on any site. + add_action( 'update_option_siteurl', $callback ); + add_action( 'update_option_home', $callback ); + add_action( 'update_option_blogname', $callback ); + + // Plugin/theme structural changes — affects network plugin list. + add_action( 'activated_plugin', $callback ); + add_action( 'deactivated_plugin', $callback ); + add_action( 'switch_theme', $callback ); +} + +/** + * Register default content generators for datamachine/scaffold-memory-file. + * + * Each generator handles one filename and builds content from the + * context array (user_id, agent_slug, etc.). Generators are composable + * via the `datamachine_scaffold_content` filter — plugins can override + * or extend any file's default content. + * + * @since 0.50.0 + */ +function datamachine_register_scaffold_generators(): void { + add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_user_content', 10, 3 ); + add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_soul_content', 10, 3 ); + add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_memory_content', 10, 3 ); + add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_daily_content', 10, 3 ); + add_filter( 'datamachine_scaffold_content', 'datamachine_scaffold_rules_content', 10, 3 ); +} diff --git a/inc/migrations/datamachine_scaffold.php b/inc/migrations/datamachine_scaffold.php new file mode 100644 index 000000000..efa3b734b --- /dev/null +++ b/inc/migrations/datamachine_scaffold.php @@ -0,0 +1,194 @@ +//! datamachine_scaffold — extracted from migrations.php. + + +/** + * Generate USER.md content from WordPress user profile data. + * + * @since 0.50.0 + * + * @param string $content Current content (empty if no prior generator). + * @param string $filename Filename being scaffolded. + * @param array $context Scaffolding context with user_id. + * @return string + */ +function datamachine_scaffold_user_content( string $content, string $filename, array $context ): string { + if ( 'USER.md' !== $filename || '' !== $content ) { + return $content; + } + + $user_id = (int) ( $context['user_id'] ?? 0 ); + if ( $user_id <= 0 ) { + return $content; + } + + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return $content; + } + + $about_lines = array(); + $about_lines[] = sprintf( '- **Name:** %s', $user->display_name ); + $about_lines[] = sprintf( '- **Username:** %s', $user->user_login ); + + $roles = $user->roles; + if ( ! empty( $roles ) ) { + $role_name = ucfirst( reset( $roles ) ); + $about_lines[] = sprintf( '- **Role:** %s', $role_name ); + } + + if ( ! empty( $user->user_registered ) ) { + $registered = wp_date( 'F Y', strtotime( $user->user_registered ) ); + $about_lines[] = sprintf( '- **Member since:** %s', $registered ); + } + + $post_count = count_user_posts( $user_id, 'post', true ); + if ( $post_count > 0 ) { + $about_lines[] = sprintf( '- **Published posts:** %d', $post_count ); + } + + $description = get_user_meta( $user_id, 'description', true ); + if ( ! empty( $description ) ) { + $clean_bio = wp_strip_all_tags( $description ); + $about_lines[] = sprintf( "\n%s", $clean_bio ); + } + + $about = implode( "\n", $about_lines ); + + return << + +## Goals + +MD; +} + +/** + * Generate SOUL.md content from site and agent context. + * + * Uses scaffolding context (agent_slug, agent_id) to resolve the agent's + * display name from the database and embed it in the identity section. + * Falls back to the generic template when no agent context is available. + * + * @since 0.50.0 + * @since 0.51.0 Resolves agent_name from context for identity-aware scaffolding. + * + * @param string $content Current content. + * @param string $filename Filename being scaffolded. + * @param array $context Scaffolding context with agent_slug, agent_id, or user_id. + * @return string + */ +function datamachine_scaffold_soul_content( string $content, string $filename, array $context ): string { + if ( 'SOUL.md' !== $filename || '' !== $content ) { + return $content; + } + + // Resolve agent identity from context. + $agent_name = datamachine_resolve_agent_name_from_context( $context ); + + $defaults = datamachine_get_scaffold_defaults( $agent_name ); + return $defaults['SOUL.md'] ?? ''; +} + +/** + * Generate MEMORY.md content from site context. + * + * @since 0.50.0 + * + * @param string $content Current content. + * @param string $filename Filename being scaffolded. + * @param array $context Scaffolding context. + * @return string + */ +function datamachine_scaffold_memory_content( string $content, string $filename, array $context ): string { + if ( 'MEMORY.md' !== $filename || '' !== $content ) { + return $content; + } + + $defaults = datamachine_get_scaffold_defaults(); + return $defaults['MEMORY.md'] ?? ''; +} + +/** + * Generate RULES.md scaffold content. + * + * Creates a starter template for site-wide behavioral constraints. + * RULES.md is admin-editable and applies to every agent on the site. + * + * @since 0.50.0 + * + * @param string $content Current content. + * @param string $filename Filename being scaffolded. + * @param array $context Scaffolding context. + * @return string + */ +function datamachine_scaffold_rules_content( string $content, string $filename, array $context ): string { + if ( 'RULES.md' !== $filename || '' !== $content ) { + return $content; + } + + $site_name = get_bloginfo( 'name' ) ?: 'this site'; + + return <<