From 4b3532f1ee20a12840788217effc2a71681b3fa2 Mon Sep 17 00:00:00 2001
From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com>
Date: Sat, 28 Mar 2026 15:42:49 +0000
Subject: [PATCH] =?UTF-8?q?chore(ci):=20homeboy=20autofix=20=E2=80=94=20re?=
=?UTF-8?q?factor=20(164=20files)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
data-machine.php
data-machine/datamachine.php
data-machine/datamachine_load.php
docs/admin-interface/pipeline-builder.md
docs/api/endpoints/files.md
docs/api/index.md
docs/core-system/abilities-api.md
docs/core-system/ai-directives.md
docs/core-system/system-tasks.md
docs/core-system/wordpress-as-agent-memory.md
docs/core-system/workspace-system.md
docs/development/hooks/core-actions.md
docs/overview.md
homeboy.json
inc/Abilities/AgentPingAbilities.php
inc/Abilities/AgentTokenAbilities.php
inc/Abilities/Analytics/BingWebmasterAbilities.php
inc/Abilities/Analytics/GoogleAnalyticsAbilities.php
inc/Abilities/Analytics/GoogleSearchConsoleAbilities.php
inc/Abilities/Analytics/PageSpeedAbilities.php
inc/Abilities/AuthAbilities.php
inc/Abilities/ChatAbilities.php
inc/Abilities/Content/EditPostBlocksAbility.php
inc/Abilities/Content/InsertContentAbility.php
inc/Abilities/Content/ReplacePostBlocksAbility.php
inc/Abilities/Email/EmailAbilities.php
inc/Abilities/Email/EmailAbilities/connect.php
inc/Abilities/Email/EmailAbilities/helpers.php
inc/Abilities/Engine/ExecuteStepAbility.php
inc/Abilities/Engine/PipelineBatchScheduler.php
inc/Abilities/EngineAbilities.php
inc/Abilities/Fetch/FetchEmailAbility.php
inc/Abilities/Fetch/FetchFilesAbility.php
inc/Abilities/Fetch/FetchRssAbility.php
inc/Abilities/Fetch/FetchWordPressApiAbility.php
inc/Abilities/Fetch/FetchWordPressMediaAbility.php
inc/Abilities/Fetch/GetWordPressPostAbility.php
inc/Abilities/Fetch/QueryWordPressPostsAbility.php
inc/Abilities/File/AgentFileAbilities.php
inc/Abilities/File/FlowFileAbilities.php
inc/Abilities/Flow/FlowHelpers.php
inc/Abilities/Flow/PauseFlowAbility.php
inc/Abilities/Flow/QueueAbility.php
inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php
inc/Abilities/Flow/QueueAbility/helpers.php
inc/Abilities/Flow/ResumeFlowAbility.php
inc/Abilities/FlowAbilities.php
inc/Abilities/FlowStep/FlowStepHelpers.php
inc/Abilities/FlowStepAbilities.php
inc/Abilities/Handler/TestHandlerAbility.php
inc/Abilities/HandlerAbilities.php
inc/Abilities/InternalLinkingAbilities.php
inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php
inc/Abilities/InternalLinkingAbilities/registerAbilities.php
inc/Abilities/Job/JobHelpers.php
inc/Abilities/JobAbilities.php
inc/Abilities/Media/ImageGenerationAbilities.php
inc/Abilities/Pipeline/PipelineHelpers.php
inc/Abilities/PipelineAbilities.php
inc/Abilities/PipelineStepAbilities.php
inc/Abilities/ProcessedItemsAbilities.php
inc/Abilities/Publish/PublishWordPressAbility.php
inc/Abilities/Publish/SendEmailAbility.php
inc/Abilities/SettingsAbilities.php
inc/Abilities/StepTypeAbilities.php
inc/Abilities/Taxonomy/CreateTaxonomyTermAbility.php
inc/Abilities/Taxonomy/DeleteTaxonomyTermAbility.php
inc/Abilities/Taxonomy/GetTaxonomyTermsAbility.php
inc/Abilities/Taxonomy/ResolveTermAbility.php
inc/Abilities/Taxonomy/UpdateTaxonomyTermAbility.php
inc/Abilities/TaxonomyAbilities.php
inc/Api/AgentFiles.php
inc/Api/AgentPing.php
inc/Api/Agents.php
inc/Api/Analytics.php
inc/Api/Auth.php
inc/Api/Chat/Chat.php
inc/Api/Chat/ChatOrchestrator.php
inc/Api/Chat/Tools/AddPipelineStep.php
inc/Api/Chat/Tools/CreatePipeline.php
inc/Api/Email.php
inc/Api/Execute.php
inc/Api/FlowFiles.php
inc/Api/Flows/FlowQueue.php
inc/Api/Flows/FlowScheduling.php
inc/Api/Flows/FlowSteps.php
inc/Api/Flows/Flows.php
inc/Api/Flows/Flows/handle.php
inc/Api/Flows/Flows/handle_bulk.php
inc/Api/Flows/Flows/handle_get.php
inc/Api/Flows/Flows/register.php
inc/Api/Flows/Flows/sanitize_daily_memory.php
inc/Api/Handlers.php
inc/Api/InternalLinks.php
inc/Api/Jobs.php
inc/Api/Logs.php
inc/Api/Pipelines/PipelineFlows.php
inc/Api/Pipelines/PipelineSteps.php
inc/Api/Pipelines/Pipelines.php
inc/Api/ProcessedItems.php
inc/Api/Providers.php
inc/Api/Settings.php
inc/Api/StepTypes.php
inc/Api/Tools.php
inc/Api/Users.php
inc/Api/WebhookTrigger.php
inc/Cli/Commands/AgentsCommand.php
inc/Cli/Commands/EmailCommand.php
inc/Cli/Commands/Flows/FlowsCommand.php
inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php
inc/Cli/Commands/Flows/FlowsCommand/helpers.php
inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php
inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php
inc/Cli/Commands/JobsCommand.php
inc/Cli/Commands/JobsCommand/delete.php
inc/Cli/Commands/JobsCommand/helpers.php
inc/Cli/Commands/JobsCommand/show.php
inc/Cli/Commands/MemoryCommand.php
inc/Cli/Commands/MemoryCommand/agent_files_multi.php
inc/Cli/Commands/MemoryCommand/daily.php
inc/Cli/Commands/MemoryCommand/search.php
inc/Cli/Commands/MemoryCommand/sections.php
inc/Cli/Commands/MemoryCommand/write.php
inc/Cli/Commands/PipelinesCommand.php
inc/Cli/Commands/ProcessedItemsCommand.php
inc/Cli/Commands/RetentionCommand.php
inc/Cli/Commands/TestCommand.php
inc/Core/ActionScheduler/ActionsCleanup.php
inc/Core/Admin/FlowFormatter.php
inc/Core/Auth/AgentAuthCallback.php
inc/Core/Auth/AgentAuthMiddleware.php
inc/Core/Auth/AgentAuthorize.php
inc/Core/Database/Agents/AgentAccess.php
inc/Core/Database/Agents/AgentTokens.php
inc/Core/Database/Logs/LogRepository.php
inc/Core/Database/PostIdentityIndex/PostIdentityIndex.php
inc/Core/OAuth/OAuth2Handler.php
inc/Core/Steps/Fetch/Handlers/WordPress/WordPress.php
inc/Core/Steps/Fetch/Handlers/WordPressMedia/WordPressMedia.php
inc/Core/Steps/Fetch/Tools/SkipItemTool.php
inc/Core/Steps/Publish/Handlers/PublishHandler.php
inc/Core/Steps/Update/UpdateStep.php
inc/Engine/AI/Directives/ClientContextDirective.php
inc/Engine/AI/System/SystemAgentServiceProvider.php
inc/Engine/AI/System/Tasks/AltTextTask.php
inc/Engine/AI/System/Tasks/ImageGenerationTask.php
inc/Engine/AI/System/Tasks/ImageOptimizationTask.php
inc/Engine/AI/System/Tasks/InternalLinkingTask.php
inc/Engine/AI/System/Tasks/MetaDescriptionTask.php
inc/Engine/AI/Tools/Global/AgentDailyMemory.php
inc/Engine/AI/Tools/Global/AgentMemory.php
inc/Engine/AI/Tools/Global/AmazonAffiliateLink.php
inc/Engine/AI/Tools/Global/InternalLinkAudit.php
inc/Engine/AI/Tools/Global/LocalSearch.php
inc/Engine/AI/Tools/Global/WebFetch.php
inc/Engine/AI/Tools/Global/WordPressPostReader.php
inc/migrations.php
inc/migrations/build_content.php
inc/migrations/datamachine.php
inc/migrations/datamachine_default.php
inc/migrations/datamachine_ensure.php
inc/migrations/datamachine_regenerate.php
inc/migrations/datamachine_register.php
inc/migrations/datamachine_scaffold.php
---
data-machine.php | 644 -----
data-machine/datamachine.php | 397 +++
data-machine/datamachine_load.php | 252 ++
docs/admin-interface/pipeline-builder.md | 2 +-
docs/api/endpoints/files.md | 8 +-
docs/api/index.md | 4 +-
docs/core-system/abilities-api.md | 194 +-
docs/core-system/ai-directives.md | 3 -
docs/core-system/system-tasks.md | 1 -
docs/core-system/wordpress-as-agent-memory.md | 3 +-
docs/core-system/workspace-system.md | 9 -
docs/development/hooks/core-actions.md | 2 -
docs/overview.md | 3 +-
homeboy.json | 1364 +++++++++-
inc/Abilities/AgentPingAbilities.php | 2 +
inc/Abilities/AgentTokenAbilities.php | 11 +-
.../Analytics/BingWebmasterAbilities.php | 2 +
.../Analytics/GoogleAnalyticsAbilities.php | 1 +
.../GoogleSearchConsoleAbilities.php | 2 +
.../Analytics/PageSpeedAbilities.php | 2 +
inc/Abilities/AuthAbilities.php | 2 +
inc/Abilities/ChatAbilities.php | 1 +
.../Content/EditPostBlocksAbility.php | 46 +-
.../Content/InsertContentAbility.php | 60 +-
.../Content/ReplacePostBlocksAbility.php | 36 +-
inc/Abilities/Email/EmailAbilities.php | 1267 ---------
.../Email/EmailAbilities/connect.php | 381 +++
.../Email/EmailAbilities/helpers.php | 890 +++++++
inc/Abilities/Engine/ExecuteStepAbility.php | 2 +-
.../Engine/PipelineBatchScheduler.php | 4 +-
inc/Abilities/EngineAbilities.php | 1 +
inc/Abilities/Fetch/FetchEmailAbility.php | 21 +-
inc/Abilities/Fetch/FetchFilesAbility.php | 13 +-
inc/Abilities/Fetch/FetchRssAbility.php | 20 +-
.../Fetch/FetchWordPressApiAbility.php | 18 +-
.../Fetch/FetchWordPressMediaAbility.php | 26 +-
.../Fetch/GetWordPressPostAbility.php | 1 +
.../Fetch/QueryWordPressPostsAbility.php | 24 +-
inc/Abilities/File/AgentFileAbilities.php | 24 +-
inc/Abilities/File/FlowFileAbilities.php | 1 +
inc/Abilities/Flow/FlowHelpers.php | 1 +
inc/Abilities/Flow/PauseFlowAbility.php | 4 +-
inc/Abilities/Flow/QueueAbility.php | 1178 ---------
.../QueueAbility/getStepConfigForQueue.php | 1098 ++++++++
inc/Abilities/Flow/QueueAbility/helpers.php | 84 +
inc/Abilities/Flow/ResumeFlowAbility.php | 1 +
inc/Abilities/FlowAbilities.php | 2 +
inc/Abilities/FlowStep/FlowStepHelpers.php | 1 +
inc/Abilities/FlowStepAbilities.php | 2 +
inc/Abilities/Handler/TestHandlerAbility.php | 18 +-
inc/Abilities/HandlerAbilities.php | 1 +
inc/Abilities/InternalLinkingAbilities.php | 850 +-----
.../extractInternalLinks.php | 339 +++
.../registerAbilities.php | 509 ++++
inc/Abilities/Job/JobHelpers.php | 1 +
inc/Abilities/JobAbilities.php | 2 +
.../Media/ImageGenerationAbilities.php | 1 +
inc/Abilities/Pipeline/PipelineHelpers.php | 1 +
inc/Abilities/PipelineAbilities.php | 2 +
inc/Abilities/PipelineStepAbilities.php | 1 +
inc/Abilities/ProcessedItemsAbilities.php | 1 +
.../Publish/PublishWordPressAbility.php | 1 +
inc/Abilities/Publish/SendEmailAbility.php | 7 +-
inc/Abilities/SettingsAbilities.php | 1 +
inc/Abilities/StepTypeAbilities.php | 2 +
.../Taxonomy/CreateTaxonomyTermAbility.php | 1 +
.../Taxonomy/DeleteTaxonomyTermAbility.php | 1 +
.../Taxonomy/GetTaxonomyTermsAbility.php | 1 +
inc/Abilities/Taxonomy/ResolveTermAbility.php | 1 +
.../Taxonomy/UpdateTaxonomyTermAbility.php | 1 +
inc/Abilities/TaxonomyAbilities.php | 2 +
inc/Api/AgentFiles.php | 3 +-
inc/Api/AgentPing.php | 1 +
inc/Api/Agents.php | 1 +
inc/Api/Analytics.php | 1 +
inc/Api/Auth.php | 5 +-
inc/Api/Chat/Chat.php | 7 +-
inc/Api/Chat/ChatOrchestrator.php | 9 +-
inc/Api/Chat/Tools/AddPipelineStep.php | 1 +
inc/Api/Chat/Tools/CreatePipeline.php | 1 +
inc/Api/Email.php | 261 +-
inc/Api/Execute.php | 1 +
inc/Api/FlowFiles.php | 1 +
inc/Api/Flows/FlowQueue.php | 1 +
inc/Api/Flows/FlowScheduling.php | 4 +
inc/Api/Flows/FlowSteps.php | 1 +
inc/Api/Flows/Flows.php | 996 +------
inc/Api/Flows/Flows/handle.php | 278 ++
inc/Api/Flows/Flows/handle_bulk.php | 92 +
inc/Api/Flows/Flows/handle_get.php | 188 ++
inc/Api/Flows/Flows/register.php | 315 +++
inc/Api/Flows/Flows/sanitize_daily_memory.php | 133 +
inc/Api/Handlers.php | 1 +
inc/Api/InternalLinks.php | 1 +
inc/Api/Jobs.php | 49 +-
inc/Api/Logs.php | 1 +
inc/Api/Pipelines/PipelineFlows.php | 1 +
inc/Api/Pipelines/PipelineSteps.php | 1 +
inc/Api/Pipelines/Pipelines.php | 1 +
inc/Api/ProcessedItems.php | 1 +
inc/Api/Providers.php | 1 +
inc/Api/Settings.php | 1 +
inc/Api/StepTypes.php | 1 +
inc/Api/Tools.php | 1 +
inc/Api/Users.php | 1 +
inc/Api/WebhookTrigger.php | 1 +
inc/Cli/Commands/AgentsCommand.php | 14 +-
inc/Cli/Commands/EmailCommand.php | 24 +-
inc/Cli/Commands/Flows/FlowsCommand.php | 1527 -----------
.../FlowsCommand/buildPauseResumeInput.php | 115 +
.../Commands/Flows/FlowsCommand/helpers.php | 803 ++++++
.../Flows/FlowsCommand/resolveHandlerStep.php | 467 ++++
.../Flows/FlowsCommand/truncateValue.php | 150 ++
inc/Cli/Commands/JobsCommand.php | 1109 --------
inc/Cli/Commands/JobsCommand/delete.php | 399 +++
inc/Cli/Commands/JobsCommand/helpers.php | 373 +++
inc/Cli/Commands/JobsCommand/show.php | 343 +++
inc/Cli/Commands/MemoryCommand.php | 989 -------
.../MemoryCommand/agent_files_multi.php | 481 ++++
inc/Cli/Commands/MemoryCommand/daily.php | 182 ++
inc/Cli/Commands/MemoryCommand/search.php | 91 +
inc/Cli/Commands/MemoryCommand/sections.php | 133 +
inc/Cli/Commands/MemoryCommand/write.php | 112 +
inc/Cli/Commands/PipelinesCommand.php | 2 +-
inc/Cli/Commands/ProcessedItemsCommand.php | 22 +-
inc/Cli/Commands/RetentionCommand.php | 16 +-
inc/Cli/Commands/TestCommand.php | 12 +-
inc/Core/ActionScheduler/ActionsCleanup.php | 4 +
inc/Core/Admin/FlowFormatter.php | 2 +-
inc/Core/Auth/AgentAuthCallback.php | 1 +
inc/Core/Auth/AgentAuthMiddleware.php | 11 +-
inc/Core/Auth/AgentAuthorize.php | 14 +-
inc/Core/Database/Agents/AgentAccess.php | 1 +
inc/Core/Database/Agents/AgentTokens.php | 1 +
inc/Core/Database/Logs/LogRepository.php | 6 +-
.../PostIdentityIndex/PostIdentityIndex.php | 65 +-
inc/Core/OAuth/OAuth2Handler.php | 2 +
.../Fetch/Handlers/WordPress/WordPress.php | 18 +-
.../WordPressMedia/WordPressMedia.php | 1 +
inc/Core/Steps/Fetch/Tools/SkipItemTool.php | 32 +-
.../Steps/Publish/Handlers/PublishHandler.php | 1 +
inc/Core/Steps/Update/UpdateStep.php | 1 -
.../AI/Directives/ClientContextDirective.php | 4 +-
.../AI/System/SystemAgentServiceProvider.php | 6 +-
inc/Engine/AI/System/Tasks/AltTextTask.php | 1 +
.../AI/System/Tasks/ImageGenerationTask.php | 1 +
.../AI/System/Tasks/ImageOptimizationTask.php | 19 +-
.../AI/System/Tasks/InternalLinkingTask.php | 1 +
.../AI/System/Tasks/MetaDescriptionTask.php | 1 +
.../AI/Tools/Global/AgentDailyMemory.php | 2 +
inc/Engine/AI/Tools/Global/AgentMemory.php | 1 +
.../AI/Tools/Global/AmazonAffiliateLink.php | 1 +
.../AI/Tools/Global/InternalLinkAudit.php | 1 +
inc/Engine/AI/Tools/Global/LocalSearch.php | 1 +
inc/Engine/AI/Tools/Global/WebFetch.php | 1 +
.../AI/Tools/Global/WordPressPostReader.php | 1 +
inc/migrations.php | 2283 -----------------
inc/migrations/build_content.php | 451 ++++
inc/migrations/datamachine.php | 1292 ++++++++++
inc/migrations/datamachine_default.php | 101 +
inc/migrations/datamachine_ensure.php | 61 +
inc/migrations/datamachine_regenerate.php | 102 +
inc/migrations/datamachine_register.php | 98 +
inc/migrations/datamachine_scaffold.php | 194 ++
164 files changed, 13056 insertions(+), 11312 deletions(-)
create mode 100644 data-machine/datamachine.php
create mode 100644 data-machine/datamachine_load.php
create mode 100644 inc/Abilities/Email/EmailAbilities/connect.php
create mode 100644 inc/Abilities/Email/EmailAbilities/helpers.php
create mode 100644 inc/Abilities/Flow/QueueAbility/getStepConfigForQueue.php
create mode 100644 inc/Abilities/Flow/QueueAbility/helpers.php
create mode 100644 inc/Abilities/InternalLinkingAbilities/extractInternalLinks.php
create mode 100644 inc/Abilities/InternalLinkingAbilities/registerAbilities.php
create mode 100644 inc/Api/Flows/Flows/handle.php
create mode 100644 inc/Api/Flows/Flows/handle_bulk.php
create mode 100644 inc/Api/Flows/Flows/handle_get.php
create mode 100644 inc/Api/Flows/Flows/register.php
create mode 100644 inc/Api/Flows/Flows/sanitize_daily_memory.php
create mode 100644 inc/Cli/Commands/Flows/FlowsCommand/buildPauseResumeInput.php
create mode 100644 inc/Cli/Commands/Flows/FlowsCommand/helpers.php
create mode 100644 inc/Cli/Commands/Flows/FlowsCommand/resolveHandlerStep.php
create mode 100644 inc/Cli/Commands/Flows/FlowsCommand/truncateValue.php
create mode 100644 inc/Cli/Commands/JobsCommand/delete.php
create mode 100644 inc/Cli/Commands/JobsCommand/helpers.php
create mode 100644 inc/Cli/Commands/JobsCommand/show.php
create mode 100644 inc/Cli/Commands/MemoryCommand/agent_files_multi.php
create mode 100644 inc/Cli/Commands/MemoryCommand/daily.php
create mode 100644 inc/Cli/Commands/MemoryCommand/search.php
create mode 100644 inc/Cli/Commands/MemoryCommand/sections.php
create mode 100644 inc/Cli/Commands/MemoryCommand/write.php
create mode 100644 inc/migrations/build_content.php
create mode 100644 inc/migrations/datamachine.php
create mode 100644 inc/migrations/datamachine_default.php
create mode 100644 inc/migrations/datamachine_ensure.php
create mode 100644 inc/migrations/datamachine_regenerate.php
create mode 100644 inc/migrations/datamachine_register.php
create mode 100644 inc/migrations/datamachine_scaffold.php
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 <<