diff --git a/.github/workflows/homeboy.yml b/.github/workflows/homeboy.yml index c7c6be8f..295c28f2 100644 --- a/.github/workflows/homeboy.yml +++ b/.github/workflows/homeboy.yml @@ -15,8 +15,20 @@ 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' extension: wordpress commands: lint,test,audit component: data-machine diff --git a/composer.json b/composer.json index e4bb1a51..eba45406 100644 --- a/composer.json +++ b/composer.json @@ -3,12 +3,12 @@ "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", 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/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/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/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/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/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' ), 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/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/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; + } +} 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 ) { 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 ); + } +}