Merge pull request #52050 from nextcloud/fix/noid/taskprocessing-appapi

fix(taskprocessing): use the event for AppAPI to get list of AI providers
pull/52086/head
Alexander Piskun 2025-04-10 10:22:25 +07:00 committed by GitHub
commit 0a474e5ccd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 434 additions and 2 deletions

@ -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',

@ -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',

@ -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,
@ -488,6 +494,20 @@ class Manager implements IManager {
return $newProviders;
}
/**
* 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[]
*/
@ -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;
}
/**

@ -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;
}
}

@ -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);
}
}
});
}
}