From b04254b4154bfe29aba897ca2d8dedb974c67eea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 4 Mar 2026 05:16:14 +0000 Subject: [PATCH 1/8] fix: allow nullable types in create-pipeline and create-flow output schemas Both abilities return different fields depending on the execution path (single vs bulk, with/without flow). Fields like flow_id are null when no flow_config is provided, but the output schema declared them as strict 'integer', causing WP_Ability::validate_output() to reject the result with ability_invalid_output. Use array('integer', 'null') union types per JSON Schema spec to allow null values. --- inc/Abilities/Flow/CreateFlowAbility.php | 24 ++++++++-------- .../Pipeline/CreatePipelineAbility.php | 28 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/inc/Abilities/Flow/CreateFlowAbility.php b/inc/Abilities/Flow/CreateFlowAbility.php index 18b6495f..fa571856 100644 --- a/inc/Abilities/Flow/CreateFlowAbility.php +++ b/inc/Abilities/Flow/CreateFlowAbility.php @@ -84,18 +84,18 @@ private function registerAbility(): void { 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_name' => array( 'type' => 'string' ), - 'pipeline_id' => array( 'type' => 'integer' ), - 'synced_steps' => array( 'type' => 'integer' ), - 'flow_data' => array( 'type' => 'object' ), - 'created_count' => array( 'type' => 'integer' ), - 'failed_count' => array( 'type' => 'integer' ), - 'created' => array( 'type' => 'array' ), - 'errors' => array( 'type' => 'array' ), - 'partial' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), + 'flow_id' => array( 'type' => array( 'integer', 'null' ) ), + 'flow_name' => array( 'type' => array( 'string', 'null' ) ), + 'pipeline_id' => array( 'type' => array( 'integer', 'null' ) ), + 'synced_steps' => array( 'type' => array( 'integer', 'null' ) ), + 'flow_data' => array( 'type' => array( 'object', 'null' ) ), + 'created_count' => array( 'type' => array( 'integer', 'null' ) ), + 'failed_count' => array( 'type' => array( 'integer', 'null' ) ), + 'created' => array( 'type' => array( 'array', 'null' ) ), + 'errors' => array( 'type' => array( 'array', 'null' ) ), + 'partial' => array( 'type' => array( 'boolean', 'null' ) ), + 'message' => array( 'type' => array( 'string', 'null' ) ), + 'error' => array( 'type' => array( 'string', 'null' ) ), ), ), 'execute_callback' => array( $this, 'execute' ), diff --git a/inc/Abilities/Pipeline/CreatePipelineAbility.php b/inc/Abilities/Pipeline/CreatePipelineAbility.php index 41746fb0..c0759254 100644 --- a/inc/Abilities/Pipeline/CreatePipelineAbility.php +++ b/inc/Abilities/Pipeline/CreatePipelineAbility.php @@ -69,20 +69,20 @@ private function registerAbility(): void { 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), - 'pipeline_id' => array( 'type' => 'integer' ), - 'pipeline_name' => array( 'type' => 'string' ), - 'flow_id' => array( 'type' => 'integer' ), - 'flow_name' => array( 'type' => 'string' ), - 'steps_created' => array( 'type' => 'integer' ), - 'flow_step_ids' => array( 'type' => 'array' ), - 'creation_mode' => array( 'type' => 'string' ), - 'created_count' => array( 'type' => 'integer' ), - 'failed_count' => array( 'type' => 'integer' ), - 'created' => array( 'type' => 'array' ), - 'errors' => array( 'type' => 'array' ), - 'partial' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), - 'error' => array( 'type' => 'string' ), + 'pipeline_id' => array( 'type' => array( 'integer', 'null' ) ), + 'pipeline_name' => array( 'type' => array( 'string', 'null' ) ), + 'flow_id' => array( 'type' => array( 'integer', 'null' ) ), + 'flow_name' => array( 'type' => array( 'string', 'null' ) ), + 'steps_created' => array( 'type' => array( 'integer', 'null' ) ), + 'flow_step_ids' => array( 'type' => array( 'array', 'null' ) ), + 'creation_mode' => array( 'type' => array( 'string', 'null' ) ), + 'created_count' => array( 'type' => array( 'integer', 'null' ) ), + 'failed_count' => array( 'type' => array( 'integer', 'null' ) ), + 'created' => array( 'type' => array( 'array', 'null' ) ), + 'errors' => array( 'type' => array( 'array', 'null' ) ), + 'partial' => array( 'type' => array( 'boolean', 'null' ) ), + 'message' => array( 'type' => array( 'string', 'null' ) ), + 'error' => array( 'type' => array( 'string', 'null' ) ), ), ), 'execute_callback' => array( $this, 'execute' ), From c192d6def457708babf82287ae8ddbad430a6ff6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 3 Mar 2026 23:20:28 +0000 Subject: [PATCH 2/8] feat: directives resolve user_id from execution context for multi-agent (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of multi-agent support: directives now read the correct agent's files based on user_id from the execution context. Pipeline path: - RunFlowAbility propagates flow's user_id to job record and engine snapshot - AIStep extracts user_id from engine snapshot into payload Chat path: - ChatOrchestrator threads user_id through options into loop_context - All three callers (processChat, processContinue, processPing) pass user_id Directives: - CoreMemoryFilesDirective resolves user_id from payload, passes to DirectoryManager::get_agent_directory() - MemoryFilesReader::read() accepts user_id param (default 0 for compat) - PipelineMemoryFilesDirective and FlowMemoryFilesDirective pass user_id through to MemoryFilesReader All changes default to user_id=0 — existing single-agent installs keep working without configuration. Refs: #565, #560 --- inc/Abilities/Engine/RunFlowAbility.php | 2 ++ inc/Api/Chat/ChatOrchestrator.php | 13 +++++++++++-- inc/Core/Steps/AI/AIStep.php | 5 +++++ .../AI/Directives/FlowMemoryFilesDirective.php | 4 +++- .../AI/Directives/PipelineMemoryFilesDirective.php | 3 ++- .../AI/Directives/CoreMemoryFilesDirective.php | 4 ++-- inc/Engine/AI/Directives/MemoryFilesReader.php | 9 ++++++--- 7 files changed, 31 insertions(+), 9 deletions(-) diff --git a/inc/Abilities/Engine/RunFlowAbility.php b/inc/Abilities/Engine/RunFlowAbility.php index beff7906..82a310cd 100644 --- a/inc/Abilities/Engine/RunFlowAbility.php +++ b/inc/Abilities/Engine/RunFlowAbility.php @@ -120,6 +120,7 @@ public function execute( array $input ): array { 'flow_id' => $flow_id, 'source' => 'pipeline', 'label' => $flow['flow_name'] ?? null, + 'user_id' => (int) ( $flow['user_id'] ?? 0 ), ) ); if ( ! $job_id ) { @@ -167,6 +168,7 @@ public function execute( array $input ): array { 'job_id' => $job_id, 'flow_id' => $flow_id, 'pipeline_id' => $pipeline_id, + 'user_id' => (int) ( $flow['user_id'] ?? 0 ), 'created_at' => current_time( 'mysql', true ), ), 'flow' => array( diff --git a/inc/Api/Chat/ChatOrchestrator.php b/inc/Api/Chat/ChatOrchestrator.php index b4f8fad7..18810d25 100644 --- a/inc/Api/Chat/ChatOrchestrator.php +++ b/inc/Api/Chat/ChatOrchestrator.php @@ -158,6 +158,7 @@ public static function processChat( 'max_turns' => $max_turns, 'selected_pipeline_id' => $selected_pipeline_id ? $selected_pipeline_id : null, 'agent_type' => AgentType::CHAT, + 'user_id' => $user_id, ) ); @@ -289,6 +290,7 @@ public static function processContinue( string $session_id, int $user_id ): arra 'max_turns' => $max_turns, 'selected_pipeline_id' => $selected_pipeline_id, 'agent_type' => AgentType::CHAT, + 'user_id' => (int) ( $session['user_id'] ?? 0 ), ) ); @@ -389,7 +391,10 @@ public static function processPing( string $message, string $provider, string $m $messages, $provider, $model, - array( 'agent_type' => AgentType::CHAT ) + array( + 'agent_type' => AgentType::CHAT, + 'user_id' => $user_id, + ) ); if ( is_wp_error( $result ) ) { @@ -544,7 +549,11 @@ public static function executeConversationTurn( $tool_manager = new ToolManager(); $all_tools = $tool_manager->getAvailableToolsForChat(); - $loop_context = array( 'session_id' => $session_id ); + $user_id = $options['user_id'] ?? 0; + $loop_context = array( + 'session_id' => $session_id, + 'user_id' => $user_id, + ); if ( $selected_pipeline_id ) { $loop_context['selected_pipeline_id'] = $selected_pipeline_id; } diff --git a/inc/Core/Steps/AI/AIStep.php b/inc/Core/Steps/AI/AIStep.php index 720b3a4d..7317f18f 100644 --- a/inc/Core/Steps/AI/AIStep.php +++ b/inc/Core/Steps/AI/AIStep.php @@ -176,12 +176,17 @@ protected function executeStep(): array { $max_turns = PluginSettings::get( 'max_turns', 12 ); + // Resolve user_id from engine snapshot (set by RunFlowAbility). + $job_snapshot = $this->engine->get( 'job' ); + $user_id = (int) ( $job_snapshot['user_id'] ?? 0 ); + $payload = array( 'job_id' => $this->job_id, 'flow_step_id' => $this->flow_step_id, 'step_id' => $pipeline_step_id, 'data' => $this->dataPackets, 'engine' => $this->engine, + 'user_id' => $user_id, ); $navigator = new \DataMachine\Engine\StepNavigator(); diff --git a/inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php b/inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php index 3aed9c16..0c76422a 100644 --- a/inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php +++ b/inc/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php @@ -54,7 +54,9 @@ public static function get_outputs( string $provider_name, array $tools, ?string $db_flows = new Flows(); $memory_files = $db_flows->get_flow_memory_files( $flow_id ); - return MemoryFilesReader::read( $memory_files, 'Flow', $flow_id ); + $user_id = (int) ( $payload['user_id'] ?? 0 ); + + return MemoryFilesReader::read( $memory_files, 'Flow', $flow_id, $user_id ); } } diff --git a/inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php b/inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php index 63878979..ac7d3d83 100644 --- a/inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php +++ b/inc/Core/Steps/AI/Directives/PipelineMemoryFilesDirective.php @@ -50,8 +50,9 @@ public static function get_outputs( string $provider_name, array $tools, ?string } $memory_files = $db_pipelines->get_pipeline_memory_files( (int) $pipeline_id ); + $user_id = (int) ( $payload['user_id'] ?? 0 ); - return MemoryFilesReader::read( $memory_files, 'Pipeline', (int) $pipeline_id ); + return MemoryFilesReader::read( $memory_files, 'Pipeline', (int) $pipeline_id, $user_id ); } } diff --git a/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php b/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php index 7e9fd15e..4f894b20 100644 --- a/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php +++ b/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php @@ -49,8 +49,8 @@ public static function get_outputs( string $provider_name, array $tools, ?string DirectoryManager::ensure_agent_files(); $directory_manager = new DirectoryManager(); - // TODO: Multi-agent Phase 2 — resolve user_id from execution context (#565). - $agent_dir = $directory_manager->get_agent_directory(); + $user_id = (int) ( $payload['user_id'] ?? 0 ); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); $outputs = array(); foreach ( $filenames as $filename ) { diff --git a/inc/Engine/AI/Directives/MemoryFilesReader.php b/inc/Engine/AI/Directives/MemoryFilesReader.php index 665aab7d..4e7243cc 100644 --- a/inc/Engine/AI/Directives/MemoryFilesReader.php +++ b/inc/Engine/AI/Directives/MemoryFilesReader.php @@ -23,16 +23,19 @@ class MemoryFilesReader { * @param array $memory_files Array of memory filenames. * @param string $scope_label Label for logging (e.g. 'Pipeline', 'Flow'). * @param int $scope_id Entity ID for logging (e.g. pipeline_id, flow_id). + * @param int $user_id WordPress user ID. 0 = legacy shared directory. * @return array Array of directive outputs (type => system_text, content => ...). */ - public static function read( array $memory_files, string $scope_label, int $scope_id ): array { + /** + * @since 0.37.0 Added $user_id parameter for multi-agent partitioning. + */ + public static function read( array $memory_files, string $scope_label, int $scope_id, int $user_id = 0 ): array { if ( empty( $memory_files ) ) { return array(); } $directory_manager = new DirectoryManager(); - // TODO: Multi-agent Phase 2 — resolve user_id from execution context (#565). - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); $outputs = array(); foreach ( $memory_files as $filename ) { From e3251dfff804a7b6150b7ce39e2b81b140092be7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 4 Mar 2026 00:31:49 +0000 Subject: [PATCH 3/8] =?UTF-8?q?ci:=20pin=20homeboy=20to=20v0.53.0=20?= =?UTF-8?q?=E2=80=94=20latest=20releases=20have=20no=20binaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/homeboy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/homeboy.yml b/.github/workflows/homeboy.yml index c7c6be8f..c906984f 100644 --- a/.github/workflows/homeboy.yml +++ b/.github/workflows/homeboy.yml @@ -17,6 +17,7 @@ jobs: - uses: Extra-Chill/homeboy-action@v1 with: + version: '0.53.0' extension: wordpress commands: lint,test,audit component: data-machine From 8f0243d729fb0e73660b2362d5635c96914ad43f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 4 Mar 2026 00:50:57 +0000 Subject: [PATCH 4/8] =?UTF-8?q?ci:=20add=20composer=20install=20before=20h?= =?UTF-8?q?omeboy=20=E2=80=94=20project=20needs=20its=20own=20autoloader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homeboy's WP test bootstrap loads the plugin on plugins_loaded, but the plugin requires vendor/autoload.php (PSR-4 autoloader). Without running composer install, no DataMachine classes are autoloadable, causing 'Class not found' fatals in PHPUnit. --- .github/workflows/homeboy.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/homeboy.yml b/.github/workflows/homeboy.yml index c906984f..295c28f2 100644 --- a/.github/workflows/homeboy.yml +++ b/.github/workflows/homeboy.yml @@ -15,6 +15,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, intl, pdo_sqlite, mysqli + tools: composer:v2 + coverage: none + + - name: Install project dependencies + run: composer install --no-interaction --prefer-dist + - uses: Extra-Chill/homeboy-action@v1 with: version: '0.53.0' From a521afb2f0d002b530b7084502f95a19c8b04629 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 4 Mar 2026 01:28:43 +0000 Subject: [PATCH 5/8] fix: add wp-cli/wp-cli as dev dep for CLI command tests BaseCommand extends WP_CLI_Command, which isn't available in the test environment without this dependency. WordPress core doesn't include WP-CLI. --- composer.json | 3 +- composer.lock | 305 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 306 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e4bb1a51..03f5006e 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "php-stubs/wordpress-stubs": "^6.9", "wp-coding-standards/wpcs": "^3.1", "phpcsstandards/phpcsutils": "^1.0", - "phpcompatibility/phpcompatibility-wp": "^2.1" + "phpcompatibility/phpcompatibility-wp": "^2.1", + "wp-cli/wp-cli": "^2.12" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 48a9a7b0..0895070f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "415ed07056e1cf07db4c3d0e3d5e2827", + "content-hash": "acb6cf491129e50ca30106c457029ff7", "packages": [ { "name": "chubes4/ai-http-client", @@ -879,6 +879,309 @@ ], "time": "2025-11-04T16:30:35+00:00" }, + { + "name": "symfony/finder", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:40:50+00:00" + }, + { + "name": "wp-cli/mustache", + "version": "v2.14.99", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/mustache.php.git", + "reference": "ca23b97ac35fbe01c160549eb634396183d04a59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/mustache.php/zipball/ca23b97ac35fbe01c160549eb634396183d04a59", + "reference": "ca23b97ac35fbe01c160549eb634396183d04a59", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "replace": { + "mustache/mustache": "^2.14.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.19.3", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Mustache": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "A Mustache implementation in PHP.", + "homepage": "https://github.com/bobthecow/mustache.php", + "keywords": [ + "mustache", + "templating" + ], + "support": { + "source": "https://github.com/wp-cli/mustache.php/tree/v2.14.99" + }, + "time": "2025-05-06T16:15:37+00:00" + }, + { + "name": "wp-cli/mustangostang-spyc", + "version": "0.6.3", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/spyc.git", + "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/spyc/zipball/6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", + "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", + "shasum": "" + }, + "require": { + "php": ">=5.3.1" + }, + "require-dev": { + "phpunit/phpunit": "4.3.*@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.5.x-dev" + } + }, + "autoload": { + "files": [ + "includes/functions.php" + ], + "psr-4": { + "Mustangostang\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "mustangostang", + "email": "vlad.andersen@gmail.com" + } + ], + "description": "A simple YAML loader/dumper class for PHP (WP-CLI fork)", + "homepage": "https://github.com/mustangostang/spyc/", + "support": { + "source": "https://github.com/wp-cli/spyc/tree/autoload" + }, + "time": "2017-04-25T11:26:20+00:00" + }, + { + "name": "wp-cli/php-cli-tools", + "version": "v0.12.7", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/php-cli-tools.git", + "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", + "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", + "shasum": "" + }, + "require": { + "php": ">= 7.2.24" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "lib/cli/cli.php" + ], + "psr-0": { + "cli": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Bachhuber", + "email": "daniel@handbuilt.co", + "role": "Maintainer" + }, + { + "name": "James Logsdon", + "email": "jlogsdon@php.net", + "role": "Developer" + } + ], + "description": "Console utilities for PHP", + "homepage": "http://github.com/wp-cli/php-cli-tools", + "keywords": [ + "cli", + "console" + ], + "support": { + "issues": "https://github.com/wp-cli/php-cli-tools/issues", + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.7" + }, + "time": "2026-01-20T20:31:49+00:00" + }, + { + "name": "wp-cli/wp-cli", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/wp-cli.git", + "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/03d30d4138d12b4bffd8b507b82e56e129e0523f", + "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": "^5.6 || ^7.0 || ^8.0", + "symfony/finder": ">2.7", + "wp-cli/mustache": "^2.14.99", + "wp-cli/mustangostang-spyc": "^0.6.3", + "wp-cli/php-cli-tools": "~0.12.4" + }, + "require-dev": { + "wp-cli/db-command": "^1.3 || ^2", + "wp-cli/entity-command": "^1.2 || ^2", + "wp-cli/extension-command": "^1.1 || ^2", + "wp-cli/package-command": "^1 || ^2", + "wp-cli/wp-cli-tests": "^4.3.10" + }, + "suggest": { + "ext-readline": "Include for a better --prompt implementation", + "ext-zip": "Needed to support extraction of ZIP archives when doing downloads or updates" + }, + "bin": [ + "bin/wp", + "bin/wp.bat" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.12.x-dev" + } + }, + "autoload": { + "psr-0": { + "WP_CLI\\": "php/" + }, + "classmap": [ + "php/class-wp-cli.php", + "php/class-wp-cli-command.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI framework", + "homepage": "https://wp-cli.org", + "keywords": [ + "cli", + "wordpress" + ], + "support": { + "docs": "https://make.wordpress.org/cli/handbook/", + "issues": "https://github.com/wp-cli/wp-cli/issues", + "source": "https://github.com/wp-cli/wp-cli" + }, + "time": "2025-05-07T01:16:12+00:00" + }, { "name": "wp-coding-standards/wpcs", "version": "3.3.0", From fd967c19bc7c1c0f1fedb26de42660a60918b6e2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 4 Mar 2026 02:15:31 +0000 Subject: [PATCH 6/8] =?UTF-8?q?revert:=20remove=20wp-cli/wp-cli=20dev=20de?= =?UTF-8?q?p=20=E2=80=94=20now=20provided=20by=20homeboy-extensions=20(#77?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 15 ++- composer.lock | 305 +------------------------------------------------- 2 files changed, 8 insertions(+), 312 deletions(-) diff --git a/composer.json b/composer.json index 03f5006e..eba45406 100644 --- a/composer.json +++ b/composer.json @@ -3,18 +3,17 @@ "description": "WordPress plugin for automated data collection, AI processing, and multi-platform publishing", "version": "0.10.2", "license": "GPL-2.0-or-later", - "require": { - "php": ">=8.2", - "monolog/monolog": "^3.9", - "woocommerce/action-scheduler": "^3.9", - "chubes4/ai-http-client": "^2.0.13" - }, + "require": { + "php": ">=8.2", + "monolog/monolog": "^3.9", + "woocommerce/action-scheduler": "^3.9", + "chubes4/ai-http-client": "^2.0.13" + }, "require-dev": { "php-stubs/wordpress-stubs": "^6.9", "wp-coding-standards/wpcs": "^3.1", "phpcsstandards/phpcsutils": "^1.0", - "phpcompatibility/phpcompatibility-wp": "^2.1", - "wp-cli/wp-cli": "^2.12" + "phpcompatibility/phpcompatibility-wp": "^2.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 0895070f..48a9a7b0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "acb6cf491129e50ca30106c457029ff7", + "content-hash": "415ed07056e1cf07db4c3d0e3d5e2827", "packages": [ { "name": "chubes4/ai-http-client", @@ -879,309 +879,6 @@ ], "time": "2025-11-04T16:30:35+00:00" }, - { - "name": "symfony/finder", - "version": "v7.4.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-29T09:40:50+00:00" - }, - { - "name": "wp-cli/mustache", - "version": "v2.14.99", - "source": { - "type": "git", - "url": "https://github.com/wp-cli/mustache.php.git", - "reference": "ca23b97ac35fbe01c160549eb634396183d04a59" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/wp-cli/mustache.php/zipball/ca23b97ac35fbe01c160549eb634396183d04a59", - "reference": "ca23b97ac35fbe01c160549eb634396183d04a59", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "replace": { - "mustache/mustache": "^2.14.2" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.19.3", - "yoast/phpunit-polyfills": "^2.0" - }, - "type": "library", - "autoload": { - "psr-0": { - "Mustache": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" - } - ], - "description": "A Mustache implementation in PHP.", - "homepage": "https://github.com/bobthecow/mustache.php", - "keywords": [ - "mustache", - "templating" - ], - "support": { - "source": "https://github.com/wp-cli/mustache.php/tree/v2.14.99" - }, - "time": "2025-05-06T16:15:37+00:00" - }, - { - "name": "wp-cli/mustangostang-spyc", - "version": "0.6.3", - "source": { - "type": "git", - "url": "https://github.com/wp-cli/spyc.git", - "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/wp-cli/spyc/zipball/6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", - "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", - "shasum": "" - }, - "require": { - "php": ">=5.3.1" - }, - "require-dev": { - "phpunit/phpunit": "4.3.*@dev" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.5.x-dev" - } - }, - "autoload": { - "files": [ - "includes/functions.php" - ], - "psr-4": { - "Mustangostang\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "mustangostang", - "email": "vlad.andersen@gmail.com" - } - ], - "description": "A simple YAML loader/dumper class for PHP (WP-CLI fork)", - "homepage": "https://github.com/mustangostang/spyc/", - "support": { - "source": "https://github.com/wp-cli/spyc/tree/autoload" - }, - "time": "2017-04-25T11:26:20+00:00" - }, - { - "name": "wp-cli/php-cli-tools", - "version": "v0.12.7", - "source": { - "type": "git", - "url": "https://github.com/wp-cli/php-cli-tools.git", - "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", - "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", - "shasum": "" - }, - "require": { - "php": ">= 7.2.24" - }, - "require-dev": { - "roave/security-advisories": "dev-latest", - "wp-cli/wp-cli-tests": "^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "0.12.x-dev" - } - }, - "autoload": { - "files": [ - "lib/cli/cli.php" - ], - "psr-0": { - "cli": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Bachhuber", - "email": "daniel@handbuilt.co", - "role": "Maintainer" - }, - { - "name": "James Logsdon", - "email": "jlogsdon@php.net", - "role": "Developer" - } - ], - "description": "Console utilities for PHP", - "homepage": "http://github.com/wp-cli/php-cli-tools", - "keywords": [ - "cli", - "console" - ], - "support": { - "issues": "https://github.com/wp-cli/php-cli-tools/issues", - "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.7" - }, - "time": "2026-01-20T20:31:49+00:00" - }, - { - "name": "wp-cli/wp-cli", - "version": "v2.12.0", - "source": { - "type": "git", - "url": "https://github.com/wp-cli/wp-cli.git", - "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/03d30d4138d12b4bffd8b507b82e56e129e0523f", - "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "php": "^5.6 || ^7.0 || ^8.0", - "symfony/finder": ">2.7", - "wp-cli/mustache": "^2.14.99", - "wp-cli/mustangostang-spyc": "^0.6.3", - "wp-cli/php-cli-tools": "~0.12.4" - }, - "require-dev": { - "wp-cli/db-command": "^1.3 || ^2", - "wp-cli/entity-command": "^1.2 || ^2", - "wp-cli/extension-command": "^1.1 || ^2", - "wp-cli/package-command": "^1 || ^2", - "wp-cli/wp-cli-tests": "^4.3.10" - }, - "suggest": { - "ext-readline": "Include for a better --prompt implementation", - "ext-zip": "Needed to support extraction of ZIP archives when doing downloads or updates" - }, - "bin": [ - "bin/wp", - "bin/wp.bat" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.12.x-dev" - } - }, - "autoload": { - "psr-0": { - "WP_CLI\\": "php/" - }, - "classmap": [ - "php/class-wp-cli.php", - "php/class-wp-cli-command.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "WP-CLI framework", - "homepage": "https://wp-cli.org", - "keywords": [ - "cli", - "wordpress" - ], - "support": { - "docs": "https://make.wordpress.org/cli/handbook/", - "issues": "https://github.com/wp-cli/wp-cli/issues", - "source": "https://github.com/wp-cli/wp-cli" - }, - "time": "2025-05-07T01:16:12+00:00" - }, { "name": "wp-coding-standards/wpcs", "version": "3.3.0", From e9d6ab23926eadd19fa83d3426b75e1793a49597 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 3 Mar 2026 23:37:05 +0000 Subject: [PATCH 7/8] feat: add --user flag to CLI commands and user_id to abilities for multi-agent scoping (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of multi-agent support: CLI and ability-level user scoping. Abilities layer: - AgentMemoryAbilities: add user_id to all 4 input_schema definitions and extract from input in execute methods - FileAbilities: add user_id to input_schema for list, get, write, delete, and upload abilities; pass through to agent-scoped private methods (listAgentFiles, getAgentFile, writeAgentFile, deleteAgentFile, uploadToAgent) - GetPipelinesAbility: add user_id input param, pass to get_all_pipelines() DB query - GetFlowsAbility: add user_id input param, pass through to getAllFlowsPaginated/countAllFlows helpers - GetJobsAbility: add user_id input param, pass to DB query args - FlowHelpers: update getAllFlowsPaginated/countAllFlows to accept optional user_id and use get_all_flows() when filtering CLI layer: - New UserResolver class: resolves --user flag (ID, login, or email) to WordPress user ID, returns 0 when omitted - MemoryCommand: wire --user through all subcommands (read, sections, write, search, daily, files) to ability calls and DirectoryManager - PipelinesCommand: resolve --user, pass to executeGetPipelines - FlowsCommand: resolve --user, pass to executeAbility - JobsCommand: resolve --user, pass to executeGetJobs - New AgentsCommand: wp datamachine agents list — shows shared agent and all WP users with pipelines, flows, or agent directories - Bootstrap: register agents command REST API: - Files.php: accept user_id query param on list, get, put, delete agent file endpoints; pass through to FileAbilities --- inc/Abilities/AgentMemoryAbilities.php | 32 +++- inc/Abilities/FileAbilities.php | 67 ++++++-- inc/Abilities/Flow/FlowHelpers.php | 14 +- inc/Abilities/Flow/GetFlowsAbility.php | 10 +- inc/Abilities/Job/GetJobsAbility.php | 10 ++ .../Pipeline/GetPipelinesAbility.php | 8 +- inc/Api/Files.php | 54 ++++-- inc/Cli/Bootstrap.php | 1 + inc/Cli/Commands/AgentsCommand.php | 162 ++++++++++++++++++ inc/Cli/Commands/Flows/FlowsCommand.php | 3 + inc/Cli/Commands/JobsCommand.php | 7 + inc/Cli/Commands/MemoryCommand.php | 58 ++++--- inc/Cli/Commands/PipelinesCommand.php | 4 + inc/Cli/UserResolver.php | 59 +++++++ 14 files changed, 425 insertions(+), 64 deletions(-) create mode 100644 inc/Cli/Commands/AgentsCommand.php create mode 100644 inc/Cli/UserResolver.php diff --git a/inc/Abilities/AgentMemoryAbilities.php b/inc/Abilities/AgentMemoryAbilities.php index 9bf1e159..152cc895 100644 --- a/inc/Abilities/AgentMemoryAbilities.php +++ b/inc/Abilities/AgentMemoryAbilities.php @@ -44,6 +44,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', + 'default' => 0, + ), 'section' => array( 'type' => 'string', 'description' => 'Section name to read (without ##). If omitted, returns the full file.', @@ -78,6 +83,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', + 'default' => 0, + ), 'section' => array( 'type' => 'string', 'description' => 'Section name (without ##). Created if it does not exist.', @@ -117,6 +127,11 @@ private function registerAbilities(): void { 'type' => 'object', 'required' => array( 'query' ), 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', + 'default' => 0, + ), 'query' => array( 'type' => 'string', 'description' => 'Search term (case-insensitive substring match).', @@ -162,6 +177,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array(), + 'user_id' => array( + 'type' => 'integer', + 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', + 'default' => 0, + ), ), 'output_schema' => array( 'type' => 'object', @@ -195,7 +215,8 @@ private function registerAbilities(): void { * @return array Result. */ public static function getMemory( array $input ): array { - $memory = new AgentMemory(); + $user_id = (int) ( $input['user_id'] ?? 0 ); + $memory = new AgentMemory( $user_id ); $section = $input['section'] ?? null; if ( null === $section || '' === $section ) { @@ -212,7 +233,8 @@ public static function getMemory( array $input ): array { * @return array Result. */ public static function updateMemory( array $input ): array { - $memory = new AgentMemory(); + $user_id = (int) ( $input['user_id'] ?? 0 ); + $memory = new AgentMemory( $user_id ); $section = $input['section']; $content = $input['content']; $mode = $input['mode']; @@ -231,7 +253,8 @@ public static function updateMemory( array $input ): array { * @return array Search results. */ public static function searchMemory( array $input ): array { - $memory = new AgentMemory(); + $user_id = (int) ( $input['user_id'] ?? 0 ); + $memory = new AgentMemory( $user_id ); $query = $input['query']; $section = $input['section'] ?? null; @@ -245,7 +268,8 @@ public static function searchMemory( array $input ): array { * @return array Result. */ public static function listSections( array $input ): array { - $memory = new AgentMemory(); + $user_id = (int) ( $input['user_id'] ?? 0 ); + $memory = new AgentMemory( $user_id ); return $memory->get_sections(); } } diff --git a/inc/Abilities/FileAbilities.php b/inc/Abilities/FileAbilities.php index 969afd71..668a01ba 100644 --- a/inc/Abilities/FileAbilities.php +++ b/inc/Abilities/FileAbilities.php @@ -90,6 +90,11 @@ private function registerListFiles(): void { 'type' => array( 'string', 'null' ), 'description' => __( 'Flow step ID for flow-level files (e.g., "1-2" for pipeline 1, flow 2)', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'data-machine' ), + 'default' => 0, + ), 'scope' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Scope for file operations. Use "agent" for agent directory files.', 'data-machine' ), @@ -134,6 +139,11 @@ private function registerGetFile(): void { 'type' => array( 'string', 'null' ), 'description' => __( 'Flow step ID for flow-level files', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'data-machine' ), + 'default' => 0, + ), 'scope' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Scope for file operations. Use "agent" for agent directory files.', 'data-machine' ), @@ -178,6 +188,11 @@ private function registerWriteAgentFile(): void { 'type' => 'string', 'description' => __( 'Content to write to the file', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'data-machine' ), + 'default' => 0, + ), ), ), 'output_schema' => array( @@ -217,6 +232,11 @@ private function registerDeleteFile(): void { 'type' => array( 'string', 'null' ), 'description' => __( 'Flow step ID for flow-level files', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'data-machine' ), + 'default' => 0, + ), 'scope' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Scope for file operations. Use "agent" for agent directory files.', 'data-machine' ), @@ -307,6 +327,11 @@ private function registerUploadFile(): void { 'type' => array( 'string', 'null' ), 'description' => __( 'Flow step ID for flow-level files', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'data-machine' ), + 'default' => 0, + ), 'scope' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Scope for file operations. Use "agent" for agent directory files.', 'data-machine' ), @@ -347,9 +372,10 @@ public function checkPermission(): bool { public function executeListFiles( array $input ): array { $flow_step_id = $input['flow_step_id'] ?? null; $scope = $input['scope'] ?? null; + $user_id = (int) ( $input['user_id'] ?? 0 ); if ( 'agent' === $scope ) { - return $this->listAgentFiles(); + return $this->listAgentFiles( $user_id ); } if ( ! $flow_step_id ) { @@ -372,6 +398,7 @@ public function executeGetFile( array $input ): array { $filename = $input['filename'] ?? null; $flow_step_id = $input['flow_step_id'] ?? null; $scope = $input['scope'] ?? null; + $user_id = (int) ( $input['user_id'] ?? 0 ); if ( empty( $filename ) ) { return array( @@ -383,7 +410,7 @@ public function executeGetFile( array $input ): array { $filename = sanitize_file_name( $filename ); if ( 'agent' === $scope ) { - return $this->getAgentFile( $filename ); + return $this->getAgentFile( $filename, $user_id ); } if ( ! $flow_step_id ) { @@ -405,6 +432,7 @@ public function executeGetFile( array $input ): array { public function executeWriteAgentFile( array $input ): array { $filename = $input['filename'] ?? null; $content = $input['content'] ?? null; + $user_id = (int) ( $input['user_id'] ?? 0 ); if ( empty( $filename ) ) { return array( @@ -422,7 +450,7 @@ public function executeWriteAgentFile( array $input ): array { $filename = sanitize_file_name( $filename ); - return $this->writeAgentFile( $filename, $content ); + return $this->writeAgentFile( $filename, $content, $user_id ); } /** @@ -435,6 +463,7 @@ public function executeDeleteFile( array $input ): array { $filename = $input['filename'] ?? null; $flow_step_id = $input['flow_step_id'] ?? null; $scope = $input['scope'] ?? null; + $user_id = (int) ( $input['user_id'] ?? 0 ); if ( empty( $filename ) ) { return array( @@ -446,7 +475,7 @@ public function executeDeleteFile( array $input ): array { $filename = sanitize_file_name( $filename ); if ( 'agent' === $scope ) { - return $this->deleteAgentFile( $filename ); + return $this->deleteAgentFile( $filename, $user_id ); } if ( ! $flow_step_id ) { @@ -557,6 +586,7 @@ public function executeUploadFile( array $input ): array { $file_data = $input['file_data'] ?? array(); $flow_step_id = $input['flow_step_id'] ?? null; $scope = $input['scope'] ?? null; + $user_id = (int) ( $input['user_id'] ?? 0 ); if ( 'agent' !== $scope && ! $flow_step_id ) { return array( @@ -615,7 +645,7 @@ public function executeUploadFile( array $input ): array { } if ( 'agent' === $scope ) { - return $this->uploadToAgent( $file ); + return $this->uploadToAgent( $file, $user_id ); } return $this->uploadToFlow( $file, $flow_step_id ); @@ -905,12 +935,12 @@ private function deleteFileFromFlow( string $filename, string $flow_step_id ): a * * @return array Result with files. */ - private function listAgentFiles(): array { + private function listAgentFiles( int $user_id = 0 ): array { // Self-heal: ensure agent files exist before listing. DirectoryManager::ensure_agent_files(); $directory_manager = new DirectoryManager(); - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); if ( ! file_exists( $agent_dir ) ) { return array( @@ -943,7 +973,7 @@ private function listAgentFiles(): array { } // Include daily memory summary if the directory exists. - $daily = new DailyMemory(); + $daily = new DailyMemory( $user_id ); $daily_result = $daily->list_all(); if ( ! empty( $daily_result['months'] ) ) { @@ -974,12 +1004,12 @@ private function listAgentFiles(): array { * @param string $filename Filename to retrieve. * @return array Result with file data. */ - private function getAgentFile( string $filename ): array { + private function getAgentFile( string $filename, int $user_id = 0 ): array { // Self-heal: ensure agent files exist before retrieval. DirectoryManager::ensure_agent_files(); $directory_manager = new DirectoryManager(); - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); $filepath = "{$agent_dir}/{$filename}"; if ( ! file_exists( $filepath ) ) { @@ -1009,7 +1039,7 @@ private function getAgentFile( string $filename ): array { * @param string $filename Filename to delete. * @return array Result with deletion status. */ - private function deleteAgentFile( string $filename ): array { + private function deleteAgentFile( string $filename, int $user_id = 0 ): array { if ( in_array( $filename, self::PROTECTED_FILES, true ) ) { return array( 'success' => false, @@ -1018,7 +1048,7 @@ private function deleteAgentFile( string $filename ): array { } $directory_manager = new DirectoryManager(); - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); $filepath = "{$agent_dir}/{$filename}"; if ( ! file_exists( $filepath ) ) { @@ -1034,7 +1064,10 @@ private function deleteAgentFile( string $filename ): array { 'datamachine_log', 'info', 'Agent file deleted via ability', - array( 'filename' => $filename ) + array( + 'filename' => $filename, + 'user_id' => $user_id, + ) ); return array( @@ -1053,7 +1086,7 @@ private function deleteAgentFile( string $filename ): array { * @param string $content Content to write. * @return array Result with write status. */ - private function writeAgentFile( string $filename, string $content ): array { + private function writeAgentFile( string $filename, string $content, int $user_id = 0 ): array { if ( in_array( $filename, self::PROTECTED_FILES, true ) && '' === trim( $content ) ) { return array( 'success' => false, @@ -1062,7 +1095,7 @@ private function writeAgentFile( string $filename, string $content ): array { } $directory_manager = new DirectoryManager(); - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); if ( ! $directory_manager->ensure_directory_exists( $agent_dir ) ) { return array( @@ -1111,9 +1144,9 @@ private function writeAgentFile( string $filename, string $content ): array { * @param array $file File data. * @return array Result with files list. */ - private function uploadToAgent( array $file ): array { + private function uploadToAgent( array $file, int $user_id = 0 ): array { $directory_manager = new DirectoryManager(); - $agent_dir = $directory_manager->get_agent_directory(); + $agent_dir = $directory_manager->get_agent_directory( $user_id ); if ( ! $directory_manager->ensure_directory_exists( $agent_dir ) ) { return array( diff --git a/inc/Abilities/Flow/FlowHelpers.php b/inc/Abilities/Flow/FlowHelpers.php index 1d02c71a..ea2faaff 100644 --- a/inc/Abilities/Flow/FlowHelpers.php +++ b/inc/Abilities/Flow/FlowHelpers.php @@ -196,7 +196,12 @@ function ( $flow ) { * @param int $offset Pagination offset. * @return array Paginated flows. */ - protected function getAllFlowsPaginated( int $per_page, int $offset ): array { + protected function getAllFlowsPaginated( int $per_page, int $offset, ?int $user_id = null ): array { + if ( null !== $user_id ) { + $all_flows = $this->db_flows->get_all_flows( $user_id ); + return array_slice( $all_flows, $offset, $per_page ); + } + $all_pipelines = $this->db_pipelines->get_pipelines_list(); $all_flows = array(); @@ -213,7 +218,12 @@ protected function getAllFlowsPaginated( int $per_page, int $offset ): array { * * @return int Total flow count. */ - protected function countAllFlows(): int { + protected function countAllFlows( ?int $user_id = null ): int { + if ( null !== $user_id ) { + $all_flows = $this->db_flows->get_all_flows( $user_id ); + return count( $all_flows ); + } + $all_pipelines = $this->db_pipelines->get_pipelines_list(); $total = 0; diff --git a/inc/Abilities/Flow/GetFlowsAbility.php b/inc/Abilities/Flow/GetFlowsAbility.php index b1b7a97d..996bae23 100644 --- a/inc/Abilities/Flow/GetFlowsAbility.php +++ b/inc/Abilities/Flow/GetFlowsAbility.php @@ -43,6 +43,10 @@ private function registerAbility(): void { 'type' => array( 'integer', 'null' ), 'description' => __( 'Get a specific flow by ID (ignores pagination when provided)', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'Filter flows by WordPress user ID. Defaults to showing all.', 'data-machine' ), + ), 'pipeline_id' => array( 'type' => array( 'integer', 'null' ), 'description' => __( 'Filter flows by pipeline ID', 'data-machine' ), @@ -107,6 +111,7 @@ public function execute( array $input ): array { try { $flow_id = $input['flow_id'] ?? null; $pipeline_id = $input['pipeline_id'] ?? null; + $user_id = isset( $input['user_id'] ) ? (int) $input['user_id'] : null; $handler_slug = $input['handler_slug'] ?? null; $per_page = (int) ( $input['per_page'] ?? self::DEFAULT_PER_PAGE ); $offset = (int) ( $input['offset'] ?? 0 ); @@ -145,6 +150,7 @@ public function execute( array $input ): array { $filters_applied = array( 'pipeline_id' => $pipeline_id, + 'user_id' => $user_id, 'handler_slug' => $handler_slug, ); @@ -155,8 +161,8 @@ public function execute( array $input ): array { $flows = $this->db_flows->get_flows_for_pipeline_paginated( $pipeline_id, $per_page, $offset ); $total = $this->db_flows->count_flows_for_pipeline( $pipeline_id ); } else { - $flows = $this->getAllFlowsPaginated( $per_page, $offset ); - $total = $this->countAllFlows(); + $flows = $this->getAllFlowsPaginated( $per_page, $offset, $user_id ); + $total = $this->countAllFlows( $user_id ); } if ( $handler_slug ) { diff --git a/inc/Abilities/Job/GetJobsAbility.php b/inc/Abilities/Job/GetJobsAbility.php index 6f64ad74..c8d4c342 100644 --- a/inc/Abilities/Job/GetJobsAbility.php +++ b/inc/Abilities/Job/GetJobsAbility.php @@ -43,6 +43,10 @@ private function registerAbility(): void { 'type' => array( 'integer', 'null' ), 'description' => __( 'Get a specific job by ID (ignores pagination/filters when provided)', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'Filter jobs by WordPress user ID.', 'data-machine' ), + ), 'flow_id' => array( 'type' => array( 'integer', 'string', 'null' ), 'description' => __( 'Filter jobs by flow ID (integer or "direct")', 'data-machine' ), @@ -125,6 +129,7 @@ public function execute( array $input ): array { $job_id = $input['job_id'] ?? null; $flow_id = $input['flow_id'] ?? null; $pipeline_id = $input['pipeline_id'] ?? null; + $user_id = $input['user_id'] ?? null; $status = $input['status'] ?? null; $source = $input['source'] ?? null; $since = $input['since'] ?? null; @@ -197,6 +202,11 @@ public function execute( array $input ): array { $filters_applied['source'] = $args['source']; } + if ( null !== $user_id ) { + $args['user_id'] = (int) $user_id; + $filters_applied['user_id'] = $args['user_id']; + } + if ( null !== $since && '' !== $since ) { $args['since'] = sanitize_text_field( $since ); $filters_applied['since'] = $args['since']; diff --git a/inc/Abilities/Pipeline/GetPipelinesAbility.php b/inc/Abilities/Pipeline/GetPipelinesAbility.php index f517da1e..fdf442e6 100644 --- a/inc/Abilities/Pipeline/GetPipelinesAbility.php +++ b/inc/Abilities/Pipeline/GetPipelinesAbility.php @@ -43,6 +43,11 @@ private function registerAbility(): void { 'type' => array( 'integer', 'null' ), 'description' => __( 'Get a specific pipeline by ID (ignores pagination when provided)', 'data-machine' ), ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'Filter pipelines by WordPress user ID. Defaults to 0 (shared/legacy).', 'data-machine' ), + 'default' => 0, + ), 'per_page' => array( 'type' => 'integer', 'default' => self::DEFAULT_PER_PAGE, @@ -99,6 +104,7 @@ private function registerAbility(): void { public function execute( array $input ): array { try { $pipeline_id = $input['pipeline_id'] ?? null; + $user_id = isset( $input['user_id'] ) ? (int) $input['user_id'] : null; $per_page = (int) ( $input['per_page'] ?? self::DEFAULT_PER_PAGE ); $offset = (int) ( $input['offset'] ?? 0 ); $output_mode = $input['output_mode'] ?? 'full'; @@ -141,7 +147,7 @@ public function execute( array $input ): array { ); } - $all_pipelines = $this->db_pipelines->get_all_pipelines(); + $all_pipelines = $this->db_pipelines->get_all_pipelines( $user_id ); $total = count( $all_pipelines ); $pipelines = array_slice( $all_pipelines, $offset, $per_page ); diff --git a/inc/Api/Files.php b/inc/Api/Files.php index 3ec0b0f8..f6c97d01 100644 --- a/inc/Api/Files.php +++ b/inc/Api/Files.php @@ -408,7 +408,14 @@ public static function delete_file( WP_REST_Request $request ) { * List agent files. */ public static function list_agent_files( WP_REST_Request $request ) { - $result = self::getAbilities()->executeListFiles( array( 'scope' => 'agent' ) ); + $user_id = $request->get_param( 'user_id' ); + $input = array( 'scope' => 'agent' ); + + if ( null !== $user_id ) { + $input['user_id'] = (int) $user_id; + } + + $result = self::getAbilities()->executeListFiles( $input ); if ( ! $result['success'] ) { return new WP_Error( 'list_agent_files_error', $result['error'], array( 'status' => 500 ) ); @@ -427,14 +434,19 @@ public static function list_agent_files( WP_REST_Request $request ) { */ public static function get_agent_file( WP_REST_Request $request ) { $filename = sanitize_file_name( wp_unslash( $request['filename'] ) ); + $user_id = $request->get_param( 'user_id' ); - $result = self::getAbilities()->executeGetFile( - array( - 'filename' => $filename, - 'scope' => 'agent', - ) + $input = array( + 'filename' => $filename, + 'scope' => 'agent', ); + if ( null !== $user_id ) { + $input['user_id'] = (int) $user_id; + } + + $result = self::getAbilities()->executeGetFile( $input ); + if ( ! $result['success'] ) { $status = false !== strpos( $result['error'] ?? '', 'not found' ) ? 404 : 400; return new WP_Error( 'get_agent_file_error', $result['error'], array( 'status' => $status ) ); @@ -455,14 +467,19 @@ public static function get_agent_file( WP_REST_Request $request ) { public static function put_agent_file( WP_REST_Request $request ) { $filename = sanitize_file_name( wp_unslash( $request['filename'] ) ); $content = $request->get_body(); + $user_id = $request->get_param( 'user_id' ); - $result = self::getAbilities()->executeWriteAgentFile( - array( - 'filename' => $filename, - 'content' => $content, - ) + $input = array( + 'filename' => $filename, + 'content' => $content, ); + if ( null !== $user_id ) { + $input['user_id'] = (int) $user_id; + } + + $result = self::getAbilities()->executeWriteAgentFile( $input ); + if ( ! $result['success'] ) { $status = 400; if ( false !== strpos( $result['error'] ?? '', 'Filesystem' ) || false !== strpos( $result['error'] ?? '', 'Failed' ) ) { @@ -496,13 +513,18 @@ public static function delete_agent_file( WP_REST_Request $request ) { ); } - $result = self::getAbilities()->executeDeleteFile( - array( - 'filename' => $filename, - 'scope' => 'agent', - ) + $input = array( + 'filename' => $filename, + 'scope' => 'agent', ); + $user_id = $request->get_param( 'user_id' ); + if ( null !== $user_id ) { + $input['user_id'] = (int) $user_id; + } + + $result = self::getAbilities()->executeDeleteFile( $input ); + if ( ! $result['success'] ) { $status = false !== strpos( $result['error'] ?? '', 'not found' ) ? 404 : 400; return new WP_Error( 'delete_agent_file_error', $result['error'], array( 'status' => $status ) ); diff --git a/inc/Cli/Bootstrap.php b/inc/Cli/Bootstrap.php index 2b2dbb5e..a7ad6bfe 100644 --- a/inc/Cli/Bootstrap.php +++ b/inc/Cli/Bootstrap.php @@ -25,6 +25,7 @@ WP_CLI::add_command( 'datamachine posts', Commands\PostsCommand::class ); WP_CLI::add_command( 'datamachine logs', Commands\LogsCommand::class ); WP_CLI::add_command( 'datamachine agent', Commands\MemoryCommand::class ); +WP_CLI::add_command( 'datamachine agents', Commands\AgentsCommand::class ); // Backwards-compatible alias: `wp datamachine memory` → agent. WP_CLI::add_command( 'datamachine memory', Commands\MemoryCommand::class ); diff --git a/inc/Cli/Commands/AgentsCommand.php b/inc/Cli/Commands/AgentsCommand.php new file mode 100644 index 00000000..8823acce --- /dev/null +++ b/inc/Cli/Commands/AgentsCommand.php @@ -0,0 +1,162 @@ +] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * # List all configured agents + * wp datamachine agents list + * + * # JSON output + * wp datamachine agents list --format=json + * + * @subcommand list + */ + /** + * Execute agent listing. + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function list_agents( array $args, array $assoc_args ): void { + global $wpdb; + + $items = array(); + + // Always include the shared agent (user_id=0). + $directory_manager = new DirectoryManager(); + $shared_dir = $directory_manager->get_agent_directory( 0 ); + $shared_exists = is_dir( $shared_dir ); + + // Count pipelines/flows for user_id=0. + $pipelines_table = $wpdb->prefix . 'datamachine_pipelines'; + $flows_table = $wpdb->prefix . 'datamachine_flows'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $shared_pipelines = (int) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', $pipelines_table, 0 ) + ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $shared_flows = (int) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', $flows_table, 0 ) + ); + + $items[] = array( + 'user_id' => 0, + 'login' => '(shared)', + 'email' => '-', + 'pipelines' => $shared_pipelines, + 'flows' => $shared_flows, + 'has_files' => $shared_exists ? 'Yes' : 'No', + ); + + // Find all distinct user_ids from pipelines and flows tables. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids_from_pipelines = $wpdb->get_col( + $wpdb->prepare( 'SELECT DISTINCT user_id FROM %i WHERE user_id > %d', $pipelines_table, 0 ) + ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids_from_flows = $wpdb->get_col( + $wpdb->prepare( 'SELECT DISTINCT user_id FROM %i WHERE user_id > %d', $flows_table, 0 ) + ); + + $all_user_ids = array_unique( + array_merge( + array_map( 'intval', $user_ids_from_pipelines ), + array_map( 'intval', $user_ids_from_flows ) + ) + ); + + // Also check for user-scoped agent directories. + $upload_dir = wp_upload_dir(); + $agent_base = trailingslashit( $upload_dir['basedir'] ) . 'datamachine-files'; + $user_dirs = glob( $agent_base . '/agent-*', GLOB_ONLYDIR ); + + if ( $user_dirs ) { + foreach ( $user_dirs as $dir ) { + $dirname = basename( $dir ); + if ( preg_match( '/^agent-(\d+)$/', $dirname, $matches ) ) { + $uid = (int) $matches[1]; + if ( $uid > 0 && ! in_array( $uid, $all_user_ids, true ) ) { + $all_user_ids[] = $uid; + } + } + } + } + + sort( $all_user_ids ); + + foreach ( $all_user_ids as $uid ) { + $user = get_user_by( 'id', $uid ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $user_pipelines = (int) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', $pipelines_table, $uid ) + ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $user_flows = (int) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', $flows_table, $uid ) + ); + + $user_dir = $directory_manager->get_agent_directory( $uid ); + $dir_exists = is_dir( $user_dir ); + + $items[] = array( + 'user_id' => $uid, + 'login' => $user ? $user->user_login : '(deleted)', + 'email' => $user ? $user->user_email : '-', + 'pipelines' => $user_pipelines, + 'flows' => $user_flows, + 'has_files' => $dir_exists ? 'Yes' : 'No', + ); + } + + $fields = array( 'user_id', 'login', 'email', 'pipelines', 'flows', 'has_files' ); + $this->format_items( $items, $fields, $assoc_args ); + + WP_CLI::log( sprintf( 'Total: %d agent(s).', count( $items ) ) ); + } +} diff --git a/inc/Cli/Commands/Flows/FlowsCommand.php b/inc/Cli/Commands/Flows/FlowsCommand.php index 949cbdfb..c57b2c11 100644 --- a/inc/Cli/Commands/Flows/FlowsCommand.php +++ b/inc/Cli/Commands/Flows/FlowsCommand.php @@ -18,6 +18,7 @@ use WP_CLI; use DataMachine\Cli\BaseCommand; +use DataMachine\Cli\UserResolver; defined( 'ABSPATH' ) || exit; @@ -290,11 +291,13 @@ public function __invoke( array $args, array $assoc_args ): void { $offset = 0; } + $user_id = UserResolver::resolve( $assoc_args ); $ability = new \DataMachine\Abilities\FlowAbilities(); $result = $ability->executeAbility( array( 'flow_id' => $flow_id, 'pipeline_id' => $pipeline_id, + 'user_id' => $user_id > 0 ? $user_id : null, 'handler_slug' => $handler_slug, 'per_page' => $per_page, 'offset' => $offset, diff --git a/inc/Cli/Commands/JobsCommand.php b/inc/Cli/Commands/JobsCommand.php index 993cb029..9ef2aa05 100644 --- a/inc/Cli/Commands/JobsCommand.php +++ b/inc/Cli/Commands/JobsCommand.php @@ -13,6 +13,7 @@ use WP_CLI; use DataMachine\Cli\BaseCommand; +use DataMachine\Cli\UserResolver; use DataMachine\Abilities\JobAbilities; use DataMachine\Core\Database\Jobs\Jobs; use DataMachine\Engine\AI\System\SystemAgent; @@ -220,6 +221,8 @@ public function list_jobs( array $args, array $assoc_args ): void { $limit = 500; } + $user_id = UserResolver::resolve( $assoc_args ); + $input = array( 'per_page' => $limit, 'offset' => 0, @@ -235,6 +238,10 @@ public function list_jobs( array $args, array $assoc_args ): void { $input['flow_id'] = $flow_id; } + if ( $user_id > 0 ) { + $input['user_id'] = $user_id; + } + $since = $assoc_args['since'] ?? null; if ( $since ) { $timestamp = strtotime( $since ); diff --git a/inc/Cli/Commands/MemoryCommand.php b/inc/Cli/Commands/MemoryCommand.php index 26cf8dd2..0fbb0715 100644 --- a/inc/Cli/Commands/MemoryCommand.php +++ b/inc/Cli/Commands/MemoryCommand.php @@ -23,6 +23,7 @@ use DataMachine\Core\FilesRepository\DailyMemory; use DataMachine\Core\FilesRepository\DirectoryManager; use DataMachine\Core\FilesRepository\FilesystemHelper; +use DataMachine\Cli\UserResolver; defined( 'ABSPATH' ) || exit; @@ -54,12 +55,16 @@ class MemoryCommand extends BaseCommand { * # Read lessons learned * wp datamachine agent read "Lessons Learned" * + * # Read memory for a specific user/agent + * wp datamachine agent read --user=2 + * * @subcommand read */ public function read( array $args, array $assoc_args ): void { $section = $args[0] ?? null; + $user_id = UserResolver::resolve( $assoc_args ); - $input = array(); + $input = array( 'user_id' => $user_id ); if ( null !== $section ) { $input['section'] = $section; } @@ -105,7 +110,8 @@ public function read( array $args, array $assoc_args ): void { * @subcommand sections */ public function sections( array $args, array $assoc_args ): void { - $result = AgentMemoryAbilities::listSections( array() ); + $user_id = UserResolver::resolve( $assoc_args ); + $result = AgentMemoryAbilities::listSections( array( 'user_id' => $user_id ) ); if ( ! $result['success'] ) { WP_CLI::error( $result['message'] ?? 'Failed to list sections.' ); @@ -177,8 +183,11 @@ public function write( array $args, array $assoc_args ): void { return; } + $user_id = UserResolver::resolve( $assoc_args ); + $result = AgentMemoryAbilities::updateMemory( array( + 'user_id' => $user_id, 'section' => $section, 'content' => $content, 'mode' => $mode, @@ -223,8 +232,11 @@ public function search( array $args, array $assoc_args ): void { $query = $args[0]; $section = $assoc_args['section'] ?? null; + $user_id = UserResolver::resolve( $assoc_args ); + $result = AgentMemoryAbilities::searchMemory( array( + 'user_id' => $user_id, 'query' => $query, 'section' => $section, ) @@ -297,8 +309,9 @@ public function daily( array $args, array $assoc_args ): void { return; } - $action = $args[0]; - $daily = new DailyMemory(); + $action = $args[0]; + $user_id = UserResolver::resolve( $assoc_args ); + $daily = new DailyMemory( $user_id ); switch ( $action ) { case 'list': @@ -562,11 +575,12 @@ public function files( array $args, array $assoc_args ): void { return; } - $action = $args[0]; + $action = $args[0]; + $user_id = UserResolver::resolve( $assoc_args ); switch ( $action ) { case 'list': - $this->files_list( $assoc_args ); + $this->files_list( $assoc_args, $user_id ); break; case 'read': $filename = $args[1] ?? null; @@ -574,7 +588,7 @@ public function files( array $args, array $assoc_args ): void { WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files read ' ); return; } - $this->files_read( $filename ); + $this->files_read( $filename, $user_id ); break; case 'write': $filename = $args[1] ?? null; @@ -582,10 +596,10 @@ public function files( array $args, array $assoc_args ): void { WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files write ' ); return; } - $this->files_write( $filename ); + $this->files_write( $filename, $user_id ); break; case 'check': - $this->files_check( $assoc_args ); + $this->files_check( $assoc_args, $user_id ); break; default: WP_CLI::error( "Unknown files action: {$action}. Use: list, read, write, check" ); @@ -597,8 +611,8 @@ public function files( array $args, array $assoc_args ): void { * * @param array $assoc_args Command arguments. */ - private function files_list( array $assoc_args ): void { - $agent_dir = $this->get_agent_dir(); + private function files_list( array $assoc_args, int $user_id = 0 ): void { + $agent_dir = $this->get_agent_dir( $user_id ); if ( ! is_dir( $agent_dir ) ) { WP_CLI::error( 'Agent directory does not exist.' ); @@ -635,12 +649,12 @@ private function files_list( array $assoc_args ): void { * * @param string $filename File name (e.g., SOUL.md). */ - private function files_read( string $filename ): void { - $agent_dir = $this->get_agent_dir(); + private function files_read( string $filename, int $user_id = 0 ): void { + $agent_dir = $this->get_agent_dir( $user_id ); $filepath = $agent_dir . '/' . $this->sanitize_agent_filename( $filename ); if ( ! file_exists( $filepath ) ) { - $available = $this->list_agent_filenames(); + $available = $this->list_agent_filenames( $user_id ); WP_CLI::error( sprintf( 'File "%s" not found. Available files: %s', $filename, implode( ', ', $available ) ) ); return; } @@ -654,7 +668,7 @@ private function files_read( string $filename ): void { * * @param string $filename File name (e.g., SOUL.md). */ - private function files_write( string $filename ): void { + private function files_write( string $filename, int $user_id = 0 ): void { $safe_name = $this->sanitize_agent_filename( $filename ); // Only allow .md files. @@ -663,7 +677,7 @@ private function files_write( string $filename ): void { return; } - $agent_dir = $this->get_agent_dir(); + $agent_dir = $this->get_agent_dir( $user_id ); $filepath = $agent_dir . '/' . $safe_name; // Read from stdin. @@ -695,8 +709,8 @@ private function files_write( string $filename ): void { * * @param array $assoc_args Command arguments. */ - private function files_check( array $assoc_args ): void { - $agent_dir = $this->get_agent_dir(); + private function files_check( array $assoc_args, int $user_id = 0 ): void { + $agent_dir = $this->get_agent_dir( $user_id ); $threshold_days = (int) ( $assoc_args['days'] ?? 7 ); if ( ! is_dir( $agent_dir ) ) { @@ -747,9 +761,9 @@ private function files_check( array $assoc_args ): void { * * @return string */ - private function get_agent_dir(): string { + private function get_agent_dir( int $user_id = 0 ): string { $directory_manager = new DirectoryManager(); - return $directory_manager->get_agent_directory(); + return $directory_manager->get_agent_directory( $user_id ); } /** @@ -767,8 +781,8 @@ private function sanitize_agent_filename( string $filename ): string { * * @return string[] */ - private function list_agent_filenames(): array { - $agent_dir = $this->get_agent_dir(); + private function list_agent_filenames( int $user_id = 0 ): array { + $agent_dir = $this->get_agent_dir( $user_id ); $files = glob( $agent_dir . '/*.md' ); return array_map( 'basename', $files ? $files : array() ); } diff --git a/inc/Cli/Commands/PipelinesCommand.php b/inc/Cli/Commands/PipelinesCommand.php index 0fd79a2f..d361438e 100644 --- a/inc/Cli/Commands/PipelinesCommand.php +++ b/inc/Cli/Commands/PipelinesCommand.php @@ -13,6 +13,7 @@ use WP_CLI; use DataMachine\Cli\BaseCommand; +use DataMachine\Cli\UserResolver; defined( 'ABSPATH' ) || exit; @@ -217,12 +218,14 @@ public function __invoke( array $args, array $assoc_args ): void { $offset = 0; } + $user_id = UserResolver::resolve( $assoc_args ); $ability = new \DataMachine\Abilities\PipelineAbilities(); if ( $pipeline_id ) { $result = $ability->executeGetPipelines( array( 'pipeline_id' => $pipeline_id, + 'user_id' => $user_id > 0 ? $user_id : null, 'output_mode' => 'full', ) ); @@ -246,6 +249,7 @@ public function __invoke( array $args, array $assoc_args ): void { array( 'per_page' => $per_page, 'offset' => $offset, + 'user_id' => $user_id > 0 ? $user_id : null, 'output_mode' => 'full', ) ); diff --git a/inc/Cli/UserResolver.php b/inc/Cli/UserResolver.php new file mode 100644 index 00000000..e7569a38 --- /dev/null +++ b/inc/Cli/UserResolver.php @@ -0,0 +1,59 @@ +ID; + } + + // Email. + if ( is_email( $user_value ) ) { + $user = get_user_by( 'email', $user_value ); + if ( ! $user ) { + WP_CLI::error( sprintf( 'User with email "%s" not found.', $user_value ) ); + } + return $user->ID; + } + + // Login. + $user = get_user_by( 'login', $user_value ); + if ( ! $user ) { + WP_CLI::error( sprintf( 'User with login "%s" not found.', $user_value ) ); + } + return $user->ID; + } +} From bbfb14b780207fa0b64306313c88efc09b1cbd15 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 3 Mar 2026 23:41:29 +0000 Subject: [PATCH 8/8] test: add multi-agent scoping tests for Phase 3 (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests: - UserResolverTest: validates resolve() contract — static, accepts array, returns int, returns 0 for empty/null/missing --user flag - AgentsCommandTest: validates class structure — extends BaseCommand, has list_agents method with correct signature Integration tests (WP_UnitTestCase): - MultiAgentScopingTest: 16 tests covering end-to-end scoping: - AgentMemoryAbilities: all 4 input_schemas include user_id, getMemory defaults to user_id=0, explicit zero matches default - FileAbilities: input_schemas include user_id, agent file listing is scoped by user_id, write creates user-scoped directory, delete is scoped (cross-agent delete fails) - GetPipelinesAbility: input_schema includes user_id, listing filtered correctly (agent A sees only their pipelines), no filter returns all - GetFlowsAbility: input_schema includes user_id - GetJobsAbility: input_schema includes user_id, listing filtered by user_id returns only matching jobs - DirectoryManager: directories isolated by user_id, shared uses legacy path, user-scoped uses agent-{id} path --- .../Unit/Abilities/MultiAgentScopingTest.php | 481 ++++++++++++++++++ tests/Unit/Cli/Commands/AgentsCommandTest.php | 65 +++ tests/Unit/Cli/UserResolverTest.php | 83 +++ 3 files changed, 629 insertions(+) create mode 100644 tests/Unit/Abilities/MultiAgentScopingTest.php create mode 100644 tests/Unit/Cli/Commands/AgentsCommandTest.php create mode 100644 tests/Unit/Cli/UserResolverTest.php diff --git a/tests/Unit/Abilities/MultiAgentScopingTest.php b/tests/Unit/Abilities/MultiAgentScopingTest.php new file mode 100644 index 00000000..d877c31b --- /dev/null +++ b/tests/Unit/Abilities/MultiAgentScopingTest.php @@ -0,0 +1,481 @@ +user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + // Create two agent users. + $this->agent_a_id = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'agent_alpha', + 'user_email' => 'alpha@test.local', + ) + ); + $this->agent_b_id = self::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'agent_beta', + 'user_email' => 'beta@test.local', + ) + ); + } + + /** + * Clean up agent directories after tests. + */ + public function tear_down(): void { + $dm = new DirectoryManager(); + + // Clean up agent-specific directories if created. + foreach ( array( $this->agent_a_id, $this->agent_b_id ) as $uid ) { + $dir = $dm->get_agent_directory( $uid ); + if ( is_dir( $dir ) ) { + array_map( 'unlink', glob( "{$dir}/*" ) ); + rmdir( $dir ); + } + } + + parent::tear_down(); + } + + // ========================================================================= + // AgentMemoryAbilities — user_id in input_schema + // ========================================================================= + + /** + * Test get-agent-memory input_schema includes user_id. + */ + public function test_get_memory_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/get-agent-memory' ); + $this->assertNotNull( $ability, 'get-agent-memory ability should be registered' ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + $this->assertSame( 'integer', $schema['properties']['user_id']['type'] ); + } + + /** + * Test update-agent-memory input_schema includes user_id. + */ + public function test_update_memory_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/update-agent-memory' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test search-agent-memory input_schema includes user_id. + */ + public function test_search_memory_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/search-agent-memory' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test list-agent-memory-sections input_schema includes user_id. + */ + public function test_list_sections_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/list-agent-memory-sections' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test getMemory defaults to user_id=0 when omitted. + */ + public function test_get_memory_defaults_to_shared_agent(): void { + $result = AgentMemoryAbilities::getMemory( array() ); + + // Should succeed (shared agent has MEMORY.md from bootstrap). + $this->assertIsArray( $result ); + // The result should come from the shared agent directory (user_id=0). + // Whether success=true depends on whether MEMORY.md exists; the point + // is it doesn't error on missing user_id. + } + + /** + * Test getMemory with explicit user_id=0 matches default behavior. + */ + public function test_get_memory_explicit_zero_matches_default(): void { + $default_result = AgentMemoryAbilities::getMemory( array() ); + $explicit_result = AgentMemoryAbilities::getMemory( array( 'user_id' => 0 ) ); + + $this->assertSame( $default_result['success'], $explicit_result['success'] ); + } + + // ========================================================================= + // FileAbilities — agent-scoped ops use user_id + // ========================================================================= + + /** + * Test list-files input_schema includes user_id. + */ + public function test_list_files_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/list-files' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test write-agent-file input_schema includes user_id. + */ + public function test_write_agent_file_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/write-agent-file' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test agent file listing with user_id returns scoped results. + */ + public function test_list_agent_files_scoped_by_user_id(): void { + $fa = new FileAbilities(); + + // Write a file to agent A's directory. + $fa->executeWriteAgentFile( + array( + 'filename' => 'test-alpha.md', + 'content' => '# Alpha agent file', + 'user_id' => $this->agent_a_id, + ) + ); + + // List agent A's files — should include test-alpha.md. + $result_a = $fa->executeListFiles( + array( + 'scope' => 'agent', + 'user_id' => $this->agent_a_id, + ) + ); + + $this->assertTrue( $result_a['success'] ); + $filenames_a = array_column( $result_a['files'], 'filename' ); + $this->assertContains( 'test-alpha.md', $filenames_a ); + + // List agent B's files — should NOT include test-alpha.md. + $result_b = $fa->executeListFiles( + array( + 'scope' => 'agent', + 'user_id' => $this->agent_b_id, + ) + ); + + $this->assertTrue( $result_b['success'] ); + $filenames_b = array_column( $result_b['files'], 'filename' ); + $this->assertNotContains( 'test-alpha.md', $filenames_b ); + } + + /** + * Test agent file write creates user-scoped directory. + */ + public function test_write_agent_file_creates_user_directory(): void { + $fa = new FileAbilities(); + + $result = $fa->executeWriteAgentFile( + array( + 'filename' => 'test-scoped.md', + 'content' => '# Scoped file', + 'user_id' => $this->agent_a_id, + ) + ); + + $this->assertTrue( $result['success'] ); + + $dm = new DirectoryManager(); + $user_dir = $dm->get_agent_directory( $this->agent_a_id ); + $this->assertDirectoryExists( $user_dir ); + $this->assertFileExists( $user_dir . '/test-scoped.md' ); + } + + /** + * Test agent file delete is scoped by user_id. + */ + public function test_delete_agent_file_scoped(): void { + $fa = new FileAbilities(); + + // Write to agent A. + $fa->executeWriteAgentFile( + array( + 'filename' => 'deleteme.md', + 'content' => '# Delete me', + 'user_id' => $this->agent_a_id, + ) + ); + + // Delete from agent B — should fail (file doesn't exist there). + $result_b = $fa->executeDeleteFile( + array( + 'filename' => 'deleteme.md', + 'scope' => 'agent', + 'user_id' => $this->agent_b_id, + ) + ); + $this->assertFalse( $result_b['success'] ); + + // Delete from agent A — should succeed. + $result_a = $fa->executeDeleteFile( + array( + 'filename' => 'deleteme.md', + 'scope' => 'agent', + 'user_id' => $this->agent_a_id, + ) + ); + $this->assertTrue( $result_a['success'] ); + } + + // ========================================================================= + // GetPipelinesAbility — user_id filter + // ========================================================================= + + /** + * Test get-pipelines input_schema includes user_id. + */ + public function test_get_pipelines_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/get-pipelines' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test pipeline listing filtered by user_id. + */ + public function test_pipeline_listing_filtered_by_user_id(): void { + $db = new Pipelines(); + + // Create pipeline for agent A. + $db->create_pipeline( + array( + 'pipeline_name' => 'Alpha Pipeline', + 'pipeline_config' => wp_json_encode( array() ), + 'user_id' => $this->agent_a_id, + ) + ); + + // Create pipeline for agent B. + $db->create_pipeline( + array( + 'pipeline_name' => 'Beta Pipeline', + 'pipeline_config' => wp_json_encode( array() ), + 'user_id' => $this->agent_b_id, + ) + ); + + $pa = new PipelineAbilities(); + + // Filter by agent A. + $result_a = $pa->executeGetPipelines( + array( 'user_id' => $this->agent_a_id ) + ); + $this->assertTrue( $result_a['success'] ); + $names_a = array_column( $result_a['pipelines'], 'pipeline_name' ); + $this->assertContains( 'Alpha Pipeline', $names_a ); + $this->assertNotContains( 'Beta Pipeline', $names_a ); + + // Filter by agent B. + $result_b = $pa->executeGetPipelines( + array( 'user_id' => $this->agent_b_id ) + ); + $this->assertTrue( $result_b['success'] ); + $names_b = array_column( $result_b['pipelines'], 'pipeline_name' ); + $this->assertContains( 'Beta Pipeline', $names_b ); + $this->assertNotContains( 'Alpha Pipeline', $names_b ); + } + + /** + * Test pipeline listing without user_id returns all pipelines. + */ + public function test_pipeline_listing_without_user_id_returns_all(): void { + $db = new Pipelines(); + + $db->create_pipeline( + array( + 'pipeline_name' => 'Shared Pipeline', + 'pipeline_config' => wp_json_encode( array() ), + 'user_id' => 0, + ) + ); + + $pa = new PipelineAbilities(); + $result = $pa->executeGetPipelines( array() ); + + $this->assertTrue( $result['success'] ); + // Should return all pipelines (no user_id filter). + $this->assertGreaterThan( 0, count( $result['pipelines'] ) ); + } + + // ========================================================================= + // GetFlowsAbility — user_id filter + // ========================================================================= + + /** + * Test get-flows input_schema includes user_id. + */ + public function test_get_flows_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/get-flows' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + // ========================================================================= + // GetJobsAbility — user_id filter + // ========================================================================= + + /** + * Test get-jobs input_schema includes user_id. + */ + public function test_get_jobs_schema_has_user_id(): void { + $ability = wp_get_ability( 'datamachine/get-jobs' ); + $this->assertNotNull( $ability ); + + $schema = $ability->get_input_schema(); + $this->assertArrayHasKey( 'user_id', $schema['properties'] ); + } + + /** + * Test job listing filtered by user_id. + */ + public function test_job_listing_filtered_by_user_id(): void { + $db = new Jobs(); + + // Create job for agent A. + $db->create_job( + array( + 'pipeline_id' => 'direct', + 'flow_id' => 'direct', + 'user_id' => $this->agent_a_id, + ) + ); + + // Create job for agent B. + $db->create_job( + array( + 'pipeline_id' => 'direct', + 'flow_id' => 'direct', + 'user_id' => $this->agent_b_id, + ) + ); + + $ja = new JobAbilities(); + + // Filter by agent A. + $result_a = $ja->executeGetJobs( + array( 'user_id' => $this->agent_a_id ) + ); + $this->assertTrue( $result_a['success'] ); + foreach ( $result_a['jobs'] as $job ) { + $this->assertEquals( $this->agent_a_id, $job['user_id'] ?? 0 ); + } + + // Filter by agent B. + $result_b = $ja->executeGetJobs( + array( 'user_id' => $this->agent_b_id ) + ); + $this->assertTrue( $result_b['success'] ); + foreach ( $result_b['jobs'] as $job ) { + $this->assertEquals( $this->agent_b_id, $job['user_id'] ?? 0 ); + } + } + + // ========================================================================= + // DirectoryManager — user-scoped paths + // ========================================================================= + + /** + * Test agent directories are isolated by user_id. + */ + public function test_agent_directories_isolated(): void { + $dm = new DirectoryManager(); + + $shared_dir = $dm->get_agent_directory( 0 ); + $agent_a_dir = $dm->get_agent_directory( $this->agent_a_id ); + $agent_b_dir = $dm->get_agent_directory( $this->agent_b_id ); + + // All three directories should be different paths. + $this->assertNotEquals( $shared_dir, $agent_a_dir ); + $this->assertNotEquals( $shared_dir, $agent_b_dir ); + $this->assertNotEquals( $agent_a_dir, $agent_b_dir ); + } + + /** + * Test shared agent directory is the legacy path (agent/). + */ + public function test_shared_agent_uses_legacy_path(): void { + $dm = new DirectoryManager(); + $shared_dir = $dm->get_agent_directory( 0 ); + + // Should end with /agent (no user suffix). + $this->assertStringEndsWith( '/agent', $shared_dir ); + } + + /** + * Test user-scoped agent directory uses agent-{user_id} path. + */ + public function test_user_scoped_agent_path(): void { + $dm = new DirectoryManager(); + $user_dir = $dm->get_agent_directory( $this->agent_a_id ); + + // Should end with /agent-{user_id}. + $this->assertStringEndsWith( '/agent-' . $this->agent_a_id, $user_dir ); + } +} diff --git a/tests/Unit/Cli/Commands/AgentsCommandTest.php b/tests/Unit/Cli/Commands/AgentsCommandTest.php new file mode 100644 index 00000000..503d5d53 --- /dev/null +++ b/tests/Unit/Cli/Commands/AgentsCommandTest.php @@ -0,0 +1,65 @@ +assertTrue( + is_subclass_of( AgentsCommand::class, \DataMachine\Cli\BaseCommand::class ), + 'AgentsCommand should extend BaseCommand' + ); + } + + /** + * Test list_agents method exists. + */ + public function test_has_list_agents_method(): void { + $this->assertTrue( + method_exists( AgentsCommand::class, 'list_agents' ), + 'AgentsCommand should have list_agents method' + ); + } + + /** + * Test list_agents method signature. + */ + public function test_list_agents_signature(): void { + $method = new ReflectionMethod( AgentsCommand::class, 'list_agents' ); + + $this->assertTrue( $method->isPublic() ); + + $params = $method->getParameters(); + $this->assertCount( 2, $params ); + $this->assertSame( 'args', $params[0]->getName() ); + $this->assertSame( 'assoc_args', $params[1]->getName() ); + } + + /** + * Test list_agents return type is void. + */ + public function test_list_agents_returns_void(): void { + $method = new ReflectionMethod( AgentsCommand::class, 'list_agents' ); + $returnType = $method->getReturnType(); + + $this->assertNotNull( $returnType ); + $this->assertSame( 'void', $returnType->getName() ); + } +} diff --git a/tests/Unit/Cli/UserResolverTest.php b/tests/Unit/Cli/UserResolverTest.php new file mode 100644 index 00000000..c4607229 --- /dev/null +++ b/tests/Unit/Cli/UserResolverTest.php @@ -0,0 +1,83 @@ +assertTrue( $method->isStatic() ); + $this->assertTrue( $method->isPublic() ); + } + + /** + * Test resolve accepts array parameter. + */ + public function test_resolve_accepts_array(): void { + $method = new ReflectionMethod( UserResolver::class, 'resolve' ); + $params = $method->getParameters(); + + $this->assertCount( 1, $params ); + $this->assertSame( 'assoc_args', $params[0]->getName() ); + $this->assertSame( 'array', $params[0]->getType()->getName() ); + } + + /** + * Test resolve returns int. + */ + public function test_resolve_returns_int(): void { + $method = new ReflectionMethod( UserResolver::class, 'resolve' ); + $returnType = $method->getReturnType(); + + $this->assertNotNull( $returnType ); + $this->assertSame( 'int', $returnType->getName() ); + } + + /** + * Test resolve returns 0 for empty assoc_args (no --user flag). + * + * This test works without WordPress because the early-return path + * doesn't call any WP functions. + */ + public function test_resolve_returns_zero_when_no_user_flag(): void { + $result = UserResolver::resolve( array() ); + $this->assertSame( 0, $result ); + } + + /** + * Test resolve returns 0 for null user value. + */ + public function test_resolve_returns_zero_for_null_user(): void { + $result = UserResolver::resolve( array( 'user' => null ) ); + $this->assertSame( 0, $result ); + } + + /** + * Test resolve returns 0 for empty string user value. + */ + public function test_resolve_returns_zero_for_empty_string_user(): void { + $result = UserResolver::resolve( array( 'user' => '' ) ); + $this->assertSame( 0, $result ); + } +}