diff options
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | lib/private/TaskProcessing/Manager.php | 46 | ||||
-rw-r--r-- | lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php | 68 | ||||
-rw-r--r-- | tests/lib/TaskProcessing/TaskProcessingTest.php | 320 |
5 files changed, 434 insertions, 2 deletions
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 52e3075e413..670a719c9fb 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -816,6 +816,7 @@ return array( 'OCP\\Talk\\ITalkBackend' => $baseDir . '/lib/public/Talk/ITalkBackend.php', 'OCP\\TaskProcessing\\EShapeType' => $baseDir . '/lib/public/TaskProcessing/EShapeType.php', 'OCP\\TaskProcessing\\Events\\AbstractTaskProcessingEvent' => $baseDir . '/lib/public/TaskProcessing/Events/AbstractTaskProcessingEvent.php', + 'OCP\\TaskProcessing\\Events\\GetTaskProcessingProvidersEvent' => $baseDir . '/lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php', 'OCP\\TaskProcessing\\Events\\TaskFailedEvent' => $baseDir . '/lib/public/TaskProcessing/Events/TaskFailedEvent.php', 'OCP\\TaskProcessing\\Events\\TaskSuccessfulEvent' => $baseDir . '/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php', 'OCP\\TaskProcessing\\Exception\\Exception' => $baseDir . '/lib/public/TaskProcessing/Exception/Exception.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index e98bc3e1aaa..3e18f17caf6 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -865,6 +865,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Talk\\ITalkBackend' => __DIR__ . '/../../..' . '/lib/public/Talk/ITalkBackend.php', 'OCP\\TaskProcessing\\EShapeType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/EShapeType.php', 'OCP\\TaskProcessing\\Events\\AbstractTaskProcessingEvent' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Events/AbstractTaskProcessingEvent.php', + 'OCP\\TaskProcessing\\Events\\GetTaskProcessingProvidersEvent' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php', 'OCP\\TaskProcessing\\Events\\TaskFailedEvent' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Events/TaskFailedEvent.php', 'OCP\\TaskProcessing\\Events\\TaskSuccessfulEvent' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php', 'OCP\\TaskProcessing\\Exception\\Exception' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/Exception.php', diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 0582d801e3d..a701c23d56f 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -41,6 +41,7 @@ use OCP\Lock\LockedException; use OCP\SpeechToText\ISpeechToTextProvider; use OCP\SpeechToText\ISpeechToTextProviderWithId; use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Events\GetTaskProcessingProvidersEvent; use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\TaskProcessing\Exception\NotFoundException; @@ -81,8 +82,13 @@ class Manager implements IManager { private IAppData $appData; private ?array $preferences = null; private ?array $providersById = null; + + /** @var ITaskType[]|null */ + private ?array $taskTypes = null; private ICache $distributedCache; + private ?GetTaskProcessingProvidersEvent $eventResult = null; + public function __construct( private IConfig $config, private Coordinator $coordinator, @@ -489,6 +495,20 @@ class Manager implements IManager { } /** + * Dispatches the event to collect external providers and task types. + * Caches the result within the request. + */ + private function dispatchGetProvidersEvent(): GetTaskProcessingProvidersEvent { + if ($this->eventResult !== null) { + return $this->eventResult; + } + + $this->eventResult = new GetTaskProcessingProvidersEvent(); + $this->dispatcher->dispatchTyped($this->eventResult); + return $this->eventResult ; + } + + /** * @return IProvider[] */ private function _getProviders(): array { @@ -516,6 +536,16 @@ class Manager implements IManager { } } + $event = $this->dispatchGetProvidersEvent(); + $externalProviders = $event->getProviders(); + foreach ($externalProviders as $provider) { + if (!isset($providers[$provider->getId()])) { + $providers[$provider->getId()] = $provider; + } else { + $this->logger->info('Skipping external task processing provider with ID ' . $provider->getId() . ' because a local provider with the same ID already exists.'); + } + } + $providers += $this->_getTextProcessingProviders() + $this->_getTextToImageProviders() + $this->_getSpeechToTextProviders(); return $providers; @@ -531,6 +561,10 @@ class Manager implements IManager { return []; } + if ($this->taskTypes !== null) { + return $this->taskTypes; + } + // Default task types $taskTypes = [ \OCP\TaskProcessing\TaskTypes\TextToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToText::class), @@ -568,9 +602,19 @@ class Manager implements IManager { } } + $event = $this->dispatchGetProvidersEvent(); + $externalTaskTypes = $event->getTaskTypes(); + foreach ($externalTaskTypes as $taskType) { + if (isset($taskTypes[$taskType->getId()])) { + $this->logger->warning('External task processing task type is using ID ' . $taskType->getId() . ' which is already used by a locally registered task type (' . get_class($taskTypes[$taskType->getId()]) . ')'); + } + $taskTypes[$taskType->getId()] = $taskType; + } + $taskTypes += $this->_getTextProcessingTaskTypes(); - return $taskTypes; + $this->taskTypes = $taskTypes; + return $this->taskTypes; } /** diff --git a/lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php b/lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php new file mode 100644 index 00000000000..10c94d20406 --- /dev/null +++ b/lib/public/TaskProcessing/Events/GetTaskProcessingProvidersEvent.php @@ -0,0 +1,68 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCP\TaskProcessing\Events; + +use OCP\EventDispatcher\Event; +use OCP\TaskProcessing\IProvider; +use OCP\TaskProcessing\ITaskType; + +/** + * Event dispatched by the server to collect Task Processing Providers + * and custom Task Types from listeners (like AppAPI). + * + * Listeners should add their providers and task types using the + * addProvider() and addTaskType() methods. + * + * @since 32.0.0 + */ +class GetTaskProcessingProvidersEvent extends Event { + /** @var IProvider[] */ + private array $providers = []; + + /** @var ITaskType[] */ + private array $taskTypes = []; + + /** + * Add a Task Processing Provider. + * + * @param IProvider $provider The provider instance to add. + * @since 32.0.0 + */ + public function addProvider(IProvider $provider): void { + $this->providers[] = $provider; + } + + /** + * Get all collected Task Processing Providers. + * + * @return IProvider[] + * @since 32.0.0 + */ + public function getProviders(): array { + return $this->providers; + } + + /** + * Add a custom Task Processing Task Type. + * + * @param ITaskType $taskType The task type instance to add. + * @since 32.0.0 + */ + public function addTaskType(ITaskType $taskType): void { + $this->taskTypes[] = $taskType; + } + + /** + * Get all collected custom Task Processing Task Types. + * + * @return ITaskType[] + * @since 32.0.0 + */ + public function getTaskTypes(): array { + return $this->taskTypes; + } +} diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 5dd38f83339..73f67b07266 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -28,6 +28,7 @@ use OCP\IServerContainer; use OCP\IUser; use OCP\IUserManager; use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Events\GetTaskProcessingProvidersEvent; use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\TaskProcessing\Exception\NotFoundException; @@ -131,8 +132,10 @@ class AsyncProvider implements IProvider { } class SuccessfulSyncProvider implements IProvider, ISynchronousProvider { + public const ID = 'test:sync:success'; + public function getId(): string { - return 'test:sync:success'; + return self::ID; } public function getName(): string { @@ -385,6 +388,132 @@ class FailingTextToImageProvider implements \OCP\TextToImage\IProvider { } } +class ExternalProvider implements IProvider { + public const ID = 'event:external:provider'; + public const TASK_TYPE_ID = 'event:external:tasktype'; + + public function getId(): string { + return self::ID; + } + public function getName(): string { + return 'External Provider via Event'; + } + public function getTaskTypeId(): string { + return self::TASK_TYPE_ID; + } + public function getExpectedRuntime(): int { + return 5; + } + public function getOptionalInputShape(): array { + return []; + } + public function getOptionalOutputShape(): array { + return []; + } + public function getInputShapeEnumValues(): array { + return []; + } + public function getInputShapeDefaults(): array { + return []; + } + public function getOptionalInputShapeEnumValues(): array { + return []; + } + public function getOptionalInputShapeDefaults(): array { + return []; + } + public function getOutputShapeEnumValues(): array { + return []; + } + public function getOptionalOutputShapeEnumValues(): array { + return []; + } +} + +class ConflictingExternalProvider implements IProvider { + // Same ID as SuccessfulSyncProvider + public const ID = 'test:sync:success'; + public const TASK_TYPE_ID = 'event:external:tasktype'; // Can be different task type + + public function getId(): string { + return self::ID; + } + public function getName(): string { + return 'Conflicting External Provider'; + } + public function getTaskTypeId(): string { + return self::TASK_TYPE_ID; + } + public function getExpectedRuntime(): int { + return 50; + } + public function getOptionalInputShape(): array { + return []; + } + public function getOptionalOutputShape(): array { + return []; + } + public function getInputShapeEnumValues(): array { + return []; + } + public function getInputShapeDefaults(): array { + return []; + } + public function getOptionalInputShapeEnumValues(): array { + return []; + } + public function getOptionalInputShapeDefaults(): array { + return []; + } + public function getOutputShapeEnumValues(): array { + return []; + } + public function getOptionalOutputShapeEnumValues(): array { + return []; + } +} + +class ExternalTaskType implements ITaskType { + public const ID = 'event:external:tasktype'; + + public function getId(): string { + return self::ID; + } + public function getName(): string { + return 'External Task Type via Event'; + } + public function getDescription(): string { + return 'A task type added via event'; + } + public function getInputShape(): array { + return ['external_input' => new ShapeDescriptor('Ext In', '', EShapeType::Text)]; + } + public function getOutputShape(): array { + return ['external_output' => new ShapeDescriptor('Ext Out', '', EShapeType::Text)]; + } +} + +class ConflictingExternalTaskType implements ITaskType { + // Same ID as built-in TextToText + public const ID = TextToText::ID; + + public function getId(): string { + return self::ID; + } + public function getName(): string { + return 'Conflicting External Task Type'; + } + public function getDescription(): string { + return 'Overrides built-in TextToText'; + } + public function getInputShape(): array { + return ['override_input' => new ShapeDescriptor('Override In', '', EShapeType::Number)]; + } + public function getOutputShape(): array { + return ['override_output' => new ShapeDescriptor('Override Out', '', EShapeType::Number)]; + } +} + /** * @group DB */ @@ -416,6 +545,10 @@ class TaskProcessingTest extends \Test\TestCase { FailingTextProcessingSummaryProvider::class => new FailingTextProcessingSummaryProvider(), SuccessfulTextToImageProvider::class => new SuccessfulTextToImageProvider(), FailingTextToImageProvider::class => new FailingTextToImageProvider(), + ExternalProvider::class => new ExternalProvider(), + ConflictingExternalProvider::class => new ConflictingExternalProvider(), + ExternalTaskType::class => new ExternalTaskType(), + ConflictingExternalTaskType::class => new ConflictingExternalTaskType(), ]; $userManager = \OCP\Server::get(IUserManager::class); @@ -447,6 +580,7 @@ class TaskProcessingTest extends \Test\TestCase { }); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->configureEventDispatcherMock(); $text2imageManager = new \OC\TextToImage\Manager( $this->serverContainer, @@ -964,4 +1098,188 @@ class TaskProcessingTest extends \Test\TestCase { self::assertEquals('ERROR', $task->getErrorMessage()); self::assertTrue($this->providers[FailingTextToImageProvider::class]->ran); } + + public function testMergeProvidersLocalAndEvent() { + // Arrange: Local provider registered, DIFFERENT external provider via event + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSyncProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getSpeechToTextProviders')->willReturn([]); + + $externalProvider = new ExternalProvider(); // ID = 'event:external:provider' + $this->configureEventDispatcherMock(providersToAdd: [$externalProvider]); + $this->manager = $this->createManagerInstance(); + + // Act + $providers = $this->manager->getProviders(); + + // Assert: Both providers should be present + self::assertArrayHasKey(SuccessfulSyncProvider::ID, $providers); + self::assertInstanceOf(SuccessfulSyncProvider::class, $providers[SuccessfulSyncProvider::ID]); + self::assertArrayHasKey(ExternalProvider::ID, $providers); + self::assertInstanceOf(ExternalProvider::class, $providers[ExternalProvider::ID]); + self::assertCount(2, $providers); + } + + public function testGetProvidersIncludesExternalViaEvent() { + // Arrange: No local providers, one external provider via event + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getSpeechToTextProviders')->willReturn([]); + + + $externalProvider = new ExternalProvider(); + $this->configureEventDispatcherMock(providersToAdd: [$externalProvider]); + $this->manager = $this->createManagerInstance(); // Create manager with configured mocks + + // Act + $providers = $this->manager->getProviders(); // Returns ID-indexed array + + // Assert + self::assertArrayHasKey(ExternalProvider::ID, $providers); + self::assertInstanceOf(ExternalProvider::class, $providers[ExternalProvider::ID]); + self::assertCount(1, $providers); + self::assertTrue($this->manager->hasProviders()); + } + + public function testGetAvailableTaskTypesIncludesExternalViaEvent() { + // Arrange: No local types/providers, one external type and provider via event + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getSpeechToTextProviders')->willReturn([]); + + $externalProvider = new ExternalProvider(); // Provides ExternalTaskType + $externalTaskType = new ExternalTaskType(); + $this->configureEventDispatcherMock( + providersToAdd: [$externalProvider], + taskTypesToAdd: [$externalTaskType] + ); + $this->manager = $this->createManagerInstance(); + + // Act + $availableTypes = $this->manager->getAvailableTaskTypes(); + + // Assert + self::assertArrayHasKey(ExternalTaskType::ID, $availableTypes); + self::assertEquals(ExternalTaskType::ID, $externalProvider->getTaskTypeId(), 'Test Sanity: Provider must handle the Task Type'); + self::assertEquals('External Task Type via Event', $availableTypes[ExternalTaskType::ID]['name']); + // Check if shapes match the external type/provider + self::assertArrayHasKey('external_input', $availableTypes[ExternalTaskType::ID]['inputShape']); + self::assertArrayHasKey('external_output', $availableTypes[ExternalTaskType::ID]['outputShape']); + self::assertEmpty($availableTypes[ExternalTaskType::ID]['optionalInputShape']); // From ExternalProvider + } + + public function testLocalProviderWinsConflictWithEvent() { + // Arrange: Local provider registered, conflicting external provider via event + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSyncProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getSpeechToTextProviders')->willReturn([]); + + $conflictingExternalProvider = new ConflictingExternalProvider(); // ID = 'test:sync:success' + $this->configureEventDispatcherMock(providersToAdd: [$conflictingExternalProvider]); + $this->manager = $this->createManagerInstance(); + + // Act + $providers = $this->manager->getProviders(); + + // Assert: Only the local provider should be present for the conflicting ID + self::assertArrayHasKey(SuccessfulSyncProvider::ID, $providers); + self::assertInstanceOf(SuccessfulSyncProvider::class, $providers[SuccessfulSyncProvider::ID]); + self::assertCount(1, $providers); // Ensure no extra provider was added + } + + public function testMergeTaskTypesLocalAndEvent() { + // Arrange: Local type registered, DIFFERENT external type via event + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', AsyncProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([ + new ServiceRegistration('test', AudioToImage::class) + ]); + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([]); + $this->registrationContext->expects($this->any())->method('getSpeechToTextProviders')->willReturn([]); + + $externalTaskType = new ExternalTaskType(); // ID = 'event:external:tasktype' + $externalProvider = new ExternalProvider(); // Handles 'event:external:tasktype' + $this->configureEventDispatcherMock( + providersToAdd: [$externalProvider], + taskTypesToAdd: [$externalTaskType] + ); + $this->manager = $this->createManagerInstance(); + + // Act + $availableTypes = $this->manager->getAvailableTaskTypes(); + + // Assert: Both task types should be available + self::assertArrayHasKey(AudioToImage::ID, $availableTypes); + self::assertEquals(AudioToImage::class, $availableTypes[AudioToImage::ID]['name']); + + self::assertArrayHasKey(ExternalTaskType::ID, $availableTypes); + self::assertEquals('External Task Type via Event', $availableTypes[ExternalTaskType::ID]['name']); + + self::assertCount(2, $availableTypes); + } + + private function createManagerInstance(): Manager { + // Clear potentially cached config values if needed + $this->config->deleteAppValue('core', 'ai.taskprocessing_type_preferences'); + + // Re-create Text2ImageManager if its state matters or mocks change + $text2imageManager = new \OC\TextToImage\Manager( + $this->serverContainer, + $this->coordinator, + \OC::$server->get(LoggerInterface::class), + $this->jobList, + \OC::$server->get(\OC\TextToImage\Db\TaskMapper::class), + $this->config, // Use the shared config mock + \OC::$server->get(IAppDataFactory::class), + ); + + return new Manager( + $this->config, + $this->coordinator, + $this->serverContainer, + \OC::$server->get(LoggerInterface::class), + $this->taskMapper, + $this->jobList, + $this->eventDispatcher, // Use the potentially reconfigured mock + \OC::$server->get(IAppDataFactory::class), + $this->rootFolder, + $text2imageManager, + $this->userMountCache, + \OC::$server->get(IClientService::class), + \OC::$server->get(IAppManager::class), + \OC::$server->get(ICacheFactory::class), + ); + } + + private function configureEventDispatcherMock( + array $providersToAdd = [], + array $taskTypesToAdd = [], + ?int $expectedCalls = null, + ): void { + $dispatchExpectation = $expectedCalls === null ? $this->any() : $this->exactly($expectedCalls); + + $this->eventDispatcher->expects($dispatchExpectation) + ->method('dispatchTyped') + ->willReturnCallback(function (object $event) use ($providersToAdd, $taskTypesToAdd) { + if ($event instanceof GetTaskProcessingProvidersEvent) { + foreach ($providersToAdd as $providerInstance) { + $event->addProvider($providerInstance); + } + foreach ($taskTypesToAdd as $taskTypeInstance) { + $event->addTaskType($taskTypeInstance); + } + } + }); + } } |