From 00894e24208e4e6ae78609b8828ba32544e88ee8 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 29 Apr 2024 16:21:07 +0200 Subject: [PATCH 01/63] feat: first pass at TaskProcessing API Signed-off-by: Marcel Klehr --- .../Version30000Date20240429122720.php | 114 +++ .../Bootstrap/RegistrationContext.php | 48 + lib/private/Files/Node/Folder.php | 2 +- lib/private/TaskProcessing/Db/Task.php | 134 +++ lib/private/TaskProcessing/Db/TaskMapper.php | 138 +++ lib/private/TaskProcessing/Manager.php | 890 ++++++++++++++++++ .../SynchronousBackgroundJob.php | 85 ++ lib/public/Files/SimpleFS/ISimpleFile.php | 8 + lib/public/TaskProcessing/EShapeType.php | 42 + .../Events/AbstractTextProcessingEvent.php | 51 + .../TaskProcessing/Events/TaskFailedEvent.php | 30 + .../Events/TaskSuccessfulEvent.php | 9 + .../TaskProcessing/Exception/Exception.php | 34 + .../Exception/NotFoundException.php | 7 + .../Exception/ProcessingException.php | 35 + .../Exception/ValidationException.php | 7 + lib/public/TaskProcessing/IManager.php | 157 +++ lib/public/TaskProcessing/IProvider.php | 80 ++ .../TaskProcessing/ISynchronousProvider.php | 48 + lib/public/TaskProcessing/ITaskType.php | 73 ++ lib/public/TaskProcessing/ShapeDescriptor.php | 24 + lib/public/TaskProcessing/Task.php | 263 ++++++ .../TaskProcessing/TaskTypes/AudioToText.php | 93 ++ .../TaskProcessing/TaskTypes/TextToImage.php | 98 ++ .../TaskProcessing/TaskTypes/TextToText.php | 93 ++ .../TaskTypes/TextToTextHeadline.php | 93 ++ .../TaskTypes/TextToTextSummary.php | 92 ++ .../TaskTypes/TextToTextTopics.php | 93 ++ .../lib/TaskProcessing/TaskProcessingTest.php | 467 +++++++++ 29 files changed, 3307 insertions(+), 1 deletion(-) create mode 100644 core/Migrations/Version30000Date20240429122720.php create mode 100644 lib/private/TaskProcessing/Db/Task.php create mode 100644 lib/private/TaskProcessing/Db/TaskMapper.php create mode 100644 lib/private/TaskProcessing/Manager.php create mode 100644 lib/private/TaskProcessing/SynchronousBackgroundJob.php create mode 100644 lib/public/TaskProcessing/EShapeType.php create mode 100644 lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php create mode 100644 lib/public/TaskProcessing/Events/TaskFailedEvent.php create mode 100644 lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php create mode 100644 lib/public/TaskProcessing/Exception/Exception.php create mode 100644 lib/public/TaskProcessing/Exception/NotFoundException.php create mode 100644 lib/public/TaskProcessing/Exception/ProcessingException.php create mode 100644 lib/public/TaskProcessing/Exception/ValidationException.php create mode 100644 lib/public/TaskProcessing/IManager.php create mode 100644 lib/public/TaskProcessing/IProvider.php create mode 100644 lib/public/TaskProcessing/ISynchronousProvider.php create mode 100644 lib/public/TaskProcessing/ITaskType.php create mode 100644 lib/public/TaskProcessing/ShapeDescriptor.php create mode 100644 lib/public/TaskProcessing/Task.php create mode 100644 lib/public/TaskProcessing/TaskTypes/AudioToText.php create mode 100644 lib/public/TaskProcessing/TaskTypes/TextToImage.php create mode 100644 lib/public/TaskProcessing/TaskTypes/TextToText.php create mode 100644 lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php create mode 100644 lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php create mode 100644 lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php create mode 100644 tests/lib/TaskProcessing/TaskProcessingTest.php diff --git a/core/Migrations/Version30000Date20240429122720.php b/core/Migrations/Version30000Date20240429122720.php new file mode 100644 index 0000000000000..1f53aacd66b85 --- /dev/null +++ b/core/Migrations/Version30000Date20240429122720.php @@ -0,0 +1,114 @@ + + * + * @author Marcel Klehr + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Migrations; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * + */ +class Version30000Date20240429122720 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('taskprocessing_tasks')) { + $table = $schema->createTable('taskprocessing_tasks'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 64, + 'autoincrement' => true, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('input', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('output', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('status', Types::INTEGER, [ + 'notnull' => false, + 'length' => 6, + 'default' => 0, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('app_id', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('identifier', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('last_updated', Types::INTEGER, [ + 'notnull' => false, + 'length' => 4, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('completion_expected_at', Types::DATETIME, [ + 'notnull' => false, + ]); + $table->addColumn('progress', Types::FLOAT, [ + 'notnull' => false, + ]); + $table->addColumn('error_message', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + + $table->setPrimaryKey(['id'], 'tasks_id_index'); + $table->addIndex(['status', 'type'], 'tasks_status_type'); + $table->addIndex(['last_updated'], 'tasks_updated'); + $table->addIndex(['user_id', 'app_id', 'identifier'], 'tasks_uid_appid_ident'); + + return $schema; + } + + return null; + } +} diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index b1b2c57da555a..31f3dd7e4d2d2 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -163,6 +163,12 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private array $teamResourceProviders = []; + /** @var ServiceRegistration<\OCP\TaskProcessing\IProvider>[] */ + private $taskProcessingProviders = []; + + /** @var ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] */ + private $taskProcessingTaskTypes = []; + public function __construct(LoggerInterface $logger) { $this->logger = $logger; } @@ -411,6 +417,20 @@ public function registerDeclarativeSettings(string $declarativeSettingsClass): v $declarativeSettingsClass ); } + + public function registerTaskProcessingProvider(string $taskProcessingProviderClass): void { + $this->context->registerTaskProcessingProvider( + $this->appId, + $taskProcessingProviderClass + ); + } + + public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeClass): void { + $this->context->registerTaskProcessingTaskType( + $this->appId, + $taskProcessingTaskTypeClass + ); + } }; } @@ -590,6 +610,20 @@ public function registerDeclarativeSettings(string $appId, string $declarativeSe $this->declarativeSettings[] = new ServiceRegistration($appId, $declarativeSettingsClass); } + /** + * @psalm-param class-string<\OCP\TaskProcessing\IProvider> $declarativeSettingsClass + */ + public function registerTaskProcessingProvider(string $appId, string $taskProcessingProviderClass): void { + $this->taskProcessingProviders[] = new ServiceRegistration($appId, $taskProcessingProviderClass); + } + + /** + * @psalm-param class-string<\OCP\TaskProcessing\ITaskType> $declarativeSettingsClass + */ + public function registerTaskProcessingTaskType(string $appId, string $taskProcessingTaskTypeClass) { + $this->taskProcessingTaskTypes[] = new ServiceRegistration($appId, $taskProcessingTaskTypeClass); + } + /** * @param App[] $apps */ @@ -920,4 +954,18 @@ public function getTeamResourceProviders(): array { public function getDeclarativeSettings(): array { return $this->declarativeSettings; } + + /** + * @return ServiceRegistration<\OCP\TaskProcessing\IProvider>[] + */ + public function getTaskProcessingProviders(): array { + return $this->taskProcessingProviders; + } + + /** + * @return ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] + */ + public function getTaskProcessingTaskTypes(): array { + return $this->taskProcessingTaskTypes; + } } diff --git a/lib/private/Files/Node/Folder.php b/lib/private/Files/Node/Folder.php index 52e7b55676a56..5dc9b41c10bb0 100644 --- a/lib/private/Files/Node/Folder.php +++ b/lib/private/Files/Node/Folder.php @@ -317,7 +317,7 @@ public function getFirstNodeById(int $id): ?\OCP\Files\Node { return current($this->getById($id)) ?: null; } - protected function getAppDataDirectoryName(): string { + public function getAppDataDirectoryName(): string { $instanceId = \OC::$server->getConfig()->getSystemValueString('instanceid'); return 'appdata_' . $instanceId; } diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php new file mode 100644 index 0000000000000..3712d0ac4225f --- /dev/null +++ b/lib/private/TaskProcessing/Db/Task.php @@ -0,0 +1,134 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OC\TaskProcessing\Db; + +use OCP\AppFramework\Db\Entity; +use OCP\TaskProcessing\Task as OCPTask; + +/** + * @method setType(string $type) + * @method string getType() + * @method setLastUpdated(int $lastUpdated) + * @method int getLastUpdated() + * @method setStatus(int $status) + * @method int getStatus() + * @method setOutput(string $output) + * @method string getOutput() + * @method setInput(string $input) + * @method string getInput() + * @method setUserId(?string $userId) + * @method string|null getUserId() + * @method setAppId(string $type) + * @method string getAppId() + * @method setIdentifier(string $identifier) + * @method string getIdentifier() + * @method setCompletionExpectedAt(null|\DateTime $completionExpectedAt) + * @method null|\DateTime getCompletionExpectedAt() + * @method setErrorMessage(null|string $error) + * @method null|string getErrorMessage() + * @method setProgress(null|float $progress) + * @method null|float getProgress() + */ +class Task extends Entity { + protected $lastUpdated; + protected $type; + protected $input; + protected $output; + protected $status; + protected $userId; + protected $appId; + protected $identifier; + protected $completionExpectedAt; + protected $errorMessage; + protected $progress; + + /** + * @var string[] + */ + public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'identifier', 'completion_expected_at', 'error_message', 'progress']; + + /** + * @var string[] + */ + public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'identifier', 'completionExpectedAt', 'errorMessage', 'progress']; + + + public function __construct() { + // add types in constructor + $this->addType('id', 'integer'); + $this->addType('lastUpdated', 'integer'); + $this->addType('type', 'string'); + $this->addType('input', 'string'); + $this->addType('output', 'string'); + $this->addType('status', 'integer'); + $this->addType('userId', 'string'); + $this->addType('appId', 'string'); + $this->addType('identifier', 'string'); + $this->addType('completionExpectedAt', 'datetime'); + $this->addType('errorMessage', 'string'); + $this->addType('progress', 'float'); + } + + public function toRow(): array { + return array_combine(self::$columns, array_map(function ($field) { + return $this->{'get'.ucfirst($field)}(); + }, self::$fields)); + } + + public static function fromPublicTask(OCPTask $task): Task { + /** @var Task $taskEntity */ + $taskEntity = Task::fromParams([ + 'id' => $task->getId(), + 'type' => $task->getTaskType(), + 'lastUpdated' => time(), + 'status' => $task->getStatus(), + 'input' => json_encode($task->getInput(), JSON_THROW_ON_ERROR), + 'output' => json_encode($task->getOutput(), JSON_THROW_ON_ERROR), + 'errorMessage' => $task->getErrorMessage(), + 'userId' => $task->getUserId(), + 'appId' => $task->getAppId(), + 'identifier' => $task->getIdentifier(), + 'completionExpectedAt' => $task->getCompletionExpectedAt(), + 'progress' => $task->getProgress(), + ]); + return $taskEntity; + } + + /** + * @return OCPTask + * @throws \JsonException + */ + public function toPublicTask(): OCPTask { + $task = new OCPTask($this->getType(), json_decode($this->getInput(), true, 512, JSON_THROW_ON_ERROR), $this->getAppId(), $this->getuserId(), $this->getIdentifier()); + $task->setId($this->getId()); + $task->setStatus($this->getStatus()); + $task->setOutput(json_decode($this->getOutput(), true, 512, JSON_THROW_ON_ERROR)); + $task->setCompletionExpectedAt($this->getCompletionExpectedAt()); + $task->setErrorMessage($this->getErrorMessage()); + $task->setProgress($this->getProgress()); + return $task; + } +} diff --git a/lib/private/TaskProcessing/Db/TaskMapper.php b/lib/private/TaskProcessing/Db/TaskMapper.php new file mode 100644 index 0000000000000..a1cc3d1409a14 --- /dev/null +++ b/lib/private/TaskProcessing/Db/TaskMapper.php @@ -0,0 +1,138 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OC\TaskProcessing\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @extends QBMapper + */ +class TaskMapper extends QBMapper { + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'taskprocessing_tasks', Task::class); + } + + /** + * @param int $id + * @return Task + * @throws Exception + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function find(int $id): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id))); + return $this->findEntity($qb); + } + + /** + * @param string|null $taskType + * @return Task + * @throws DoesNotExistException + * @throws Exception + */ + public function findOldestScheduledByType(?string $taskType): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('status', $qb->createPositionalParameter(\OCP\TaskProcessing\Task::STATUS_SCHEDULED, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1) + ->orderBy('last_updated', 'ASC'); + if ($taskType !== null) { + $qb->andWhere($qb->expr()->eq('type', $qb->createPositionalParameter($taskType))); + } + return $this->findEntity($qb); + } + + /** + * @param int $id + * @param string|null $userId + * @return Task + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + public function findByIdAndUser(int $id, ?string $userId): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id))); + if ($userId === null) { + $qb->andWhere($qb->expr()->isNull('user_id')); + } else { + $qb->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))); + } + return $this->findEntity($qb); + } + + /** + * @param string $userId + * @param string $appId + * @param string|null $identifier + * @return array + * @throws Exception + */ + public function findUserTasksByApp(string $userId, string $appId, ?string $identifier = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))) + ->andWhere($qb->expr()->eq('app_id', $qb->createPositionalParameter($appId))); + if ($identifier !== null) { + $qb->andWhere($qb->expr()->eq('identifier', $qb->createPositionalParameter($identifier))); + } + return $this->findEntities($qb); + } + + /** + * @param int $timeout + * @return int the number of deleted tasks + * @throws Exception + */ + public function deleteOlderThan(int $timeout): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->lt('last_updated', $qb->createPositionalParameter(time() - $timeout))); + return $qb->executeStatement(); + } + + public function update(Entity $entity): Entity { + $entity->setLastUpdated($this->timeFactory->now()->getTimestamp()); + return parent::update($entity); + } +} diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php new file mode 100644 index 0000000000000..08cf9679087f7 --- /dev/null +++ b/lib/private/TaskProcessing/Manager.php @@ -0,0 +1,890 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OC\TaskProcessing; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\TaskProcessing\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\GenericFileException; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IServerContainer; +use OCP\Lock\LockedException; +use OCP\PreConditionNotMetException; +use OCP\SpeechToText\ISpeechToTextProvider; +use OCP\SpeechToText\ISpeechToTextProviderWithId; +use OCP\SpeechToText\ISpeechToTextProviderWithUserId; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Events\TaskFailedEvent; +use OCP\TaskProcessing\Events\TaskSuccessfulEvent; +use OCP\TaskProcessing\Exception\Exception; +use OCP\TaskProcessing\Exception\NotFoundException; +use OCP\TaskProcessing\Exception\ProcessingException; +use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\IManager; +use OCP\TaskProcessing\IProvider; +use OCP\TaskProcessing\ISynchronousProvider; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; +use OCP\TaskProcessing\Task; +use OCP\TaskProcessing\TaskTypes\AudioToText; +use OCP\TaskProcessing\TaskTypes\TextToImage; +use OCP\TaskProcessing\TaskTypes\TextToText; +use OCP\TaskProcessing\TaskTypes\TextToTextHeadline; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; +use OCP\TaskProcessing\TaskTypes\TextToTextTopics; +use Psr\Log\LoggerInterface; + +class Manager implements IManager { + + public const LEGACY_PREFIX_TEXTPROCESSING = 'legacy:TextProcessing:'; + public const LEGACY_PREFIX_TEXTTOIMAGE = 'legacy:TextToImage:'; + public const LEGACY_PREFIX_SPEECHTOTEXT = 'legacy:SpeechToText:'; + + /** @var |null */ + private ?array $providers = null; + + /** @var array, optionalInputShape: array, outputShape: array, optionalOutputShape: array}>|null */ + private ?array $availableTaskTypes = null; + + private IAppData $appData; + + public function __construct( + private Coordinator $coordinator, + private IServerContainer $serverContainer, + private LoggerInterface $logger, + private TaskMapper $taskMapper, + private IJobList $jobList, + private IEventDispatcher $dispatcher, + IAppDataFactory $appDataFactory, + private IRootFolder $rootFolder, + private \OCP\TextProcessing\IManager $textProcessingManager, + private \OCP\TextToImage\IManager $textToImageManager, + private \OCP\SpeechToText\ISpeechToTextManager $speechToTextManager, + ) { + $this->appData = $appDataFactory->get('core'); + } + + /** + * @return IProvider[] + */ + private function _getTextProcessingProviders(): array { + $oldProviders = $this->textProcessingManager->getProviders(); + $newProviders = []; + foreach ($oldProviders as $oldProvider) { + $provider = new class($oldProvider) implements IProvider, ISynchronousProvider { + private \OCP\TextProcessing\IProvider $provider; + + public function __construct(\OCP\TextProcessing\IProvider $provider) { + $this->provider = $provider; + } + + public function getId(): string { + if ($this->provider instanceof \OCP\TextProcessing\IProviderWithId) { + return $this->provider->getId(); + } + return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider::class; + } + + public function getName(): string { + return $this->provider->getName(); + } + + public function getTaskType(): string { + return match ($this->provider->getTaskType()) { + \OCP\TextProcessing\FreePromptTaskType::class => TextToText::ID, + \OCP\TextProcessing\HeadlineTaskType::class => TextToTextHeadline::ID, + \OCP\TextProcessing\TopicsTaskType::class => TextToTextTopics::ID, + \OCP\TextProcessing\SummaryTaskType::class => TextToTextSummary::ID, + default => Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider->getTaskType(), + }; + } + + public function getExpectedRuntime(): int { + if ($this->provider instanceof \OCP\TextProcessing\IProviderWithExpectedRuntime) { + return $this->provider->getExpectedRuntime(); + } + return 60; + } + + public function getOptionalInputShape(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function process(?string $userId, array $input): array { + if ($this->provider instanceof \OCP\TextProcessing\IProviderWithUserId) { + $this->provider->setUserId($userId); + } + try { + return ['output' => $this->provider->process($input['input'])]; + } catch(\RuntimeException $e) { + throw new ProcessingException($e->getMessage(), 0, $e); + } + } + }; + $newProviders[$provider->getId()] = $provider; + } + + return $newProviders; + } + + /** + * @return IProvider[] + */ + private function _getTextProcessingTaskTypes(): array { + $oldProviders = $this->textProcessingManager->getProviders(); + $newTaskTypes = []; + foreach ($oldProviders as $oldProvider) { + // These are already implemented in the TaskProcessing realm + if (in_array($oldProvider->getTaskType(), [ + \OCP\TextProcessing\FreePromptTaskType::class, + \OCP\TextProcessing\HeadlineTaskType::class, + \OCP\TextProcessing\TopicsTaskType::class, + \OCP\TextProcessing\SummaryTaskType::class + ], true)) { + continue; + } + $taskType = new class($oldProvider->getTaskType()) implements ITaskType { + private string $oldTaskTypeClass; + private \OCP\TextProcessing\ITaskType $oldTaskType; + + public function __construct(string $oldTaskTypeClass) { + $this->oldTaskTypeClass = $oldTaskTypeClass; + $this->oldTaskType = \OCP\Server::get($oldTaskTypeClass); + } + + public function getId(): string { + return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->oldTaskTypeClass; + } + + public function getName(): string { + return $this->oldTaskType->getName(); + } + + public function getDescription(): string { + return $this->oldTaskType->getDescription(); + } + + public function getInputShape(): array { + return ['input' => EShapeType::Text]; + } + + public function getOutputShape(): array { + return ['output' => EShapeType::Text]; + } + }; + $newTaskTypes[$taskType->getId()] = $taskType; + } + + return $newTaskTypes; + } + + /** + * @return IProvider[] + */ + private function _getTextToImageProviders(): array { + $oldProviders = $this->textToImageManager->getProviders(); + $newProviders = []; + foreach ($oldProviders as $oldProvider) { + $newProvider = new class($oldProvider, $this->appData) implements IProvider, ISynchronousProvider { + private \OCP\TextToImage\IProvider $provider; + private IAppData $appData; + + public function __construct(\OCP\TextToImage\IProvider $provider, IAppData $appData) { + $this->provider = $provider; + $this->appData = $appData; + } + + public function getId(): string { + return Manager::LEGACY_PREFIX_TEXTTOIMAGE . $this->provider->getId(); + } + + public function getName(): string { + return $this->provider->getName(); + } + + public function getTaskType(): string { + return TextToImage::ID; + } + + public function getExpectedRuntime(): int { + return $this->provider->getExpectedRuntime(); + } + + public function getOptionalInputShape(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function process(?string $userId, array $input): array { + try { + $folder = $this->appData->getFolder('text2image'); + } catch(\OCP\Files\NotFoundException) { + $folder = $this->appData->newFolder('text2image'); + } + try { + $folder = $folder->getFolder((string) rand(1, 100000)); + } catch(\OCP\Files\NotFoundException) { + $folder = $folder->newFolder((string) rand(1, 100000)); + } + $resources = []; + $files = []; + for ($i = 0; $i < $input['numberOfImages']; $i++) { + $file = $folder->newFile((string) $i); + $files[] = $file; + $resource = $file->write(); + if ($resource !== false && $resource !== true && is_resource($resource)) { + $resources[] = $resource; + } else { + throw new ProcessingException('Text2Image generation using provider "' . $this->getName() . '" failed: Couldn\'t open file to write.'); + } + } + if ($this->provider instanceof \OCP\TextToImage\IProviderWithUserId) { + $this->provider->setUserId($userId); + } + try { + $this->provider->generate($input['input'], $resources); + }catch (\RuntimeException $e) { + throw new ProcessingException($e->getMessage(), 0, $e); + } + return ['images' => array_map(fn(File $file) => base64_encode($file->getContent()), $files)]; + } + }; + $newProviders[$newProvider->getId()] = $newProvider; + } + + return $newProviders; + } + + + /** + * @return IProvider[] + */ + private function _getSpeechToTextProviders(): array { + $oldProviders = $this->speechToTextManager->getProviders(); + $newProviders = []; + foreach ($oldProviders as $oldProvider) { + $newProvider = new class($oldProvider, $this->rootFolder, $this->appData) implements IProvider, ISynchronousProvider { + private ISpeechToTextProvider $provider; + private IAppData $appData; + + public function __construct(ISpeechToTextProvider $provider, IRootFolder $rootFolder, IAppData $appData) { + $this->provider = $provider; + $this->rootFolder = $rootFolder; + $this->appData = $appData; + } + + public function getId(): string { + if ($this->provider instanceof ISpeechToTextProviderWithId) { + return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider->getId(); + } + return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider::class; + } + + public function getName(): string { + return $this->provider->getName(); + } + + public function getTaskType(): string { + return AudioToText::ID; + } + + public function getExpectedRuntime(): int { + return 60; + } + + public function getOptionalInputShape(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function process(?string $userId, array $input): array { + try { + $folder = $this->appData->getFolder('audio2text'); + } catch(\OCP\Files\NotFoundException) { + $folder = $this->appData->newFolder('audio2text'); + } + try { + $folder = $folder->getFolder((string) rand(1, 100000)); + } catch(\OCP\Files\NotFoundException) { + $folder = $folder->newFolder((string) rand(1, 100000)); + } + $simpleFile = $folder->newFile((string) rand(0, 100000), base64_decode($input['input'])); + $id = $simpleFile->getId(); + /** @var File $file */ + $file = current($this->rootFolder->getById($id)); + if ($this->provider instanceof ISpeechToTextProviderWithUserId) { + $this->provider->setUserId($userId); + } + try { + $result = $this->provider->transcribeFile($file); + }catch (\RuntimeException $e) { + throw new ProcessingException($e->getMessage(), 0, $e); + } + return ['output' => $result]; + } + }; + $newProviders[$newProvider->getId()] = $newProvider; + } + + return $newProviders; + } + + /** + * @return IProvider[] + */ + private function _getProviders(): array { + $context = $this->coordinator->getRegistrationContext(); + + if ($context === null) { + return []; + } + + $providers = []; + + foreach ($context->getTaskProcessingProviders() as $providerServiceRegistration) { + $class = $providerServiceRegistration->getService(); + try { + /** @var IProvider $provider */ + $provider = $this->serverContainer->get($class); + $providers[$provider->getId()] = $provider; + } catch (\Throwable $e) { + $this->logger->error('Failed to load task processing provider ' . $class, [ + 'exception' => $e, + ]); + } + } + + $providers += $this->_getTextProcessingProviders() + $this->_getTextToImageProviders() + $this->_getSpeechToTextProviders(); + + return $providers; + } + + /** + * @return ITaskType[] + */ + private function _getTaskTypes(): array { + $context = $this->coordinator->getRegistrationContext(); + + if ($context === null) { + return []; + } + + // Default task types + $taskTypes = [ + \OCP\TaskProcessing\TaskTypes\TextToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToText::class), + \OCP\TaskProcessing\TaskTypes\TextToTextTopics::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTopics::class), + \OCP\TaskProcessing\TaskTypes\TextToTextHeadline::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextHeadline::class), + \OCP\TaskProcessing\TaskTypes\TextToTextSummary::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSummary::class), + \OCP\TaskProcessing\TaskTypes\TextToImage::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToImage::class), + \OCP\TaskProcessing\TaskTypes\AudioToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToText::class), + ]; + + foreach ($context->getTaskProcessingTaskTypes() as $providerServiceRegistration) { + $class = $providerServiceRegistration->getService(); + try { + /** @var ITaskType $provider */ + $taskType = $this->serverContainer->get($class); + $taskTypes[$taskType->getId()] = $taskType; + } catch (\Throwable $e) { + $this->logger->error('Failed to load task processing task type ' . $class, [ + 'exception' => $e, + ]); + } + } + + $taskTypes += $this->_getTextProcessingTaskTypes(); + + return $taskTypes; + } + + /** + * @param string $taskType + * @return IProvider + * @throws \OCP\TaskProcessing\Exception\Exception + */ + private function _getPreferredProvider(string $taskType){ + $providers = $this->getProviders(); + foreach ($providers as $provider) { + if ($provider->getTaskType() === $taskType) { + return $provider; + } + } + throw new \OCP\TaskProcessing\Exception\Exception('No matching provider found'); + } + + /** + * @param array $spec + * @param array $io + * @return void + * @throws ValidationException + */ + private function validateInput(array $spec, array $io, bool $optional = false) { + foreach ($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($io[$key])) { + if ($optional) { + continue; + } + throw new \OCP\TaskProcessing\Exception\ValidationException('Missing key: "' . $key . '"'); + } + if ($type === EShapeType::Text && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); + } + if ($type === EShapeType::Number && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); + } + if ($type === EShapeType::Image && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '"'); + } + if ($type === EShapeType::Audio && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '"'); + } + if ($type === EShapeType::Video && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '"'); + } + if ($type === EShapeType::File && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '"'); + } + } + } + + /** + * @param array $spec + * @param array $io + * @return void + * @throws ValidationException + */ + private function validateOutput(array $spec, array $io, bool $optional = false) { + foreach ($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($io[$key])) { + if ($optional) { + continue; + } + throw new \OCP\TaskProcessing\Exception\ValidationException('Missing key: "' . $key . '"'); + } + if ($type === EShapeType::Text && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); + } + if ($type === EShapeType::Number && !is_numeric($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); + } + if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); + } + if ($type === EShapeType::Image && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '". Expecting base64 encoded image data.'); + } + if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '". Expecting base64 encoded image data.'); + } + if ($type === EShapeType::Audio && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '". Expecting base64 encoded audio data.'); + } + if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '". Expecting base64 encoded audio data.'); + } + if ($type === EShapeType::Video && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '". Expecting base64 encoded video data.'); + } + if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '". Expecting base64 encoded video data.'); + } + if ($type === EShapeType::File && !is_string($io[$key])) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '". Expecting base64 encoded file data.'); + } + if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '". Expecting base64 encoded image data.'); + } + } + } + + /** + * @param array $array The array to filter + * @param array ...$specs the specs that define which keys to keep + * @return array + */ + private function removeSuperfluousArrayKeys(array $array, ...$specs): array { + $keys = array_unique(array_reduce($specs, fn($carry, $spec) => $carry + array_keys($spec), [])); + $values = array_map(fn(string $key) => $array[$key], $keys); + return array_combine($keys, $values); + } + + public function hasProviders(): bool { + return count($this->getProviders()) !== 0; + } + + public function getProviders(): array { + if ($this->providers === null) { + $this->providers = $this->_getProviders(); + } + + return $this->providers; + } + + public function getAvailableTaskTypes(): array { + if ($this->availableTaskTypes === null) { + $taskTypes = $this->_getTaskTypes(); + $providers = $this->getProviders(); + + $availableTaskTypes = []; + foreach ($providers as $provider) { + if (!isset($taskTypes[$provider->getTaskType()])) { + continue; + } + $taskType = $taskTypes[$provider->getTaskType()]; + $availableTaskTypes[$provider->getTaskType()] = [ + 'name' => $taskType->getName(), + 'description' => $taskType->getDescription(), + 'inputShape' => $taskType->getInputShape(), + 'optionalInputShape' => $provider->getOptionalInputShape(), + 'outputShape' => $taskType->getOutputShape(), + 'optionalOutputShape' => $provider->getOptionalOutputShape(), + ]; + } + + $this->availableTaskTypes = $availableTaskTypes; + } + + return $this->availableTaskTypes; + } + + public function canHandleTask(Task $task): bool { + return isset($this->getAvailableTaskTypes()[$task->getTaskType()]); + } + + public function scheduleTask(Task $task): void { + if (!$this->canHandleTask($task)) { + throw new PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskType()); + } + $taskTypes = $this->getAvailableTaskTypes(); + $inputShape = $taskTypes[$task->getTaskType()]['inputShape']; + $optionalInputShape = $taskTypes[$task->getTaskType()]['optionalInputShape']; + // validate input + $this->validateInput($inputShape, $task->getInput()); + $this->validateInput($optionalInputShape, $task->getInput(), true); + // remove superfluous keys and set input + $task->setInput($this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape)); + $task->setStatus(Task::STATUS_SCHEDULED); + $provider = $this->_getPreferredProvider($task->getTaskType()); + // calculate expected completion time + $completionExpectedAt = new \DateTime('now'); + $completionExpectedAt->add(new \DateInterval('PT'.$provider->getExpectedRuntime().'S')); + $task->setCompletionExpectedAt($completionExpectedAt); + // create a db entity and insert into db table + $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); + $this->taskMapper->insert($taskEntity); + // make sure the scheduler knows the id + $task->setId($taskEntity->getId()); + // schedule synchronous job if the provider is synchronous + if ($provider instanceof ISynchronousProvider) { + $this->jobList->add(SynchronousBackgroundJob::class, null); + } + } + + public function deleteTask(Task $task): void { + $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); + $this->taskMapper->delete($taskEntity); + } + + public function getTask(int $id): Task { + try { + $taskEntity = $this->taskMapper->find($id); + return $taskEntity->toPublicTask(); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Couldn\'t find task with id ' . $id, 0, $e); + } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e); + } + } + + public function cancelTask(int $id): void { + $task = $this->getTask($id); + $task->setStatus(Task::STATUS_CANCELLED); + $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); + try { + $this->taskMapper->update($taskEntity); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } + } + + public function setTaskProgress(int $id, float $progress): bool { + // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently + $task = $this->getTask($id); + if ($task->getStatus() === Task::STATUS_CANCELLED) { + return false; + } + $task->setStatus(Task::STATUS_RUNNING); + $task->setProgress($progress); + $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); + try { + $this->taskMapper->update($taskEntity); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } + return true; + } + + public function setTaskResult(int $id, ?string $error, ?array $result): void { + // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently + $task = $this->getTask($id); + if ($task->getStatus() === Task::STATUS_CANCELLED) { + $this->logger->info('A TaskProcessing ' . $task->getTaskType() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.'); + return; + } + if ($error !== null) { + $task->setStatus(Task::STATUS_FAILED); + $task->setErrorMessage($error); + $this->logger->warning('A TaskProcessing ' . $task->getTaskType() . ' task with id ' . $id . ' failed with the following message: ' . $error); + } else if ($result !== null) { + $taskTypes = $this->getAvailableTaskTypes(); + $outputShape = $taskTypes[$task->getTaskType()]['outputShape']; + $optionalOutputShape = $taskTypes[$task->getTaskType()]['optionalOutputShape']; + try { + // validate output + $this->validateOutput($outputShape, $result); + $this->validateOutput($optionalOutputShape, $result, true); + $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape); + // extract base64 data and put it in files, replace it with file ids + $output = $this->encapsulateInputOutputFileData($output, $outputShape, $optionalOutputShape); + $task->setOutput($output); + $task->setProgress(1); + $task->setStatus(Task::STATUS_SUCCESSFUL); + } catch (ValidationException $e) { + $task->setProgress(1); + $task->setStatus(Task::STATUS_FAILED); + $error = 'The task was processed successfully but the provider\'s output doesn\'t pass validation against the task type\'s outputShape spec and/or the provider\'s own optionalOutputShape spec'; + $task->setErrorMessage($error); + $this->logger->error($error, ['exception' => $e]); + } catch (NotPermittedException $e) { + $task->setProgress(1); + $task->setStatus(Task::STATUS_FAILED); + $error = 'The task was processed successfully but storing the output in a file failed'; + $task->setErrorMessage($error); + $this->logger->error($error, ['exception' => $e]); + + } + } + $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); + try { + $this->taskMapper->update($taskEntity); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } + if ($task->getStatus() === Task::STATUS_SUCCESSFUL) { + $event = new TaskSuccessfulEvent($task); + }else{ + $event = new TaskFailedEvent($task, $error); + } + $this->dispatcher->dispatchTyped($event); + } + + public function getNextScheduledTask(?string $taskTypeId = null): Task { + try { + $taskEntity = $this->taskMapper->findOldestScheduledByType($taskTypeId); + return $taskEntity->toPublicTask(); + } catch (DoesNotExistException $e) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e); + } + } + + public function getUserTask(int $id, ?string $userId): Task { + try { + $taskEntity = $this->taskMapper->findByIdAndUser($id, $userId); + return $taskEntity->toPublicTask(); + } catch (DoesNotExistException $e) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e); + } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e); + } + } + + public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { + try { + $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); + return array_map(fn($taskEntity) => $taskEntity->toPublicTask(), $taskEntities); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e); + } + } + + /** + * Takes task input or output data and replaces fileIds with base64 data + * + * @param array ...$specs the specs + * @param array $inputOutput + * @return array + * @throws GenericFileException + * @throws LockedException + * @throws NotPermittedException + * @throws ValidationException + */ + public function fillInputOutputFileData(array $inputOutput, ...$specs): array { + $newInputOutput = []; + $spec = array_reduce($specs, fn($carry, $spec) => $carry + $spec, []); + foreach($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($inputOutput[$key])) { + continue; + } + if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { + $newInputOutput[$key] = $inputOutput[$key]; + continue; + } + if ($type->value < 10) { + $node = $this->rootFolder->getFirstNodeById((int)$inputOutput[$key]); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath((int)$inputOutput[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if (!$node instanceof File) { + throw new ValidationException('File id given for key "' . $key . '" is not a file'); + } + } else if (!$node instanceof File) { + throw new ValidationException('File id given for key "' . $key . '" is not a file'); + } + // TODO: Validate if userId has access to this file + $newInputOutput[$key] = base64_encode($node->getContent()); + } else { + $newInputOutput[$key] = []; + foreach ($inputOutput[$key] as $item) { + $node = $this->rootFolder->getFirstNodeById((int)$inputOutput[$key]); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath((int)$inputOutput[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if (!$node instanceof File) { + throw new ValidationException('File id given for key "' . $key . '" is not a file'); + } + } else if (!$node instanceof File) { + throw new ValidationException('File id given for key "' . $key . '" is not a file'); + } + // TODO: Validate if userId has access to this file + $newInputOutput[$key][] = base64_encode($node->getContent()); + } + } + } + return $newInputOutput; + } + + /** + *Takes task input or output and replaces base64 data with file ids + * + * @param array $inputOutput + * @param array ...$specs the specs that define which keys to keep + * @return array + * @throws NotPermittedException + */ + public function encapsulateInputOutputFileData(array $inputOutput, ...$specs): array { + $newInputOutput = []; + try { + $folder = $this->appData->getFolder('TaskProcessing'); + } catch (\OCP\Files\NotFoundException) { + $folder = $this->appData->newFolder('TaskProcessing'); + } + $spec = array_reduce($specs, fn($carry, $spec) => $carry + $spec, []); + foreach($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($inputOutput[$key])) { + continue; + } + if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { + $newInputOutput[$key] = $inputOutput[$key]; + continue; + } + if ($type->value < 10) { + $file = $folder->newFile((string) rand(0, 10000000), base64_decode($inputOutput[$key])); + $newInputOutput[$key] = $file->getId(); + } else { + $newInputOutput = []; + foreach ($inputOutput[$key] as $item) { + $file = $folder->newFile((string) rand(0, 10000000), base64_decode($item)); + $newInputOutput[$key][] = $file->getId(); + } + } + } + return $newInputOutput; + } + + public function prepareInputData(Task $task): array { + $taskTypes = $this->getAvailableTaskTypes(); + $inputShape = $taskTypes[$task->getTaskType()]['inputShape']; + $optionalInputShape = $taskTypes[$task->getTaskType()]['optionalInputShape']; + $input = $task->getInput(); + // validate input, again for good measure (should have been validated in scheduleTask) + $this->validateInput($inputShape, $input); + $this->validateInput($optionalInputShape, $input, true); + $input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape); + $input = $this->fillInputOutputFileData($input, $inputShape, $optionalInputShape); + return $input; + } +} diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php new file mode 100644 index 0000000000000..ab85d46908938 --- /dev/null +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -0,0 +1,85 @@ +taskProcessingManager->getProviders(); + + foreach ($providers as $provider) { + if (!$provider instanceof ISynchronousProvider) { + continue; + } + $taskType = $provider->getTaskType(); + try { + $task = $this->taskProcessingManager->getNextScheduledTask($taskType); + } catch (NotFoundException $e) { + continue; + } catch (Exception $e) { + $this->logger->error('Unknown error while retrieving scheduled TaskProcessing tasks', ['exception' => $e]); + continue; + } + try { + try { + $input = $this->taskProcessingManager->prepareInputData($task); + } catch (GenericFileException|NotPermittedException|LockedException|ValidationException $e) { + $this->logger->warning('Failed to prepare input data for a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); + $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); + // Schedule again + $this->jobList->add(self::class, $argument); + return; + } + try { + $output = $provider->process($task->getUserId(), $input); + } catch (ProcessingException $e) { + $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); + $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); + // Schedule again + $this->jobList->add(self::class, $argument); + return; + } catch (\Throwable $e) { + $this->logger->error('Unknown error while processing TaskProcessing task', ['exception' => $e]); + $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); + // Schedule again + $this->jobList->add(self::class, $argument); + return; + } + $this->taskProcessingManager->setTaskResult($task->getId(), null, $output); + } catch (NotFoundException $e) { + $this->logger->info('Could not find task anymore after execution. Moving on.', ['exception' => $e]); + } catch (Exception $e) { + $this->logger->error('Failed to report result of TaskProcessing task', ['exception' => $e]); + } + } + + // Schedule again + $this->jobList->add(self::class, $argument); + } +} diff --git a/lib/public/Files/SimpleFS/ISimpleFile.php b/lib/public/Files/SimpleFS/ISimpleFile.php index 8afc310883660..cf848d33724ed 100644 --- a/lib/public/Files/SimpleFS/ISimpleFile.php +++ b/lib/public/Files/SimpleFS/ISimpleFile.php @@ -121,4 +121,12 @@ public function read(); * @since 14.0.0 */ public function write(); + + /** + * Returns the file id + * + * @return int + * @since 30.0.0 + */ + public function getId(): int; } diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php new file mode 100644 index 0000000000000..514451da0685b --- /dev/null +++ b/lib/public/TaskProcessing/EShapeType.php @@ -0,0 +1,42 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing; + +enum EShapeType: int { + case Number = 0; + case Text = 1; + case Image = 2; + case Audio = 3; + case Video = 4; + case File = 5; + case ListOfNumbers = 10; + case ListOfTexts = 11; + case ListOfImages = 12; + case ListOfAudio = 13; + case ListOfVideo = 14; + case ListOfFiles = 15; +} + diff --git a/lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php b/lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php new file mode 100644 index 0000000000000..0d8f6ddb2e0b1 --- /dev/null +++ b/lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php @@ -0,0 +1,51 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCP\TaskProcessing\Events; + +use OCP\EventDispatcher\Event; +use OCP\TaskProcessing\Task; + +/** + * @since 30.0.0 + */ +abstract class AbstractTextProcessingEvent extends Event { + /** + * @since 30.0.0 + */ + public function __construct( + private readonly Task $task + ) { + parent::__construct(); + } + + /** + * @return Task + * @since 30.0.0 + */ + public function getTask(): Task { + return $this->task; + } +} diff --git a/lib/public/TaskProcessing/Events/TaskFailedEvent.php b/lib/public/TaskProcessing/Events/TaskFailedEvent.php new file mode 100644 index 0000000000000..7b118c08b8cf2 --- /dev/null +++ b/lib/public/TaskProcessing/Events/TaskFailedEvent.php @@ -0,0 +1,30 @@ +errorMessage; + } +} diff --git a/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php b/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php new file mode 100644 index 0000000000000..88214a451aacc --- /dev/null +++ b/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php @@ -0,0 +1,9 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OCP\TaskProcessing\Exception; + +/** + * TaskProcessing Exception + * @since 30.0.0 + */ +class Exception extends \Exception { +} diff --git a/lib/public/TaskProcessing/Exception/NotFoundException.php b/lib/public/TaskProcessing/Exception/NotFoundException.php new file mode 100644 index 0000000000000..ef3eee9009c5b --- /dev/null +++ b/lib/public/TaskProcessing/Exception/NotFoundException.php @@ -0,0 +1,7 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OCP\TaskProcessing\Exception; + +/** + * Exception thrown during processing of a task + * by a synchronous provider + * @since 30.0.0 + */ +class ProcessingException extends \RuntimeException { +} diff --git a/lib/public/TaskProcessing/Exception/ValidationException.php b/lib/public/TaskProcessing/Exception/ValidationException.php new file mode 100644 index 0000000000000..82de81226b45d --- /dev/null +++ b/lib/public/TaskProcessing/Exception/ValidationException.php @@ -0,0 +1,7 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OCP\TaskProcessing; + +use OCP\Files\GenericFileException; +use OCP\Files\NotPermittedException; +use OCP\Lock\LockedException; +use OCP\PreConditionNotMetException; +use OCP\TaskProcessing\Exception\Exception; +use OCP\TaskProcessing\Exception\NotFoundException; +use OCP\TaskProcessing\Exception\ValidationException; + +/** + * API surface for apps interacting with and making use of LanguageModel providers + * without known which providers are installed + * @since 30.0.0 + */ +interface IManager { + /** + * @since 30.0.0 + */ + public function hasProviders(): bool; + + /** + * @return IProvider[] + * @since 30.0.0 + */ + public function getProviders(): array; + + /** + * @return array, optionalInputShape: array, outputShape: array, optionalOutputShape: array}> + * @since 30.0.0 + */ + public function getAvailableTaskTypes(): array; + + /** + * @param Task $task The task to run + * @throws PreConditionNotMetException If no or not the requested provider was registered but this method was still called + * @throws ValidationException the given task input didn't pass validation against the task type's input shape and/or the providers optional input shape specs + * @throws Exception storing the task in the database failed + * @since 30.0.0 + */ + public function scheduleTask(Task $task): void; + + /** + * Delete a task that has been scheduled before + * + * @param Task $task The task to delete + * @throws Exception if deleting the task in the database failed + * @since 30.0.0 + */ + public function deleteTask(Task $task): void; + + /** + * @param int $id The id of the task + * @return Task + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function getTask(int $id): Task; + + /** + * @param int $id The id of the task + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function cancelTask(int $id): void; + + /** + * @param int $id The id of the task + * @param string|null $error + * @param array|null $result + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function setTaskResult(int $id, ?string $error, ?array $result): void; + + /** + * @param int $id + * @param float $progress + * @return bool `true` if the task should still be running; `false` if the task has been cancelled in the meantime + * @throws ValidationException + * @throws Exception + * @throws NotFoundException + */ + public function setTaskProgress(int $id, float $progress): bool; + + /** + * @param string|null $taskTypeId + * @return Task + * @throws Exception If the query failed + * @throws NotFoundException If no task could not be found + * @since 30.0.0 + */ + public function getNextScheduledTask(?string $taskTypeId = null): Task; + + /** + * @param int $id The id of the task + * @param string|null $userId The user id that scheduled the task + * @return Task + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function getUserTask(int $id, ?string $userId): Task; + + /** + * @param string|null $userId + * @param string $appId + * @param string|null $identifier + * @return list + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array; + + /** + * Prepare the task's input data, so it can be processed by the provider + * ie. this replaces file ids with base64 data + * + * @param Task $task + * @return array + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + * @throws ValidationException + */ + public function prepareInputData(Task $task): array; +} diff --git a/lib/public/TaskProcessing/IProvider.php b/lib/public/TaskProcessing/IProvider.php new file mode 100644 index 0000000000000..be6aa33d12572 --- /dev/null +++ b/lib/public/TaskProcessing/IProvider.php @@ -0,0 +1,80 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OCP\TaskProcessing; + +use OCP\TextProcessing\ITaskType; +use RuntimeException; + +/** + * This is the interface that is implemented by apps that + * implement a task processing provider + * @since 30.0.0 + */ +interface IProvider { + /** + * The unique id of this provider + * @since 30.0.0 + */ + public function getId(): string; + + /** + * The localized name of this provider + * @since 30.0.0 + */ + public function getName(): string; + + /** + * Returns the task type id of the task type, that this + * provider handles + * + * @since 30.0.0 + * @return string + */ + public function getTaskType(): string; + + /** + * @return int The expected average runtime of a task in seconds + * @since 30.0.0 + */ + public function getExpectedRuntime(): int; + + /** + * Returns the shape of optional input parameters + * + * @since 30.0.0 + * @psalm-return array{string, ShapeDescriptor} + */ + public function getOptionalInputShape(): array; + + /** + * Returns the shape of optional output parameters + * + * @since 30.0.0 + * @psalm-return array{string, ShapeDescriptor} + */ + public function getOptionalOutputShape(): array; +} diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php new file mode 100644 index 0000000000000..e4fc0b1ea7fe0 --- /dev/null +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -0,0 +1,48 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OCP\TaskProcessing; + +use OCP\TaskProcessing\Exception\ProcessingException; + +/** + * This is the interface that is implemented by apps that + * implement a task processing provider + * @since 30.0.0 + */ +interface ISynchronousProvider extends IProvider { + + /** + * Returns the shape of optional output parameters + * + * @since 30.0.0 + * @param null|string $userId The user that created the current task + * @param array $input The task input + * @psalm-return array + * @throws ProcessingException + */ + public function process(?string $userId, array $input): array; +} diff --git a/lib/public/TaskProcessing/ITaskType.php b/lib/public/TaskProcessing/ITaskType.php new file mode 100644 index 0000000000000..bdac1ec397e7d --- /dev/null +++ b/lib/public/TaskProcessing/ITaskType.php @@ -0,0 +1,73 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing; + +/** + * This is a task type interface that is implemented by task processing + * task types + * @since 30.0.0 + */ +interface ITaskType { + /** + * Returns the unique id of this task type + * + * @since 30.0.0 + * @return string + */ + public function getId(): string; + + /** + * Returns the localized name of this task type + * + * @since 30.0.0 + * @return string + */ + public function getName(): string; + + /** + * Returns the localized description of this task type + * + * @since 30.0.0 + * @return string + */ + public function getDescription(): string; + + /** + * Returns the shape of the input array + * + * @since 30.0.0 + * @psalm-return array{string, ShapeDescriptor} + */ + public function getInputShape(): array; + + /** + * Returns the shape of the output array + * + * @since 30.0.0 + * @psalm-return array{string, ShapeDescriptor} + */ + public function getOutputShape(): array; +} diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php new file mode 100644 index 0000000000000..0c770b7d07e6c --- /dev/null +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -0,0 +1,24 @@ +name; + } + + public function getDescription(): string { + return $this->description; + } + + public function getShapeType(): EShapeType { + return $this->shapeType; + } +} diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php new file mode 100644 index 0000000000000..a467c0d57d038 --- /dev/null +++ b/lib/public/TaskProcessing/Task.php @@ -0,0 +1,263 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing; + +use DateTime; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IImage; +use OCP\Image; +use OCP\TaskProcessing\Exception\ValidationException; + +/** + * This is a task processing task + * + * @since 30.0.0 + */ +final class Task implements \JsonSerializable { + protected ?int $id = null; + + protected ?DateTime $completionExpectedAt = null; + + protected ?array $output = null; + + protected ?string $errorMessage = null; + + protected ?float $progress = null; + + /** + * @since 30.0.0 + */ + public const STATUS_CANCELLED = 5; + /** + * @since 30.0.0 + */ + public const STATUS_FAILED = 4; + /** + * @since 30.0.0 + */ + public const STATUS_SUCCESSFUL = 3; + /** + * @since 30.0.0 + */ + public const STATUS_RUNNING = 2; + /** + * @since 30.0.0 + */ + public const STATUS_SCHEDULED = 1; + /** + * @since 30.0.0 + */ + public const STATUS_UNKNOWN = 0; + + /** + * @psalm-var self::STATUS_* + */ + protected int $status = self::STATUS_UNKNOWN; + + /** + * @param array $input + * @param string $appId + * @param string|null $userId + * @param null|string $identifier An arbitrary identifier for this task. max length: 255 chars + * @since 30.0.0 + */ + final public function __construct( + protected readonly string $taskType, + protected array $input, + protected readonly string $appId, + protected readonly ?string $userId, + protected readonly ?string $identifier = '', + ) { + } + + /** + * @since 30.0.0 + */ + final public function getTaskType(): string { + return $this->taskType; + } + + /** + * @psalm-return self::STATUS_* + * @since 30.0.0 + */ + final public function getStatus(): int { + return $this->status; + } + + /** + * @psalm-param self::STATUS_* $status + * @since 30.0.0 + */ + final public function setStatus(int $status): void { + $this->status = $status; + } + + /** + * @param ?DateTime $at + * @since 30.0.0 + */ + final public function setCompletionExpectedAt(?DateTime $at): void { + $this->completionExpectedAt = $at; + } + + /** + * @return ?DateTime + * @since 30.0.0 + */ + final public function getCompletionExpectedAt(): ?DateTime { + return $this->completionExpectedAt; + } + + /** + * @return int|null + * @since 30.0.0 + */ + final public function getId(): ?int { + return $this->id; + } + + /** + * @param int|null $id + * @since 30.0.0 + */ + final public function setId(?int $id): void { + $this->id = $id; + } + + /** + * @since 30.0.0 + */ + final public function setOutput(?array $output): void { + $this->output = $output; + } + + /** + * @since 30.0.0 + */ + final public function getOutput(): ?array { + return $this->output; + } + + /** + * @return string + * @since 30.0.0 + */ + final public function getInput(): array { + return $this->input; + } + + /** + * @return string + * @since 30.0.0 + */ + final public function getAppId(): string { + return $this->appId; + } + + /** + * @return null|string + * @since 30.0.0 + */ + final public function getIdentifier(): ?string { + return $this->identifier; + } + + /** + * @return string|null + * @since 30.0.0 + */ + final public function getUserId(): ?string { + return $this->userId; + } + + /** + * @psalm-return array{id: ?int, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} + * @since 30.0.0 + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'status' => $this->getStatus(), + 'userId' => $this->getUserId(), + 'appId' => $this->getAppId(), + 'input' => $this->getInput(), + 'output' => $this->getOutput(), + 'identifier' => $this->getIdentifier(), + 'completionExpectedAt' => $this->getCompletionExpectedAt()->getTimestamp(), + 'progress' => $this->getProgress(), + ]; + } + + /** + * @param string|null $error + * @return void + * @since 30.0.0 + */ + public function setErrorMessage(?string $error) { + $this->errorMessage = $error; + } + + /** + * @return string|null + * @since 30.0.0 + */ + public function getErrorMessage(): ?string { + return $this->errorMessage; + } + + /** + * @param array $input + * @return void + * @since 30.0.0 + */ + public function setInput(array $input): void { + $this->input = $input; + } + + /** + * @param float|null $progress + * @return void + * @throws ValidationException + * @since 30.0.0 + */ + public function setProgress(?float $progress): void { + if ($progress < 0 || $progress > 1.0) { + throw new ValidationException('Progress must be between 0.0 and 1.0 inclusively; ' . $progress . ' given'); + } + $this->progress = $progress; + } + + /** + * @return float|null + * @since 30.0.0 + */ + public function getProgress(): ?float { + return $this->progress; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/AudioToText.php b/lib/public/TaskProcessing/TaskTypes/AudioToText.php new file mode 100644 index 0000000000000..c074c1543415f --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/AudioToText.php @@ -0,0 +1,93 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for generic transcription + * @since 30.0.0 + */ +class AudioToText implements ITaskType { + const ID = 'core:audio2text'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Transcribe audio'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Transcribe the things said in an audio'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Audio input'), + $this->l->t('The audio to transcribe'), + EShapeType::Audio + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Transcription'), + $this->l->t('The transcribed text'), + EShapeType::Text + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/TextToImage.php b/lib/public/TaskProcessing/TaskTypes/TextToImage.php new file mode 100644 index 0000000000000..264238afee544 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/TextToImage.php @@ -0,0 +1,98 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for image generation + * @since 30.0.0 + */ +class TextToImage implements ITaskType { + const ID = 'core:text2image'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Generate image'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Generate an image from a text prompt'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Prompt'), + $this->l->t('Describe the image you want to generate'), + EShapeType::Text + ), + 'numberOfImages' => new ShapeDescriptor( + $this->l->t('Number of images'), + $this->l->t('How many images to generate'), + EShapeType::Number + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'images' => new ShapeDescriptor( + $this->l->t('Output images'), + $this->l->t('The generated images'), + EShapeType::ListOfImages + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/TextToText.php b/lib/public/TaskProcessing/TaskTypes/TextToText.php new file mode 100644 index 0000000000000..436c47aa8eea6 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/TextToText.php @@ -0,0 +1,93 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for generic text processing + * @since 30.0.0 + */ +class TextToText implements ITaskType { + const ID = 'core:text2text'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Free text to text prompt'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Runs an arbitrary prompt through a language model that retuns a reply'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Prompt'), + $this->l->t('Describe a task that you want the assistant to do or ask a question'), + EShapeType::Text + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Generated reply'), + $this->l->t('The generated text from the assistant'), + EShapeType::Text + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php b/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php new file mode 100644 index 0000000000000..e524c83fe5505 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php @@ -0,0 +1,93 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for creating headline + * @since 30.0.0 + */ +class TextToTextHeadline implements ITaskType { + const ID = 'core:text2text:headline'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Generate a headline'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Generates a possible headline for a text.'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Original text'), + $this->l->t('The original text to generate a headline for'), + EShapeType::Text + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Headline'), + $this->l->t('The generated headline'), + EShapeType::Text + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php b/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php new file mode 100644 index 0000000000000..4db13b24a2443 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php @@ -0,0 +1,92 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for summaries + * @since 30.0.0 + */ +class TextToTextSummary implements ITaskType { + const ID = 'core:text2text:summary'; + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Summarize'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Summarizes a text'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Original text'), + $this->l->t('The original text to summarize'), + EShapeType::Text + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Summary'), + $this->l->t('The generated summary'), + EShapeType::Text + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php b/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php new file mode 100644 index 0000000000000..f2f0c5c1b7de8 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php @@ -0,0 +1,93 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for topics extraction + * @since 30.0.0 + */ +class TextToTextTopics implements ITaskType { + const ID = 'core:text2text:topics'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Extract topics'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Extracts topics from a text and outputs them separated by commas'); + } + + public function getId(): string { + return self::ID; + } + + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Original text'), + $this->l->t('The original text to extract topics from'), + EShapeType::Text + ), + ]; + } + + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Topics'), + $this->l->t('The list of extracted topics'), + EShapeType::Text + ), + ]; + } +} diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php new file mode 100644 index 0000000000000..65ee5382883fb --- /dev/null +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -0,0 +1,467 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test\TextProcessing; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\AppFramework\Bootstrap\RegistrationContext; +use OC\AppFramework\Bootstrap\ServiceRegistration; +use OC\EventDispatcher\EventDispatcher; +use OC\TaskProcessing\Db\TaskMapper; +use OC\TaskProcessing\Db\Task as DbTask; +use OC\TaskProcessing\Manager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IServerContainer; +use OCP\PreConditionNotMetException; +use OCP\SpeechToText\ISpeechToTextManager; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Events\TaskFailedEvent; +use OCP\TaskProcessing\Events\TaskSuccessfulEvent; +use OCP\TaskProcessing\Exception\ProcessingException; +use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\IManager; +use OCP\TaskProcessing\IProvider; +use OCP\TaskProcessing\ISynchronousProvider; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; +use OCP\TaskProcessing\Task; +use OCP\TaskProcessing\TaskTypes\TextToText; +use PHPUnit\Framework\Constraint\IsInstanceOf; +use Psr\Log\LoggerInterface; +use Test\BackgroundJob\DummyJobList; + +class AudioToImage implements ITaskType { + const ID = 'test:audiotoimage'; + + public function getId(): string { + return self::ID; + } + + public function getName(): string { + return self::class; + } + + public function getDescription(): string { + return self::class; + } + + public function getInputShape(): array { + return [ + 'audio' => new ShapeDescriptor('Audio', 'The audio', EShapeType::Audio), + ]; + } + + public function getOutputShape(): array { + return [ + 'spectrogram' => new ShapeDescriptor('Spectrogram', 'The audio spectrogram', EShapeType::Image), + ]; + } +} + +class AsyncProvider implements IProvider { + public function getId(): string { + return 'test:sync:success'; + } + + public function getName(): string { + return self::class; + } + + public function getTaskType(): string { + return AudioToImage::ID; + } + + public function getExpectedRuntime(): int { + return 10; + } + + public function getOptionalInputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function getOptionalOutputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } +} + +class SuccessfulSyncProvider implements IProvider, ISynchronousProvider { + public function getId(): string { + return 'test:sync:success'; + } + + public function getName(): string { + return self::class; + } + + public function getTaskType(): string { + return TextToText::ID; + } + + public function getExpectedRuntime(): int { + return 10; + } + + public function getOptionalInputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function getOptionalOutputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function process(?string $userId, array $input): array { + return ['output' => $input['input']]; + } +} + +class FailingSyncProvider implements IProvider, ISynchronousProvider { + const ERROR_MESSAGE = 'Failure'; + public function getId(): string { + return 'test:sync:fail'; + } + + public function getName(): string { + return self::class; + } + + public function getTaskType(): string { + return TextToText::ID; + } + + public function getExpectedRuntime(): int { + return 10; + } + + public function getOptionalInputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function getOptionalOutputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function process(?string $userId, array $input): array { + throw new ProcessingException(self::ERROR_MESSAGE); + } +} + +class BrokenSyncProvider implements IProvider, ISynchronousProvider { + public function getId(): string { + return 'test:sync:broken-output'; + } + + public function getName(): string { + return self::class; + } + + public function getTaskType(): string { + return TextToText::ID; + } + + public function getExpectedRuntime(): int { + return 10; + } + + public function getOptionalInputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function getOptionalOutputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function process(?string $userId, array $input): array { + return []; + } +} + +/** + * @group DB + */ +class TaskProcessingTest extends \Test\TestCase { + private IManager $manager; + private Coordinator $coordinator; + private array $providers; + private IServerContainer $serverContainer; + private IEventDispatcher $eventDispatcher; + private RegistrationContext $registrationContext; + private \DateTimeImmutable $currentTime; + private TaskMapper $taskMapper; + private array $tasksDb; + private IJobList $jobList; + private IAppData $appData; + + protected function setUp(): void { + parent::setUp(); + + $this->providers = [ + SuccessfulSyncProvider::class => new SuccessfulSyncProvider(), + FailingSyncProvider::class => new FailingSyncProvider(), + BrokenSyncProvider::class => new BrokenSyncProvider(), + AsyncProvider::class => new AsyncProvider(), + AudioToImage::class => new AudioToImage(), + ]; + + $this->serverContainer = $this->createMock(IServerContainer::class); + $this->serverContainer->expects($this->any())->method('get')->willReturnCallback(function ($class) { + return $this->providers[$class]; + }); + + $this->eventDispatcher = new EventDispatcher( + new \Symfony\Component\EventDispatcher\EventDispatcher(), + $this->serverContainer, + \OC::$server->get(LoggerInterface::class), + ); + + $this->registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator = $this->createMock(Coordinator::class); + $this->coordinator->expects($this->any())->method('getRegistrationContext')->willReturn($this->registrationContext); + + $this->currentTime = new \DateTimeImmutable('now'); + + $this->taskMapper = \OCP\Server::get(TaskMapper::class); + + $this->jobList = $this->createPartialMock(DummyJobList::class, ['add']); + $this->jobList->expects($this->any())->method('add')->willReturnCallback(function () { + }); + + $config = $this->createMock(IConfig::class); + $config->method('getAppValue') + ->with('core', 'ai.textprocessing_provider_preferences', '') + ->willReturn(''); + + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + + $this->manager = new Manager( + $this->coordinator, + $this->serverContainer, + \OC::$server->get(LoggerInterface::class), + $this->taskMapper, + $this->jobList, + $this->eventDispatcher, + \OC::$server->get(IAppDataFactory::class), + \OC::$server->get(IRootFolder::class), + \OC::$server->get(\OCP\TextProcessing\IManager::class), + \OC::$server->get(\OCP\TextToImage\IManager::class), + \OC::$server->get(ISpeechToTextManager::class), + ); + } + + private function getFile(string $name, string $content): \OCP\Files\File { + /** @var IRootFolder $rootFolder */ + $rootFolder = \OC::$server->get(IRootFolder::class); + $this->appData = \OC::$server->get(IAppDataFactory::class)->get('core'); + try { + $folder = $this->appData->getFolder('test'); + } catch (\OCP\Files\NotFoundException $e) { + $folder = $this->appData->newFolder('test'); + } + $file = $folder->newFile($name, $content); + $inputFile = current($rootFolder->getByIdInPath($file->getId(), '/' . $rootFolder->getAppDataDirectoryName() . '/')); + if (!$inputFile instanceof \OCP\Files\File) { + throw new \Exception('PEBCAK'); + } + return $inputFile; + } + + public function testShouldNotHaveAnyProviders() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([]); + self::assertCount(0, $this->manager->getAvailableTaskTypes()); + self::assertFalse($this->manager->hasProviders()); + self::expectException(PreConditionNotMetException::class); + $this->manager->scheduleTask(new Task(TextToText::ID, ['input' => 'Hello'], 'test', null)); + } + + public function testProviderShouldBeRegisteredAndTaskFailValidation() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', BrokenSyncProvider::class) + ]); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['wrongInputKey' => 'Hello'], 'test', null); + self::assertNull($task->getId()); + self::expectException(ValidationException::class); + $this->manager->scheduleTask($task); + } + + public function testProviderShouldBeRegisteredAndFail() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', FailingSyncProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_FAILED, $task->getStatus()); + self::assertEquals(FailingSyncProvider::ERROR_MESSAGE, $task->getErrorMessage()); + } + + public function testProviderShouldBeRegisteredAndFailOutputValidation() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', BrokenSyncProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_FAILED, $task->getStatus()); + self::assertEquals('The task was processed successfully but the provider\'s output doesn\'t pass validation against the task type\'s outputShape spec and/or the provider\'s own optionalOutputShape spec', $task->getErrorMessage()); + } + + public function testProviderShouldBeRegisteredAndRun() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSyncProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $taskTypeStruct = $this->manager->getAvailableTaskTypes()[array_keys($this->manager->getAvailableTaskTypes())[0]]; + $this->assertTrue(isset($taskTypeStruct['inputShape']['input'])); + $this->assertEquals(EShapeType::Text, $taskTypeStruct['inputShape']['input']->getShapeType()); + $this->assertTrue(isset($taskTypeStruct['optionalInputShape']['optionalKey'])); + $this->assertEquals(EShapeType::Text, $taskTypeStruct['optionalInputShape']['optionalKey']->getShapeType()); + $this->assertTrue(isset($taskTypeStruct['outputShape']['output'])); + $this->assertEquals(EShapeType::Text, $taskTypeStruct['outputShape']['output']->getShapeType()); + $this->assertTrue(isset($taskTypeStruct['optionalOutputShape']['optionalKey'])); + $this->assertEquals(EShapeType::Text, $taskTypeStruct['optionalOutputShape']['optionalKey']->getShapeType()); + + $this->assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + // Task object retrieved from db is up-to-date + $task2 = $this->manager->getTask($task->getId()); + self::assertEquals($task->getId(), $task2->getId()); + self::assertEquals(['input' => 'Hello'], $task2->getInput()); + self::assertNull($task2->getOutput()); + self::assertEquals(Task::STATUS_SCHEDULED, $task2->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus(), 'Status is '. $task->getStatus() . ' with error message: ' . $task->getErrorMessage()); + self::assertEquals(['output' => 'Hello'], $task->getOutput()); + self::assertEquals(1, $task->getProgress()); + } + + public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { + $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([ + new ServiceRegistration('test', AudioToImage::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', AsyncProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + + $this->assertTrue($this->manager->hasProviders()); + $audioId = $this->getFile('audioInput', 'Hello')->getId(); + $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + // Task object retrieved from db is up-to-date + $task2 = $this->manager->getTask($task->getId()); + self::assertEquals($task->getId(), $task2->getId()); + self::assertEquals(['audio' => $audioId], $task2->getInput()); + self::assertNull($task2->getOutput()); + self::assertEquals(Task::STATUS_SCHEDULED, $task2->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $this->manager->setTaskProgress($task2->getId(), 0.1); + $input = $this->manager->prepareInputData($task2); + self::assertTrue(isset($input['audio'])); + self::assertEquals(base64_encode('Hello'), $input['audio']); + + $this->manager->setTaskResult($task2->getId(), null, ['spectrogram' => base64_encode('World')]); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); + self::assertEquals(1, $task->getProgress()); + self::assertTrue(isset($task->getOutput()['spectrogram'])); + $root = \OCP\Server::get(IRootFolder::class); + $node = $root->getFirstNodeByIdInPath($task->getOutput()['spectrogram'], '/' . $root->getAppDataDirectoryName() . '/'); + self::assertNotNull($node); + self::assertInstanceOf(\OCP\Files\File::class, $node); + self::assertEquals('World', $node->getContent()); + + } + + public function testNonexistentTask() { + $this->expectException(\OCP\TaskProcessing\Exception\NotFoundException::class); + $this->manager->getTask(2147483646); + } +} From 8352b27c11f78f7359caf28d4ef7175014d7b0d3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 30 Apr 2024 15:48:00 +0200 Subject: [PATCH 02/63] fix: weed out some psalm errors and run cs:fix Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 126 +++++++++--------- lib/public/Files/SimpleFS/ISimpleFile.php | 8 -- lib/public/TaskProcessing/EShapeType.php | 5 +- .../Exception/NotFoundException.php | 3 + .../Exception/ValidationException.php | 3 + lib/public/TaskProcessing/IManager.php | 4 +- lib/public/TaskProcessing/IProvider.php | 7 +- lib/public/TaskProcessing/ITaskType.php | 4 +- lib/public/TaskProcessing/ShapeDescriptor.php | 22 +++ lib/public/TaskProcessing/Task.php | 8 +- .../TaskProcessing/TaskTypes/AudioToText.php | 17 ++- .../TaskProcessing/TaskTypes/TextToImage.php | 17 ++- .../TaskProcessing/TaskTypes/TextToText.php | 17 ++- .../TaskTypes/TextToTextHeadline.php | 17 ++- .../TaskTypes/TextToTextSummary.php | 17 ++- .../TaskTypes/TextToTextTopics.php | 17 ++- .../lib/TaskProcessing/TaskProcessingTest.php | 8 +- 17 files changed, 204 insertions(+), 96 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 08cf9679087f7..9ea92691f2ae9 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -26,6 +26,7 @@ namespace OC\TaskProcessing; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Files\SimpleFS\SimpleFile; use OC\TaskProcessing\Db\TaskMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -33,7 +34,6 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\File; -use OCP\Files\Folder; use OCP\Files\GenericFileException; use OCP\Files\IAppData; use OCP\Files\IRootFolder; @@ -47,7 +47,6 @@ use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; -use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; use OCP\TaskProcessing\Exception\ValidationException; @@ -71,7 +70,7 @@ class Manager implements IManager { public const LEGACY_PREFIX_TEXTTOIMAGE = 'legacy:TextToImage:'; public const LEGACY_PREFIX_SPEECHTOTEXT = 'legacy:SpeechToText:'; - /** @var |null */ + /** @var list|null */ private ?array $providers = null; /** @var array, optionalInputShape: array, outputShape: array, optionalOutputShape: array}>|null */ @@ -281,10 +280,10 @@ public function process(?string $userId, array $input): array { } try { $this->provider->generate($input['input'], $resources); - }catch (\RuntimeException $e) { + } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } - return ['images' => array_map(fn(File $file) => base64_encode($file->getContent()), $files)]; + return ['images' => array_map(fn (File $file) => base64_encode($file->getContent()), $files)]; } }; $newProviders[$newProvider->getId()] = $newProvider; @@ -358,7 +357,7 @@ public function process(?string $userId, array $input): array { } try { $result = $this->provider->transcribeFile($file); - }catch (\RuntimeException $e) { + } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } return ['output' => $result]; @@ -443,7 +442,7 @@ private function _getTaskTypes(): array { * @return IProvider * @throws \OCP\TaskProcessing\Exception\Exception */ - private function _getPreferredProvider(string $taskType){ + private function _getPreferredProvider(string $taskType) { $providers = $this->getProviders(); foreach ($providers as $provider) { if ($provider->getTaskType() === $taskType) { @@ -454,12 +453,12 @@ private function _getPreferredProvider(string $taskType){ } /** - * @param array $spec - * @param array $io + * @param ShapeDescriptor[] $spec + * @param array $io * @return void * @throws ValidationException */ - private function validateInput(array $spec, array $io, bool $optional = false) { + private function validateInput(array $spec, array $io, bool $optional = false): void { foreach ($spec as $key => $descriptor) { $type = $descriptor->getShapeType(); if (!isset($io[$key])) { @@ -471,49 +470,50 @@ private function validateInput(array $spec, array $io, bool $optional = false) { if ($type === EShapeType::Text && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); } - if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); } if ($type === EShapeType::Number && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); } - if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); } if ($type === EShapeType::Image && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '"'); } - if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '"'); } if ($type === EShapeType::Audio && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '"'); } - if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '"'); } if ($type === EShapeType::Video && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '"'); } - if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '"'); } if ($type === EShapeType::File && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '"'); } - if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '"'); } } } /** - * @param array $spec + * @param ShapeDescriptor[] $spec * @param array $io + * @param bool $optional * @return void * @throws ValidationException */ - private function validateOutput(array $spec, array $io, bool $optional = false) { + private function validateOutput(array $spec, array $io, bool $optional = false): void { foreach ($spec as $key => $descriptor) { $type = $descriptor->getShapeType(); if (!isset($io[$key])) { @@ -525,37 +525,37 @@ private function validateOutput(array $spec, array $io, bool $optional = false) if ($type === EShapeType::Text && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); } - if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); } if ($type === EShapeType::Number && !is_numeric($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); } - if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_numeric($item))) > 0)) { + if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); } if ($type === EShapeType::Image && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '". Expecting base64 encoded image data.'); } - if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '". Expecting base64 encoded image data.'); } if ($type === EShapeType::Audio && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '". Expecting base64 encoded audio data.'); } - if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '". Expecting base64 encoded audio data.'); } if ($type === EShapeType::Video && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '". Expecting base64 encoded video data.'); } - if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '". Expecting base64 encoded video data.'); } if ($type === EShapeType::File && !is_string($io[$key])) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '". Expecting base64 encoded file data.'); } - if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn($item) => !is_string($item))) > 0)) { + if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '". Expecting base64 encoded image data.'); } } @@ -567,8 +567,8 @@ private function validateOutput(array $spec, array $io, bool $optional = false) * @return array */ private function removeSuperfluousArrayKeys(array $array, ...$specs): array { - $keys = array_unique(array_reduce($specs, fn($carry, $spec) => $carry + array_keys($spec), [])); - $values = array_map(fn(string $key) => $array[$key], $keys); + $keys = array_unique(array_reduce($specs, fn ($carry, $spec) => $carry + array_keys($spec), [])); + $values = array_map(fn (string $key) => $array[$key], $keys); return array_combine($keys, $values); } @@ -701,7 +701,7 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void { $task->setStatus(Task::STATUS_FAILED); $task->setErrorMessage($error); $this->logger->warning('A TaskProcessing ' . $task->getTaskType() . ' task with id ' . $id . ' failed with the following message: ' . $error); - } else if ($result !== null) { + } elseif ($result !== null) { $taskTypes = $this->getAvailableTaskTypes(); $outputShape = $taskTypes[$task->getTaskType()]['outputShape']; $optionalOutputShape = $taskTypes[$task->getTaskType()]['optionalOutputShape']; @@ -738,7 +738,7 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void { } if ($task->getStatus() === Task::STATUS_SUCCESSFUL) { $event = new TaskSuccessfulEvent($task); - }else{ + } else { $event = new TaskFailedEvent($task, $error); } $this->dispatcher->dispatchTyped($event); @@ -773,7 +773,7 @@ public function getUserTask(int $id, ?string $userId): Task { public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { try { $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); - return array_map(fn($taskEntity) => $taskEntity->toPublicTask(), $taskEntities); + return array_map(fn ($taskEntity) => $taskEntity->toPublicTask(), $taskEntities); } catch (\OCP\DB\Exception $e) { throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); } catch (\JsonException $e) { @@ -784,52 +784,52 @@ public function getUserTasksByApp(?string $userId, string $appId, ?string $ident /** * Takes task input or output data and replaces fileIds with base64 data * - * @param array ...$specs the specs - * @param array $inputOutput - * @return array + * @param ShapeDescriptor[] ...$specs the specs + * @param array $input + * @return array * @throws GenericFileException * @throws LockedException * @throws NotPermittedException * @throws ValidationException - */ - public function fillInputOutputFileData(array $inputOutput, ...$specs): array { + */ + public function fillInputFileData(array $input, ...$specs): array { $newInputOutput = []; - $spec = array_reduce($specs, fn($carry, $spec) => $carry + $spec, []); + $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []); foreach($spec as $key => $descriptor) { $type = $descriptor->getShapeType(); - if (!isset($inputOutput[$key])) { + if (!isset($input[$key])) { continue; } if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { - $newInputOutput[$key] = $inputOutput[$key]; + $newInputOutput[$key] = $input[$key]; continue; } if ($type->value < 10) { - $node = $this->rootFolder->getFirstNodeById((int)$inputOutput[$key]); + $node = $this->rootFolder->getFirstNodeById((int)$input[$key]); if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath((int)$inputOutput[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + $node = $this->rootFolder->getFirstNodeByIdInPath((int)$input[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); if (!$node instanceof File) { throw new ValidationException('File id given for key "' . $key . '" is not a file'); } - } else if (!$node instanceof File) { + } elseif (!$node instanceof File) { throw new ValidationException('File id given for key "' . $key . '" is not a file'); } // TODO: Validate if userId has access to this file - $newInputOutput[$key] = base64_encode($node->getContent()); + $newInputOutput[$key] = $node; } else { $newInputOutput[$key] = []; - foreach ($inputOutput[$key] as $item) { - $node = $this->rootFolder->getFirstNodeById((int)$inputOutput[$key]); + foreach ($input[$key] as $item) { + $node = $this->rootFolder->getFirstNodeById((int)$input[$key]); if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath((int)$inputOutput[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + $node = $this->rootFolder->getFirstNodeByIdInPath((int)$input[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); if (!$node instanceof File) { throw new ValidationException('File id given for key "' . $key . '" is not a file'); } - } else if (!$node instanceof File) { + } elseif (!$node instanceof File) { throw new ValidationException('File id given for key "' . $key . '" is not a file'); } // TODO: Validate if userId has access to this file - $newInputOutput[$key][] = base64_encode($node->getContent()); + $newInputOutput[$key][] = $node; } } } @@ -839,40 +839,42 @@ public function fillInputOutputFileData(array $inputOutput, ...$specs): array { /** *Takes task input or output and replaces base64 data with file ids * - * @param array $inputOutput - * @param array ...$specs the specs that define which keys to keep - * @return array + * @param array $output + * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep + * @return array * @throws NotPermittedException */ - public function encapsulateInputOutputFileData(array $inputOutput, ...$specs): array { - $newInputOutput = []; + public function encapsulateOutputFileData(array $output, ...$specs): array { + $newOutput = []; try { $folder = $this->appData->getFolder('TaskProcessing'); } catch (\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('TaskProcessing'); } - $spec = array_reduce($specs, fn($carry, $spec) => $carry + $spec, []); + $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []); foreach($spec as $key => $descriptor) { $type = $descriptor->getShapeType(); - if (!isset($inputOutput[$key])) { + if (!isset($output[$key])) { continue; } if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { - $newInputOutput[$key] = $inputOutput[$key]; + $newOutput[$key] = $output[$key]; continue; } if ($type->value < 10) { - $file = $folder->newFile((string) rand(0, 10000000), base64_decode($inputOutput[$key])); - $newInputOutput[$key] = $file->getId(); + /** @var SimpleFile $file */ + $file = $folder->newFile((string) rand(0, 10000000), $output[$key]); + $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile } else { - $newInputOutput = []; - foreach ($inputOutput[$key] as $item) { - $file = $folder->newFile((string) rand(0, 10000000), base64_decode($item)); - $newInputOutput[$key][] = $file->getId(); + $newOutput = []; + foreach ($output[$key] as $item) { + /** @var SimpleFile $file */ + $file = $folder->newFile((string) rand(0, 10000000), $item); + $newOutput[$key][] = $file->getId(); } } } - return $newInputOutput; + return $newOutput; } public function prepareInputData(Task $task): array { @@ -884,7 +886,7 @@ public function prepareInputData(Task $task): array { $this->validateInput($inputShape, $input); $this->validateInput($optionalInputShape, $input, true); $input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape); - $input = $this->fillInputOutputFileData($input, $inputShape, $optionalInputShape); + $input = $this->fillInputFileData($input, $inputShape, $optionalInputShape); return $input; } } diff --git a/lib/public/Files/SimpleFS/ISimpleFile.php b/lib/public/Files/SimpleFS/ISimpleFile.php index cf848d33724ed..8afc310883660 100644 --- a/lib/public/Files/SimpleFS/ISimpleFile.php +++ b/lib/public/Files/SimpleFS/ISimpleFile.php @@ -121,12 +121,4 @@ public function read(); * @since 14.0.0 */ public function write(); - - /** - * Returns the file id - * - * @return int - * @since 30.0.0 - */ - public function getId(): int; } diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 514451da0685b..3da7391daa1b8 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -25,6 +25,10 @@ namespace OCP\TaskProcessing; +/** + * The input and output Shape types + * @since 30.0.0 + */ enum EShapeType: int { case Number = 0; case Text = 1; @@ -39,4 +43,3 @@ enum EShapeType: int { case ListOfVideo = 14; case ListOfFiles = 15; } - diff --git a/lib/public/TaskProcessing/Exception/NotFoundException.php b/lib/public/TaskProcessing/Exception/NotFoundException.php index ef3eee9009c5b..9c8636051eab1 100644 --- a/lib/public/TaskProcessing/Exception/NotFoundException.php +++ b/lib/public/TaskProcessing/Exception/NotFoundException.php @@ -2,6 +2,9 @@ namespace OCP\TaskProcessing\Exception; +/** + * @since 30.0.0 + */ class NotFoundException extends Exception { } diff --git a/lib/public/TaskProcessing/Exception/ValidationException.php b/lib/public/TaskProcessing/Exception/ValidationException.php index 82de81226b45d..b17da89919dc9 100644 --- a/lib/public/TaskProcessing/Exception/ValidationException.php +++ b/lib/public/TaskProcessing/Exception/ValidationException.php @@ -2,6 +2,9 @@ namespace OCP\TaskProcessing\Exception; +/** + * @since 30.0.0 + */ class ValidationException extends Exception { } diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 73e4c85701e22..11b969ec37975 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -52,7 +52,7 @@ public function hasProviders(): bool; public function getProviders(): array; /** - * @return array, optionalInputShape: array, outputShape: array, optionalOutputShape: array}> + * @return array, outputShape: array, optionalOutputShape: array}> * @since 30.0.0 */ public function getAvailableTaskTypes(): array; @@ -109,6 +109,7 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void; * @throws ValidationException * @throws Exception * @throws NotFoundException + * @since 30.0.0 */ public function setTaskProgress(int $id, float $progress): bool; @@ -152,6 +153,7 @@ public function getUserTasksByApp(?string $userId, string $appId, ?string $ident * @throws GenericFileException * @throws LockedException * @throws ValidationException + * @since 30.0.0 */ public function prepareInputData(Task $task): array; } diff --git a/lib/public/TaskProcessing/IProvider.php b/lib/public/TaskProcessing/IProvider.php index be6aa33d12572..5768294fd96ee 100644 --- a/lib/public/TaskProcessing/IProvider.php +++ b/lib/public/TaskProcessing/IProvider.php @@ -26,9 +26,6 @@ namespace OCP\TaskProcessing; -use OCP\TextProcessing\ITaskType; -use RuntimeException; - /** * This is the interface that is implemented by apps that * implement a task processing provider @@ -66,7 +63,7 @@ public function getExpectedRuntime(): int; * Returns the shape of optional input parameters * * @since 30.0.0 - * @psalm-return array{string, ShapeDescriptor} + * @psalm-return ShapeDescriptor[] */ public function getOptionalInputShape(): array; @@ -74,7 +71,7 @@ public function getOptionalInputShape(): array; * Returns the shape of optional output parameters * * @since 30.0.0 - * @psalm-return array{string, ShapeDescriptor} + * @psalm-return ShapeDescriptor[] */ public function getOptionalOutputShape(): array; } diff --git a/lib/public/TaskProcessing/ITaskType.php b/lib/public/TaskProcessing/ITaskType.php index bdac1ec397e7d..a1c6f002433a5 100644 --- a/lib/public/TaskProcessing/ITaskType.php +++ b/lib/public/TaskProcessing/ITaskType.php @@ -59,7 +59,7 @@ public function getDescription(): string; * Returns the shape of the input array * * @since 30.0.0 - * @psalm-return array{string, ShapeDescriptor} + * @psalm-return ShapeDescriptor[] */ public function getInputShape(): array; @@ -67,7 +67,7 @@ public function getInputShape(): array; * Returns the shape of the output array * * @since 30.0.0 - * @psalm-return array{string, ShapeDescriptor} + * @psalm-return ShapeDescriptor[] */ public function getOutputShape(): array; } diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php index 0c770b7d07e6c..58d4b5d8e7f5d 100644 --- a/lib/public/TaskProcessing/ShapeDescriptor.php +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -2,7 +2,17 @@ namespace OCP\TaskProcessing; +/** + * Data object for input output shape entries + * @since 30.0.0 + */ class ShapeDescriptor { + /** + * @param string $name + * @param string $description + * @param EShapeType $shapeType + * @since 30.0.0 + */ public function __construct( private string $name, private string $description, @@ -10,14 +20,26 @@ public function __construct( ) { } + /** + * @return string + * @since 30.0.0 + */ public function getName(): string { return $this->name; } + /** + * @return string + * @since 30.0.0 + */ public function getDescription(): string { return $this->description; } + /** + * @return EShapeType + * @since 30.0.0 + */ public function getShapeType(): EShapeType { return $this->shapeType; } diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index a467c0d57d038..71b5dae84cab6 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -26,11 +26,6 @@ namespace OCP\TaskProcessing; use DateTime; -use OCP\Files\AppData\IAppDataFactory; -use OCP\Files\NotFoundException; -use OCP\Files\NotPermittedException; -use OCP\IImage; -use OCP\Image; use OCP\TaskProcessing\Exception\ValidationException; /** @@ -158,6 +153,7 @@ final public function setOutput(?array $output): void { } /** + * @return array|null * @since 30.0.0 */ final public function getOutput(): ?array { @@ -165,7 +161,7 @@ final public function getOutput(): ?array { } /** - * @return string + * @return array * @since 30.0.0 */ final public function getInput(): array { diff --git a/lib/public/TaskProcessing/TaskTypes/AudioToText.php b/lib/public/TaskProcessing/TaskTypes/AudioToText.php index c074c1543415f..604b99729a460 100644 --- a/lib/public/TaskProcessing/TaskTypes/AudioToText.php +++ b/lib/public/TaskProcessing/TaskTypes/AudioToText.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class AudioToText implements ITaskType { - const ID = 'core:audio2text'; + /** + * @since 30.0.0 + */ + public const ID = 'core:audio2text'; private IL10N $l; @@ -67,10 +70,18 @@ public function getDescription(): string { return $this->l->t('Transcribe the things said in an audio'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -81,6 +92,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'output' => new ShapeDescriptor( diff --git a/lib/public/TaskProcessing/TaskTypes/TextToImage.php b/lib/public/TaskProcessing/TaskTypes/TextToImage.php index 264238afee544..0b52524b7d3df 100644 --- a/lib/public/TaskProcessing/TaskTypes/TextToImage.php +++ b/lib/public/TaskProcessing/TaskTypes/TextToImage.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class TextToImage implements ITaskType { - const ID = 'core:text2image'; + /** + * @since 30.0.0 + */ + public const ID = 'core:text2image'; private IL10N $l; @@ -67,10 +70,18 @@ public function getDescription(): string { return $this->l->t('Generate an image from a text prompt'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -86,6 +97,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'images' => new ShapeDescriptor( diff --git a/lib/public/TaskProcessing/TaskTypes/TextToText.php b/lib/public/TaskProcessing/TaskTypes/TextToText.php index 436c47aa8eea6..76cc5d2781d8d 100644 --- a/lib/public/TaskProcessing/TaskTypes/TextToText.php +++ b/lib/public/TaskProcessing/TaskTypes/TextToText.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class TextToText implements ITaskType { - const ID = 'core:text2text'; + /** + * @since 30.0.0 + */ + public const ID = 'core:text2text'; private IL10N $l; @@ -67,10 +70,18 @@ public function getDescription(): string { return $this->l->t('Runs an arbitrary prompt through a language model that retuns a reply'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -81,6 +92,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'output' => new ShapeDescriptor( diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php b/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php index e524c83fe5505..219f6272cc9ed 100644 --- a/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class TextToTextHeadline implements ITaskType { - const ID = 'core:text2text:headline'; + /** + * @since 30.0.0 + */ + public const ID = 'core:text2text:headline'; private IL10N $l; @@ -67,10 +70,18 @@ public function getDescription(): string { return $this->l->t('Generates a possible headline for a text.'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -81,6 +92,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'output' => new ShapeDescriptor( diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php b/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php index 4db13b24a2443..7564ed7cd0b2a 100644 --- a/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class TextToTextSummary implements ITaskType { - const ID = 'core:text2text:summary'; + /** + * @since 30.0.0 + */ + public const ID = 'core:text2text:summary'; private IL10N $l; /** @@ -66,10 +69,18 @@ public function getDescription(): string { return $this->l->t('Summarizes a text'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -80,6 +91,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'output' => new ShapeDescriptor( diff --git a/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php b/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php index f2f0c5c1b7de8..b0376968c2400 100644 --- a/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php +++ b/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php @@ -36,7 +36,10 @@ * @since 30.0.0 */ class TextToTextTopics implements ITaskType { - const ID = 'core:text2text:topics'; + /** + * @since 30.0.0 + */ + public const ID = 'core:text2text:topics'; private IL10N $l; @@ -67,10 +70,18 @@ public function getDescription(): string { return $this->l->t('Extracts topics from a text and outputs them separated by commas'); } + /** + * @return string + * @since 30.0.0 + */ public function getId(): string { return self::ID; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getInputShape(): array { return [ 'input' => new ShapeDescriptor( @@ -81,6 +92,10 @@ public function getInputShape(): array { ]; } + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ public function getOutputShape(): array { return [ 'output' => new ShapeDescriptor( diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 65ee5382883fb..01bb0253853bf 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -13,9 +13,7 @@ use OC\AppFramework\Bootstrap\ServiceRegistration; use OC\EventDispatcher\EventDispatcher; use OC\TaskProcessing\Db\TaskMapper; -use OC\TaskProcessing\Db\Task as DbTask; use OC\TaskProcessing\Manager; -use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\IEventDispatcher; @@ -43,7 +41,7 @@ use Test\BackgroundJob\DummyJobList; class AudioToImage implements ITaskType { - const ID = 'test:audiotoimage'; + public const ID = 'test:audiotoimage'; public function getId(): string { return self::ID; @@ -135,7 +133,7 @@ public function process(?string $userId, array $input): array { } class FailingSyncProvider implements IProvider, ISynchronousProvider { - const ERROR_MESSAGE = 'Failure'; + public const ERROR_MESSAGE = 'Failure'; public function getId(): string { return 'test:sync:fail'; } @@ -258,7 +256,7 @@ protected function setUp(): void { ->with('core', 'ai.textprocessing_provider_preferences', '') ->willReturn(''); - $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->manager = new Manager( $this->coordinator, From 4b2acee64be7331061f88c9b2443fa74edd488d4 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 30 Apr 2024 16:30:24 +0200 Subject: [PATCH 03/63] test: Add OldTasksShouldBeCleanedUp test Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Db/TaskMapper.php | 2 +- lib/private/TaskProcessing/Manager.php | 2 +- .../RemoveOldTasksBackgroundJob.php | 46 ++++++++++ .../lib/TaskProcessing/TaskProcessingTest.php | 91 ++++++++++++++----- 4 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php diff --git a/lib/private/TaskProcessing/Db/TaskMapper.php b/lib/private/TaskProcessing/Db/TaskMapper.php index a1cc3d1409a14..f8a1adc695c4a 100644 --- a/lib/private/TaskProcessing/Db/TaskMapper.php +++ b/lib/private/TaskProcessing/Db/TaskMapper.php @@ -127,7 +127,7 @@ public function findUserTasksByApp(string $userId, string $appId, ?string $ident public function deleteOlderThan(int $timeout): int { $qb = $this->db->getQueryBuilder(); $qb->delete($this->tableName) - ->where($qb->expr()->lt('last_updated', $qb->createPositionalParameter(time() - $timeout))); + ->where($qb->expr()->lt('last_updated', $qb->createPositionalParameter($this->timeFactory->getDateTime()->getTimestamp() - $timeout))); return $qb->executeStatement(); } diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 9ea92691f2ae9..4cc2119f299a8 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -711,7 +711,7 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void { $this->validateOutput($optionalOutputShape, $result, true); $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape); // extract base64 data and put it in files, replace it with file ids - $output = $this->encapsulateInputOutputFileData($output, $outputShape, $optionalOutputShape); + $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape); $task->setOutput($output); $task->setProgress(1); $task->setStatus(Task::STATUS_SUCCESSFUL); diff --git a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php new file mode 100644 index 0000000000000..7678641205934 --- /dev/null +++ b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php @@ -0,0 +1,46 @@ +setInterval(60 * 60 * 24); + // can be deferred to maintenance window + $this->setTimeSensitivity(TimedJob::TIME_INSENSITIVE); + } + + + /** + * @inheritDoc + */ + protected function run($argument) { + try { + $this->taskMapper->deleteOlderThan(self::MAX_TASK_AGE_SECONDS); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to delete stale language model tasks', ['exception' => $e]); + } + } +} diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 01bb0253853bf..e1ddaf8250060 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -14,6 +14,7 @@ use OC\EventDispatcher\EventDispatcher; use OC\TaskProcessing\Db\TaskMapper; use OC\TaskProcessing\Manager; +use OC\TaskProcessing\RemoveOldTasksBackgroundJob; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\IEventDispatcher; @@ -21,12 +22,14 @@ use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\IConfig; +use OCP\IDBConnection; use OCP\IServerContainer; use OCP\PreConditionNotMetException; use OCP\SpeechToText\ISpeechToTextManager; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; +use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; @@ -211,9 +214,7 @@ class TaskProcessingTest extends \Test\TestCase { private IServerContainer $serverContainer; private IEventDispatcher $eventDispatcher; private RegistrationContext $registrationContext; - private \DateTimeImmutable $currentTime; private TaskMapper $taskMapper; - private array $tasksDb; private IJobList $jobList; private IAppData $appData; @@ -243,8 +244,6 @@ protected function setUp(): void { $this->coordinator = $this->createMock(Coordinator::class); $this->coordinator->expects($this->any())->method('getRegistrationContext')->willReturn($this->registrationContext); - $this->currentTime = new \DateTimeImmutable('now'); - $this->taskMapper = \OCP\Server::get(TaskMapper::class); $this->jobList = $this->createPartialMock(DummyJobList::class, ['add']); @@ -314,8 +313,8 @@ public function testProviderShouldBeRegisteredAndFail() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', FailingSyncProvider::class) ]); - $this->assertCount(1, $this->manager->getAvailableTaskTypes()); - $this->assertTrue($this->manager->hasProviders()); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertTrue($this->manager->hasProviders()); $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); self::assertNull($task->getId()); self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); @@ -342,8 +341,8 @@ public function testProviderShouldBeRegisteredAndFailOutputValidation() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', BrokenSyncProvider::class) ]); - $this->assertCount(1, $this->manager->getAvailableTaskTypes()); - $this->assertTrue($this->manager->hasProviders()); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertTrue($this->manager->hasProviders()); $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); self::assertNull($task->getId()); self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); @@ -370,18 +369,18 @@ public function testProviderShouldBeRegisteredAndRun() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', SuccessfulSyncProvider::class) ]); - $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); $taskTypeStruct = $this->manager->getAvailableTaskTypes()[array_keys($this->manager->getAvailableTaskTypes())[0]]; - $this->assertTrue(isset($taskTypeStruct['inputShape']['input'])); - $this->assertEquals(EShapeType::Text, $taskTypeStruct['inputShape']['input']->getShapeType()); - $this->assertTrue(isset($taskTypeStruct['optionalInputShape']['optionalKey'])); - $this->assertEquals(EShapeType::Text, $taskTypeStruct['optionalInputShape']['optionalKey']->getShapeType()); - $this->assertTrue(isset($taskTypeStruct['outputShape']['output'])); - $this->assertEquals(EShapeType::Text, $taskTypeStruct['outputShape']['output']->getShapeType()); - $this->assertTrue(isset($taskTypeStruct['optionalOutputShape']['optionalKey'])); - $this->assertEquals(EShapeType::Text, $taskTypeStruct['optionalOutputShape']['optionalKey']->getShapeType()); - - $this->assertTrue($this->manager->hasProviders()); + self::assertTrue(isset($taskTypeStruct['inputShape']['input'])); + self::assertEquals(EShapeType::Text, $taskTypeStruct['inputShape']['input']->getShapeType()); + self::assertTrue(isset($taskTypeStruct['optionalInputShape']['optionalKey'])); + self::assertEquals(EShapeType::Text, $taskTypeStruct['optionalInputShape']['optionalKey']->getShapeType()); + self::assertTrue(isset($taskTypeStruct['outputShape']['output'])); + self::assertEquals(EShapeType::Text, $taskTypeStruct['outputShape']['output']->getShapeType()); + self::assertTrue(isset($taskTypeStruct['optionalOutputShape']['optionalKey'])); + self::assertEquals(EShapeType::Text, $taskTypeStruct['optionalOutputShape']['optionalKey']->getShapeType()); + + self::assertTrue($this->manager->hasProviders()); $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); self::assertNull($task->getId()); self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); @@ -419,9 +418,9 @@ public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', AsyncProvider::class) ]); - $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); - $this->assertTrue($this->manager->hasProviders()); + self::assertTrue($this->manager->hasProviders()); $audioId = $this->getFile('audioInput', 'Hello')->getId(); $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', null); self::assertNull($task->getId()); @@ -442,9 +441,10 @@ public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { $this->manager->setTaskProgress($task2->getId(), 0.1); $input = $this->manager->prepareInputData($task2); self::assertTrue(isset($input['audio'])); - self::assertEquals(base64_encode('Hello'), $input['audio']); + self::assertInstanceOf(\OCP\Files\File::class, $input['audio']); + self::assertEquals($audioId, $input['audio']->getId()); - $this->manager->setTaskResult($task2->getId(), null, ['spectrogram' => base64_encode('World')]); + $this->manager->setTaskResult($task2->getId(), null, ['spectrogram' => 'World']); $task = $this->manager->getTask($task->getId()); self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); @@ -462,4 +462,49 @@ public function testNonexistentTask() { $this->expectException(\OCP\TaskProcessing\Exception\NotFoundException::class); $this->manager->getTask(2147483646); } + + public function testOldTasksShouldBeCleanedUp() { + $currentTime = new \DateTime('now'); + $timeFactory = $this->createMock(ITimeFactory::class); + $timeFactory->expects($this->any())->method('getDateTime')->willReturnCallback(fn() => $currentTime); + $timeFactory->expects($this->any())->method('getTime')->willReturnCallback(fn() => $currentTime->getTimestamp()); + + $this->taskMapper = new TaskMapper( + \OCP\Server::get(IDBConnection::class), + $timeFactory, + ); + + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSyncProvider::class) + ]); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); + $this->manager->scheduleTask($task); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + + $currentTime = $currentTime->add(new \DateInterval('P1Y')); + // run background job + $bgJob = new RemoveOldTasksBackgroundJob( + $timeFactory, + $this->taskMapper, + \OC::$server->get(LoggerInterface::class), + ); + $bgJob->setArgument([]); + $bgJob->start($this->jobList); + + $this->expectException(NotFoundException::class); + $this->manager->getTask($task->getId()); + } } From 17486ad15ba1575d7e31d8d4195a86cd9ef450b0 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 30 Apr 2024 16:37:13 +0200 Subject: [PATCH 04/63] fix: Add RemoveOldTasksBackgroundJob to repair step that instantiates it Signed-off-by: Marcel Klehr --- lib/private/Repair/AddRemoveOldTasksBackgroundJob.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php index 00badbb726dd6..62918dbae5fb3 100644 --- a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php +++ b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php @@ -27,6 +27,7 @@ use OC\TextProcessing\RemoveOldTasksBackgroundJob as RemoveOldTextProcessingTasksBackgroundJob; use OC\TextToImage\RemoveOldTasksBackgroundJob as RemoveOldTextToImageTasksBackgroundJob; +use OC\TaskProcessing\RemoveOldTasksBackgroundJob; use OCP\BackgroundJob\IJobList; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; @@ -39,11 +40,12 @@ public function __construct(IJobList $jobList) { } public function getName(): string { - return 'Add AI tasks cleanup job'; + return 'Add AI tasks cleanup jobs'; } public function run(IOutput $output) { $this->jobList->add(RemoveOldTextProcessingTasksBackgroundJob::class); $this->jobList->add(RemoveOldTextToImageTasksBackgroundJob::class); + $this->jobList->add(RemoveOldTasksBackgroundJob::class); } } From ee7592ffdd67d8ac24b8f3d3b3f87b8b28932d5b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 30 Apr 2024 17:01:41 +0200 Subject: [PATCH 05/63] fix: Run cs:fix Signed-off-by: Marcel Klehr --- .../Repair/AddRemoveOldTasksBackgroundJob.php | 2 +- .../TaskProcessing/RemoveOldTasksBackgroundJob.php | 11 ----------- lib/public/TaskProcessing/ShapeDescriptor.php | 13 ++++++++++++- tests/lib/TaskProcessing/TaskProcessingTest.php | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php index 62918dbae5fb3..fb159bdfb9c3d 100644 --- a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php +++ b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php @@ -25,9 +25,9 @@ */ namespace OC\Repair; +use OC\TaskProcessing\RemoveOldTasksBackgroundJob; use OC\TextProcessing\RemoveOldTasksBackgroundJob as RemoveOldTextProcessingTasksBackgroundJob; use OC\TextToImage\RemoveOldTasksBackgroundJob as RemoveOldTextToImageTasksBackgroundJob; -use OC\TaskProcessing\RemoveOldTasksBackgroundJob; use OCP\BackgroundJob\IJobList; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php index 7678641205934..54b63ac42fbab 100644 --- a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php +++ b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php @@ -4,18 +4,7 @@ use OC\TaskProcessing\Db\TaskMapper; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJobList; -use OCP\BackgroundJob\QueuedJob; use OCP\BackgroundJob\TimedJob; -use OCP\Files\GenericFileException; -use OCP\Files\NotPermittedException; -use OCP\Lock\LockedException; -use OCP\TaskProcessing\Exception\Exception; -use OCP\TaskProcessing\Exception\NotFoundException; -use OCP\TaskProcessing\Exception\ProcessingException; -use OCP\TaskProcessing\Exception\ValidationException; -use OCP\TaskProcessing\IManager; -use OCP\TaskProcessing\ISynchronousProvider; use Psr\Log\LoggerInterface; class RemoveOldTasksBackgroundJob extends TimedJob { diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php index 58d4b5d8e7f5d..c84a638862dc7 100644 --- a/lib/public/TaskProcessing/ShapeDescriptor.php +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -6,7 +6,7 @@ * Data object for input output shape entries * @since 30.0.0 */ -class ShapeDescriptor { +class ShapeDescriptor implements \JsonSerializable { /** * @param string $name * @param string $description @@ -43,4 +43,15 @@ public function getDescription(): string { public function getShapeType(): EShapeType { return $this->shapeType; } + + /** + * @return array{name: string, description: string, type: int} + */ + public function jsonSerialize(): array { + return [ + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'type' => $this->getShapeType()->value, + ]; + } } diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index e1ddaf8250060..ddf64f23173e1 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -466,8 +466,8 @@ public function testNonexistentTask() { public function testOldTasksShouldBeCleanedUp() { $currentTime = new \DateTime('now'); $timeFactory = $this->createMock(ITimeFactory::class); - $timeFactory->expects($this->any())->method('getDateTime')->willReturnCallback(fn() => $currentTime); - $timeFactory->expects($this->any())->method('getTime')->willReturnCallback(fn() => $currentTime->getTimestamp()); + $timeFactory->expects($this->any())->method('getDateTime')->willReturnCallback(fn () => $currentTime); + $timeFactory->expects($this->any())->method('getTime')->willReturnCallback(fn () => $currentTime->getTimestamp()); $this->taskMapper = new TaskMapper( \OCP\Server::get(IDBConnection::class), From 44b896f999fb1a8ed1b6c5bdfd4db6ca01f7b193 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 30 Apr 2024 17:02:38 +0200 Subject: [PATCH 06/63] feat: TaskProcessing OCS API Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 214 ++++++++++++++++++ core/ResponseDefinitions.php | 28 +++ 2 files changed, 242 insertions(+) create mode 100644 core/Controller/TaskProcessingApiController.php diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php new file mode 100644 index 0000000000000..25417cb0d5faf --- /dev/null +++ b/core/Controller/TaskProcessingApiController.php @@ -0,0 +1,214 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace OC\Core\Controller; + +use OCA\Core\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\Common\Exception\NotFoundException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\PreConditionNotMetException; +use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\ShapeDescriptor; +use OCP\TaskProcessing\Task; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * @psalm-import-type CoreTaskProcessingTask from ResponseDefinitions + * @psalm-import-type CoreTaskProcessingTaskType from ResponseDefinitions + */ +class TaskProcessingApiController extends \OCP\AppFramework\OCSController { + public function __construct( + string $appName, + IRequest $request, + private \OCP\TaskProcessing\IManager $taskProcessingManager, + private IL10N $l, + private ?string $userId, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * This endpoint returns all available TaskProcessing task types + * + * @return DataResponse}> + * []}, array{}> + * + * 200: Task types returned + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/taskprocessing')] + public function taskTypes(): DataResponse { + $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); + + /** @var string $typeClass */ + foreach ($taskTypes as $taskType) { + $taskType['inputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['inputShape']); + $taskType['optionalInputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalInputShape']); + $taskType['outputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['outputShape']); + $taskType['optionalOutputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalOutputShape']); + } + + return new DataResponse([ + 'types' => $taskTypes, + ]); + } + + /** + * This endpoint allows scheduling a task + * + * @param string $input Input text + * @param string $type Type of the task + * @param string $appId ID of the app that will execute the task + * @param string $identifier An arbitrary identifier for the task + * + * @return DataResponse|DataResponse + * + * 200: Task scheduled successfully + * 400: Scheduling task is not possible + * 412: Scheduling task is not possible + */ + #[PublicPage] + #[UserRateLimit(limit: 20, period: 120)] + #[AnonRateLimit(limit: 5, period: 120)] + #[ApiRoute(verb: 'POST', url: '/schedule', root: '/taskprocessing')] + public function schedule(array $input, string $type, string $appId, string $identifier = ''): DataResponse { + $task = new Task($type, $input, $appId, $this->userId, $identifier); + try { + $this->taskProcessingManager->scheduleTask($task); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l->t('The given provider is not available')], Http::STATUS_PRECONDITION_FAILED); + } catch (ValidationException $e) { + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows checking the status and results of a task. + * Tasks are removed 1 week after receiving their last update. + * + * @param int $id The id of the task + * + * @return DataResponse|DataResponse + * + * 200: Task returned + * 404: Task not found + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/taskprocessing')] + public function getTask(int $id): DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($id, $this->userId); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint allows to delete a scheduled task for a user + * + * @param int $id The id of the task + * + * @return DataResponse|DataResponse + * + * 200: Task returned + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/taskprocessing')] + public function deleteTask(int $id): DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($id, $this->userId); + + $this->taskProcessingManager->deleteTask($task); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + + /** + * This endpoint returns a list of tasks of a user that are related + * with a specific appId and optionally with an identifier + * + * @param string $appId ID of the app + * @param string|null $identifier An arbitrary identifier for the task + * @return DataResponse|DataResponse + * + * 200: Task list returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/taskprocessing')] + public function listTasksByApp(string $appId, ?string $identifier = null): DataResponse { + try { + $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $identifier); + /** @var CoreTaskProcessingTask[] $json */ + $json = array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 4a79c3ad3ec85..414b47c159a52 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -176,6 +176,34 @@ * iconURL: ?string, * iconEmoji: ?string, * } + * + * @psalm-type CoreTaskProcessingShape = array{ + * name: string, + * description: string, + * type: int + * } + * + * @psalm-type CoreTaskProcessingTaskType = array{ + * name: string, + * description: string, + * inputShape: CoreTaskProcessingShape[], + * optionalInputShape: CoreTaskProcessingShape[], + * outputShape: CoreTaskProcessingShape[], + * optionalOutputShape: CoreTaskProcessingShape[], + * } + * + * @psalm-type CoreTaskProcessingTask = array{ + * id: ?int, + * status: int, + * userId: ?string, + * appId: string, + * input: array, + * output: ?array, + * identifier: ?string, + * completionExpectedAt: ?int, + * progress: ?float + * } + * */ class ResponseDefinitions { } From b2b93e4219c7912ae8abf59c3d7ba4e54d064201 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 11:13:53 +0200 Subject: [PATCH 07/63] feat: Add getFileContents endpoint to TaskProcessing OCS API Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 86 +++++++++++++++++-- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 25417cb0d5faf..6cadf9cbceea9 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -27,17 +27,26 @@ namespace OC\Core\Controller; use OCA\Core\ResponseDefinitions; +use OCA\User_LDAP\Handler\ExtStorageConfigHandler; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\Common\Exception\NotFoundException; +use OCP\Files\File; +use OCP\Files\GenericFileException; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; +use OCP\Lock\LockedException; use OCP\PreConditionNotMetException; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\ShapeDescriptor; use OCP\TaskProcessing\Task; @@ -50,13 +59,12 @@ */ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { public function __construct( - string $appName, - IRequest $request, - private \OCP\TaskProcessing\IManager $taskProcessingManager, - private IL10N $l, - private ?string $userId, - private ContainerInterface $container, - private LoggerInterface $logger, + string $appName, + IRequest $request, + private \OCP\TaskProcessing\IManager $taskProcessingManager, + private IL10N $l, + private ?string $userId, + private IRootFolder $rootFolder, ) { parent::__construct($appName, $request); } @@ -207,8 +215,70 @@ public function listTasksByApp(string $appId, ?string $identifier = null): DataR return new DataResponse([ 'tasks' => $json, ]); - } catch (\RuntimeException $e) { + } catch (Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\JsonException $e) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * This endpoint returns the contents of a file referenced in a task + * + * @param int $taskId + * @param int $fileId + * @return DataDownloadResponse|DataResponse + * + * 200: File content returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] + public function getFileContents(int $taskId, int $fileId): Http\DataDownloadResponse|DataResponse { + try { + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + $ids = $this->extractFileIdsFromTask($task); + if (!in_array($fileId, $ids)) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } + $node = $this->rootFolder->getFirstNodeById($fileId); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if (!$node instanceof File) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Node is not a file'); + } + } elseif (!$node instanceof File) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Node is not a file'); + } + return new Http\DataDownloadResponse($node->getContent(), $node->getName(), $node->getMimeType()); + } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (GenericFileException|NotPermittedException|LockedException|Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @param Task $task + * @return array + * @throws \OCP\TaskProcessing\Exception\NotFoundException + */ + private function extractFileIdsFromTask(Task $task) { + $ids = []; + $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); + if (!isset($taskTypes[$task->getTaskType()])) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find task type'); + } + $taskType = $taskTypes[$task->getTaskType()]; + foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + $ids[] = $task->getInput()[$key]; + } + } + foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + $ids[] = $task->getOutput()[$key]; + } + } + return $ids; + } } From 86317bbf4dfd5302a1db286f0972ad2263907f3c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 11:15:51 +0200 Subject: [PATCH 08/63] refactor: Move validation to EShapeType Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 86 ++++------------------ lib/public/TaskProcessing/EShapeType.php | 91 ++++++++++++++++++++++++ lib/public/TaskProcessing/IManager.php | 2 +- 3 files changed, 104 insertions(+), 75 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 4cc2119f299a8..65307d4e43521 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -465,43 +465,12 @@ private function validateInput(array $spec, array $io, bool $optional = false): if ($optional) { continue; } - throw new \OCP\TaskProcessing\Exception\ValidationException('Missing key: "' . $key . '"'); + throw new ValidationException('Missing key: "' . $key . '"'); } - if ($type === EShapeType::Text && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); - } - if ($type === EShapeType::Number && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); - } - if ($type === EShapeType::Image && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '"'); - } - if ($type === EShapeType::Audio && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '"'); - } - if ($type === EShapeType::Video && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '"'); - } - if ($type === EShapeType::File && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '"'); + try { + $type->validateInput($io[$key]); + } catch (ValidationException $e) { + throw new ValidationException('Failed to validate input key "' . $key . '": ' . $e->getMessage()); } } } @@ -520,43 +489,12 @@ private function validateOutput(array $spec, array $io, bool $optional = false): if ($optional) { continue; } - throw new \OCP\TaskProcessing\Exception\ValidationException('Missing key: "' . $key . '"'); + throw new ValidationException('Missing key: "' . $key . '"'); } - if ($type === EShapeType::Text && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('Non-text item provided for Text key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfTexts && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-text list item provided for ListOfTexts key: "' . $key . '"'); - } - if ($type === EShapeType::Number && !is_numeric($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric item provided for Number key: "' . $key . '"'); - } - if ($type === EShapeType::ListOfNumbers && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_numeric($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-numeric list item provided for ListOfNumbers key: "' . $key . '"'); - } - if ($type === EShapeType::Image && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-image item provided for Image key: "' . $key . '". Expecting base64 encoded image data.'); - } - if ($type === EShapeType::ListOfImages && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-image list item provided for ListOfImages key: "' . $key . '". Expecting base64 encoded image data.'); - } - if ($type === EShapeType::Audio && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio item provided for Audio key: "' . $key . '". Expecting base64 encoded audio data.'); - } - if ($type === EShapeType::ListOfAudio && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfAudio key: "' . $key . '". Expecting base64 encoded audio data.'); - } - if ($type === EShapeType::Video && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-video item provided for Video key: "' . $key . '". Expecting base64 encoded video data.'); - } - if ($type === EShapeType::ListOfVideo && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-video list item provided for ListOfTexts key: "' . $key . '". Expecting base64 encoded video data.'); - } - if ($type === EShapeType::File && !is_string($io[$key])) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-file item provided for File key: "' . $key . '". Expecting base64 encoded file data.'); - } - if ($type === EShapeType::ListOfFiles && (!is_array($io[$key]) || count(array_filter($io[$key], fn ($item) => !is_string($item))) > 0)) { - throw new \OCP\TaskProcessing\Exception\ValidationException('None-audio list item provided for ListOfFiles key: "' . $key . '". Expecting base64 encoded image data.'); + try { + $type->validateOutput($io[$key]); + } catch (ValidationException $e) { + throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage()); } } } @@ -800,7 +738,7 @@ public function fillInputFileData(array $input, ...$specs): array { if (!isset($input[$key])) { continue; } - if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { + if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { $newInputOutput[$key] = $input[$key]; continue; } @@ -857,7 +795,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { if (!isset($output[$key])) { continue; } - if (!in_array(EShapeType::from($type->value % 10), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { + if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { $newOutput[$key] = $output[$key]; continue; } diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 3da7391daa1b8..e2b2837e8e11f 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -25,8 +25,11 @@ namespace OCP\TaskProcessing; +use OCP\TaskProcessing\Exception\ValidationException; + /** * The input and output Shape types + * * @since 30.0.0 */ enum EShapeType: int { @@ -42,4 +45,92 @@ enum EShapeType: int { case ListOfAudio = 13; case ListOfVideo = 14; case ListOfFiles = 15; + + /** + * @param mixed $value + * @return void + * @throws ValidationException + */ + private function validateNonFileType(mixed $value): void { + if ($this === EShapeType::Text && !is_string($value)) { + throw new ValidationException('Non-text item provided for Text slot'); + } + if ($this === EShapeType::ListOfTexts && (!is_array($value) || count(array_filter($value, fn($item) => !is_string($item))) > 0)) { + throw new ValidationException('Non-text list item provided for ListOfTexts slot'); + } + if ($this === EShapeType::Number && !is_numeric($value)) { + throw new ValidationException('Non-numeric item provided for Number slot'); + } + if ($this === EShapeType::ListOfNumbers && (!is_array($value) || count(array_filter($value, fn($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-numeric list item provided for ListOfNumbers slot'); + } + } + + /** + * @param mixed $value + * @return void + * @throws Exception\ValidationException + */ + public function validateInput(mixed $value): void { + $this->validateNonFileType($value); + if ($this === EShapeType::Image && !is_numeric($value)) { + throw new ValidationException('Non-image item provided for Image slot'); + } + if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-image list item provided for ListOfImages slot'); + } + if ($this === EShapeType::Audio && !is_numeric($value)) { + throw new ValidationException('Non-audio item provided for Audio slot'); + } + if ($this === EShapeType::ListOfAudio && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); + } + if ($this === EShapeType::Video && !is_numeric($value)) { + throw new ValidationException('Non-video item provided for Video slot'); + } + if ($this === EShapeType::ListOfVideo && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-video list item provided for ListOfTexts slot'); + } + if ($this === EShapeType::File && !is_numeric($value)) { + throw new ValidationException('Non-file item provided for File slot'); + } + if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + } + } + + /** + * @throws ValidationException + */ + public function validateOutput(mixed $value) { + $this->validateNonFileType($value); + if ($this === EShapeType::Image && !is_string($value)) { + throw new ValidationException('Non-image item provided for Image slot'); + } + if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + throw new ValidationException('Non-image list item provided for ListOfImages slot'); + } + if ($this === EShapeType::Audio && !is_string($value)) { + throw new ValidationException('Non-audio item provided for Audio slot'); + } + if ($this === EShapeType::ListOfAudio && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); + } + if ($this === EShapeType::Video && !is_string($value)) { + throw new ValidationException('Non-video item provided for Video slot'); + } + if ($this === EShapeType::ListOfVideo && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + throw new ValidationException('Non-video list item provided for ListOfTexts slot'); + } + if ($this === EShapeType::File && !is_string($value)) { + throw new ValidationException('Non-file item provided for File slot'); + } + if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + } + } + + public static function getScalarType(EShapeType $type): EShapeType { + return EShapeType::from($type->value % 10); + } } diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 11b969ec37975..6c374d3fb35c6 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -138,7 +138,7 @@ public function getUserTask(int $id, ?string $userId): Task; * @param string|null $identifier * @return list * @throws Exception If the query failed - * @throws NotFoundException If the task could not be found + * @throws \JsonException If parsing the task input and output failed * @since 30.0.0 */ public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array; From 29cbb3cf71f900ecdc85a56b29c23c08e46d1b95 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 11:17:43 +0200 Subject: [PATCH 09/63] chore: Run cs:fix Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 3 --- lib/public/TaskProcessing/EShapeType.php | 11 +++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 6cadf9cbceea9..9b77fb37cdf64 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -27,7 +27,6 @@ namespace OC\Core\Controller; use OCA\Core\ResponseDefinitions; -use OCA\User_LDAP\Handler\ExtStorageConfigHandler; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -50,8 +49,6 @@ use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\ShapeDescriptor; use OCP\TaskProcessing\Task; -use Psr\Container\ContainerInterface; -use Psr\Log\LoggerInterface; /** * @psalm-import-type CoreTaskProcessingTask from ResponseDefinitions diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index e2b2837e8e11f..8a862fe19a16d 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -55,13 +55,13 @@ private function validateNonFileType(mixed $value): void { if ($this === EShapeType::Text && !is_string($value)) { throw new ValidationException('Non-text item provided for Text slot'); } - if ($this === EShapeType::ListOfTexts && (!is_array($value) || count(array_filter($value, fn($item) => !is_string($item))) > 0)) { + if ($this === EShapeType::ListOfTexts && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { throw new ValidationException('Non-text list item provided for ListOfTexts slot'); } if ($this === EShapeType::Number && !is_numeric($value)) { throw new ValidationException('Non-numeric item provided for Number slot'); } - if ($this === EShapeType::ListOfNumbers && (!is_array($value) || count(array_filter($value, fn($item) => !is_numeric($item))) > 0)) { + if ($this === EShapeType::ListOfNumbers && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { throw new ValidationException('Non-numeric list item provided for ListOfNumbers slot'); } } @@ -70,6 +70,7 @@ private function validateNonFileType(mixed $value): void { * @param mixed $value * @return void * @throws Exception\ValidationException + * @since 30.0.0 */ public function validateInput(mixed $value): void { $this->validateNonFileType($value); @@ -101,6 +102,7 @@ public function validateInput(mixed $value): void { /** * @throws ValidationException + * @since 30.0.0 */ public function validateOutput(mixed $value) { $this->validateNonFileType($value); @@ -130,6 +132,11 @@ public function validateOutput(mixed $value) { } } + /** + * @param EShapeType $type + * @return EShapeType + * @since 30.0.0 + */ public static function getScalarType(EShapeType $type): EShapeType { return EShapeType::from($type->value % 10); } From 3b0925a064b208cfdd91c5f71bf7be5efd326774 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 11:46:49 +0200 Subject: [PATCH 10/63] chore: Regenerate openapi.json Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 10 +- core/ResponseDefinitions.php | 4 +- core/openapi.json | 990 ++++++++++++++++++ 3 files changed, 997 insertions(+), 7 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 9b77fb37cdf64..c3a2e9e097442 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -69,8 +69,7 @@ public function __construct( /** * This endpoint returns all available TaskProcessing task types * - * @return DataResponse}> - * []}, array{}> + * @return DataResponse}, array{}> * * 200: Task types returned */ @@ -131,7 +130,7 @@ public function schedule(array $input, string $type, string $appId, string $iden /** * This endpoint allows checking the status and results of a task. - * Tasks are removed 1 week after receiving their last update. + * Tasks are removed 1 week after receiving their last update * * @param int $id The id of the task * @@ -222,11 +221,12 @@ public function listTasksByApp(string $appId, ?string $identifier = null): DataR /** * This endpoint returns the contents of a file referenced in a task * - * @param int $taskId - * @param int $fileId + * @param int $taskId The id of the task + * @param int $fileId The file id of the file to retrieve * @return DataDownloadResponse|DataResponse * * 200: File content returned + * 404: Task or file not found */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 414b47c159a52..8025f7ef8a784 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -197,8 +197,8 @@ * status: int, * userId: ?string, * appId: string, - * input: array, - * output: ?array, + * input: array, + * output: ?array, * identifier: ?string, * completionExpectedAt: ?int, * progress: ?float diff --git a/core/openapi.json b/core/openapi.json index 37c32cb74042b..311026a33af79 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -466,6 +466,128 @@ } } }, + "TaskProcessingShape": { + "type": "object", + "required": [ + "name", + "description", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "integer", + "format": "int64" + } + } + }, + "TaskProcessingTask": { + "type": "object", + "required": [ + "id", + "status", + "userId", + "appId", + "input", + "output", + "identifier", + "completionExpectedAt", + "progress" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string", + "nullable": true + }, + "appId": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "output": { + "type": "object", + "nullable": true, + "additionalProperties": { + "type": "object" + } + }, + "identifier": { + "type": "string", + "nullable": true + }, + "completionExpectedAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "progress": { + "type": "number", + "format": "double", + "nullable": true + } + } + }, + "TaskProcessingTaskType": { + "type": "object", + "required": [ + "name", + "description", + "inputShape", + "optionalInputShape", + "outputShape", + "optionalOutputShape" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inputShape": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingShape" + } + }, + "optionalInputShape": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingShape" + } + }, + "outputShape": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingShape" + } + }, + "optionalOutputShape": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingShape" + } + } + } + }, "Team": { "type": "object", "required": [ @@ -3183,6 +3305,874 @@ } } }, + "/ocs/v2.php/taskprocessing/tasktypes": { + "get": { + "operationId": "task_processing_api-task-types", + "summary": "This endpoint returns all available TaskProcessing task types", + "tags": [ + "task_processing_api" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task types returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "types" + ], + "properties": { + "types": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/TaskProcessingTaskType" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/taskprocessing/schedule": { + "post": { + "operationId": "task_processing_api-schedule", + "summary": "This endpoint allows scheduling a task", + "tags": [ + "task_processing_api" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "input", + "in": "query", + "description": "Input text", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "Type of the task", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "appId", + "in": "query", + "description": "ID of the app that will execute the task", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "identifier", + "in": "query", + "description": "An arbitrary identifier for the task", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task scheduled successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Scheduling task is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "412": { + "description": "Scheduling task is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/taskprocessing/task/{id}": { + "get": { + "operationId": "task_processing_api-get-task", + "summary": "This endpoint allows checking the status and results of a task. Tasks are removed 1 week after receiving their last update", + "tags": [ + "task_processing_api" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "task_processing_api-delete-task", + "summary": "This endpoint allows to delete a scheduled task for a user", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/taskprocessing/tasks/app/{appId}": { + "get": { + "operationId": "task_processing_api-list-tasks-by-app", + "summary": "This endpoint returns a list of tasks of a user that are related with a specific appId and optionally with an identifier", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "identifier", + "in": "query", + "description": "An arbitrary identifier for the task", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "appId", + "in": "path", + "description": "ID of the app", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task list returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "tasks" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/taskprocessing/tasks/{taskId}/file/{fileId}": { + "get": { + "operationId": "task_processing_api-get-file-contents", + "summary": "This endpoint returns the contents of a file referenced in a task", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "fileId", + "in": "path", + "description": "The file id of the file to retrieve", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "File content returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task or file not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/teams/{teamId}/resources": { "get": { "operationId": "teams_api-resolve-one", From 1c033ae70a4a8a6d141e41a355cdcc38311eb966 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 11:48:10 +0200 Subject: [PATCH 11/63] fix(IRootFolder): Add getAppDataDirectoryName method Signed-off-by: Marcel Klehr --- lib/public/Files/IRootFolder.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/public/Files/IRootFolder.php b/lib/public/Files/IRootFolder.php index c1c0e6e8c72c0..8fa8ed564e2fb 100644 --- a/lib/public/Files/IRootFolder.php +++ b/lib/public/Files/IRootFolder.php @@ -98,4 +98,10 @@ public function getNodeFromCacheEntryAndMount(ICacheEntry $cacheEntry, IMountPoi * @since 28.0.0 */ public function getMount(string $mountPoint): IMountPoint; + + /** + * @return string + * @since 30.0.0 + */ + public function getAppDataDirectoryName(): string; } From 7a947980db9e8824a3c5c0f32a85af3f07a9ada9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 2 May 2024 15:12:35 +0200 Subject: [PATCH 12/63] fix: Fix psalm issues Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 31 +++++++++---------- core/ResponseDefinitions.php | 6 ++-- lib/private/TaskProcessing/Db/TaskMapper.php | 4 +-- lib/private/TaskProcessing/Manager.php | 6 ++-- lib/public/TaskProcessing/IManager.php | 2 +- .../TaskProcessing/ISynchronousProvider.php | 4 +-- lib/public/TaskProcessing/Task.php | 6 ++-- 7 files changed, 30 insertions(+), 29 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index c3a2e9e097442..99d5755e10bf9 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -78,23 +78,27 @@ public function __construct( public function taskTypes(): DataResponse { $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); - /** @var string $typeClass */ - foreach ($taskTypes as $taskType) { - $taskType['inputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['inputShape']); - $taskType['optionalInputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalInputShape']); - $taskType['outputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['outputShape']); - $taskType['optionalOutputShape'] = array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalOutputShape']); + $serializedTaskTypes = []; + foreach ($taskTypes as $key => $taskType) { + $serializedTaskTypes[$key] = [ + 'name' => $taskType['name'], + 'description' => $taskType['description'], + 'inputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['inputShape']), + 'optionalInputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalInputShape']), + 'outputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['outputShape']), + 'optionalOutputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalOutputShape']), + ]; } return new DataResponse([ - 'types' => $taskTypes, + 'types' => $serializedTaskTypes, ]); } /** * This endpoint allows scheduling a task * - * @param string $input Input text + * @param array $input Input text * @param string $type Type of the task * @param string $appId ID of the app that will execute the task * @param string $identifier An arbitrary identifier for the task @@ -162,10 +166,9 @@ public function getTask(int $id): DataResponse { * * @param int $id The id of the task * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: Task returned - * 404: Task not found */ #[NoAdminRequired] #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/taskprocessing')] @@ -175,13 +178,9 @@ public function deleteTask(int $id): DataResponse { $this->taskProcessingManager->deleteTask($task); - $json = $task->jsonSerialize(); - - return new DataResponse([ - 'task' => $json, - ]); + return new DataResponse([]); } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { - return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + return new DataResponse([]); } catch (\OCP\TaskProcessing\Exception\Exception $e) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 8025f7ef8a784..dd4196ec38315 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -194,11 +194,11 @@ * * @psalm-type CoreTaskProcessingTask = array{ * id: ?int, - * status: int, + * status: 0|1|2|3|4|5, * userId: ?string, * appId: string, - * input: array, - * output: ?array, + * input: ?array, + * output: ?array, * identifier: ?string, * completionExpectedAt: ?int, * progress: ?float diff --git a/lib/private/TaskProcessing/Db/TaskMapper.php b/lib/private/TaskProcessing/Db/TaskMapper.php index f8a1adc695c4a..7ba16105f4c45 100644 --- a/lib/private/TaskProcessing/Db/TaskMapper.php +++ b/lib/private/TaskProcessing/Db/TaskMapper.php @@ -104,7 +104,7 @@ public function findByIdAndUser(int $id, ?string $userId): Task { * @param string $userId * @param string $appId * @param string|null $identifier - * @return array + * @return list * @throws Exception */ public function findUserTasksByApp(string $userId, string $appId, ?string $identifier = null): array { @@ -116,7 +116,7 @@ public function findUserTasksByApp(string $userId, string $appId, ?string $ident if ($identifier !== null) { $qb->andWhere($qb->expr()->eq('identifier', $qb->createPositionalParameter($identifier))); } - return $this->findEntities($qb); + return array_values($this->findEntities($qb)); } /** diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 65307d4e43521..8b145be8a65da 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -162,7 +162,7 @@ public function process(?string $userId, array $input): array { } /** - * @return IProvider[] + * @return ITaskType[] */ private function _getTextProcessingTaskTypes(): array { $oldProviders = $this->textProcessingManager->getProviders(); @@ -304,6 +304,8 @@ private function _getSpeechToTextProviders(): array { private ISpeechToTextProvider $provider; private IAppData $appData; + private IRootFolder $rootFolder; + public function __construct(ISpeechToTextProvider $provider, IRootFolder $rootFolder, IAppData $appData) { $this->provider = $provider; $this->rootFolder = $rootFolder; @@ -711,7 +713,7 @@ public function getUserTask(int $id, ?string $userId): Task { public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { try { $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); - return array_map(fn ($taskEntity) => $taskEntity->toPublicTask(), $taskEntities); + return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); } catch (\OCP\DB\Exception $e) { throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); } catch (\JsonException $e) { diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 6c374d3fb35c6..ea85ce6c4fefa 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -52,7 +52,7 @@ public function hasProviders(): bool; public function getProviders(): array; /** - * @return array, outputShape: array, optionalOutputShape: array}> + * @return array * @since 30.0.0 */ public function getAvailableTaskTypes(): array; diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php index e4fc0b1ea7fe0..0b17c6b6d86ac 100644 --- a/lib/public/TaskProcessing/ISynchronousProvider.php +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -40,8 +40,8 @@ interface ISynchronousProvider extends IProvider { * * @since 30.0.0 * @param null|string $userId The user that created the current task - * @param array $input The task input - * @psalm-return array + * @param array $input The task input + * @psalm-return array * @throws ProcessingException */ public function process(?string $userId, array $input): array; diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index 71b5dae84cab6..58c420a9992ce 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -153,7 +153,7 @@ final public function setOutput(?array $output): void { } /** - * @return array|null + * @return array|null * @since 30.0.0 */ final public function getOutput(): ?array { @@ -161,7 +161,7 @@ final public function getOutput(): ?array { } /** - * @return array + * @return array * @since 30.0.0 */ final public function getInput(): array { @@ -193,7 +193,7 @@ final public function getUserId(): ?string { } /** - * @psalm-return array{id: ?int, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} + * @psalm-return array{id: ?int, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} * @since 30.0.0 */ public function jsonSerialize(): array { From 8e5662602a1dd264f54f249ce1cfc2f30be627fb Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 10:14:19 +0200 Subject: [PATCH 13/63] feat: Add ExApp endpoints Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 76 +++- core/ResponseDefinitions.php | 4 +- core/openapi.json | 379 ++++++++++++++++-- 3 files changed, 413 insertions(+), 46 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 99d5755e10bf9..cd8b967387193 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -98,7 +98,7 @@ public function taskTypes(): DataResponse { /** * This endpoint allows scheduling a task * - * @param array $input Input text + * @param array $input Input text * @param string $type Type of the task * @param string $appId ID of the app that will execute the task * @param string $identifier An arbitrary identifier for the task @@ -118,6 +118,7 @@ public function schedule(array $input, string $type, string $appId, string $iden try { $this->taskProcessingManager->scheduleTask($task); + /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ @@ -149,6 +150,7 @@ public function getTask(int $id): DataResponse { try { $task = $this->taskProcessingManager->getUserTask($id, $this->userId); + /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ @@ -255,7 +257,7 @@ public function getFileContents(int $taskId, int $fileId): Http\DataDownloadResp /** * @param Task $task - * @return array + * @return list * @throws \OCP\TaskProcessing\Exception\NotFoundException */ private function extractFileIdsFromTask(Task $task) { @@ -270,11 +272,75 @@ private function extractFileIdsFromTask(Task $task) { $ids[] = $task->getInput()[$key]; } } - foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { - if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - $ids[] = $task->getOutput()[$key]; + if ($task->getOutput() !== null) { + foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + $ids[] = $task->getOutput()[$key]; + } } } return $ids; } + + /** + * This endpoint sets the task progress + * + * @param int $taskId The id of the task + * @param float $progress The progress + * @return DataResponse|DataResponse + * + * 200: File content returned + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/progress', root: '/taskprocessing')] + public function setProgress(int $taskId, float $progress): DataResponse { + try { + $this->taskProcessingManager->setTaskProgress($taskId, $progress); + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint sets the task progress + * + * @param int $taskId The id of the task + * @param array|null $output The resulting task output + * @param string|null $errorMessage An error message if the task failed + * @return DataResponse|DataResponse + * + * 200: File content returned + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/result', root: '/taskprocessing')] + public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { + try { + $this->taskProcessingManager->getUserTask($taskId, $this->userId); + $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output); + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } } diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index dd4196ec38315..ef577a614204a 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -197,8 +197,8 @@ * status: 0|1|2|3|4|5, * userId: ?string, * appId: string, - * input: ?array, - * output: ?array, + * input: ?array, + * output: ?array, * identifier: ?string, * completionExpectedAt: ?int, * progress: ?float diff --git a/core/openapi.json b/core/openapi.json index 311026a33af79..83a6bcc838376 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -507,7 +507,15 @@ }, "status": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ] }, "userId": { "type": "string", @@ -518,6 +526,7 @@ }, "input": { "type": "object", + "nullable": true, "additionalProperties": { "type": "object" } @@ -3792,6 +3801,36 @@ "responses": { "200": { "description": "Task returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + }, + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -3813,11 +3852,11 @@ "data": { "type": "object", "required": [ - "task" + "message" ], "properties": { - "task": { - "$ref": "#/components/schemas/TaskProcessingTask" + "message": { + "type": "string" } } } @@ -3827,9 +3866,58 @@ } } } + } + } + } + }, + "/ocs/v2.php/taskprocessing/tasks/app/{appId}": { + "get": { + "operationId": "task_processing_api-list-tasks-by-app", + "summary": "This endpoint returns a list of tasks of a user that are related with a specific appId and optionally with an identifier", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] }, - "404": { - "description": "Task not found", + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "identifier", + "in": "query", + "description": "An arbitrary identifier for the task", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "appId", + "in": "path", + "description": "ID of the app", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task list returned", "content": { "application/json": { "schema": { @@ -3851,11 +3939,14 @@ "data": { "type": "object", "required": [ - "message" + "tasks" ], "properties": { - "message": { - "type": "string" + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingTask" + } } } } @@ -3907,10 +3998,10 @@ } } }, - "/ocs/v2.php/taskprocessing/tasks/app/{appId}": { + "/ocs/v2.php/taskprocessing/tasks/{taskId}/file/{fileId}": { "get": { - "operationId": "task_processing_api-list-tasks-by-app", - "summary": "This endpoint returns a list of tasks of a user that are related with a specific appId and optionally with an identifier", + "operationId": "task_processing_api-get-file-contents", + "summary": "This endpoint returns the contents of a file referenced in a task", "tags": [ "task_processing_api" ], @@ -3924,21 +4015,23 @@ ], "parameters": [ { - "name": "identifier", - "in": "query", - "description": "An arbitrary identifier for the task", + "name": "taskId", + "in": "path", + "description": "The id of the task", + "required": true, "schema": { - "type": "string", - "nullable": true + "type": "integer", + "format": "int64" } }, { - "name": "appId", + "name": "fileId", "in": "path", - "description": "ID of the app", + "description": "The file id of the file to retrieve", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int64" } }, { @@ -3954,7 +4047,18 @@ ], "responses": { "200": { - "description": "Task list returned", + "description": "File content returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -3976,14 +4080,11 @@ "data": { "type": "object", "required": [ - "tasks" + "message" ], "properties": { - "tasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskProcessingTask" - } + "message": { + "type": "string" } } } @@ -3994,8 +4095,8 @@ } } }, - "500": { - "description": "", + "404": { + "description": "Task or file not found", "content": { "application/json": { "schema": { @@ -4035,10 +4136,10 @@ } } }, - "/ocs/v2.php/taskprocessing/tasks/{taskId}/file/{fileId}": { - "get": { - "operationId": "task_processing_api-get-file-contents", - "summary": "This endpoint returns the contents of a file referenced in a task", + "/ocs/v2.php/taskprocessing/tasks/{taskId}/progress": { + "post": { + "operationId": "task_processing_api-set-progress", + "summary": "This endpoint sets the task progress", "tags": [ "task_processing_api" ], @@ -4051,6 +4152,16 @@ } ], "parameters": [ + { + "name": "progress", + "in": "query", + "description": "The progress", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, { "name": "taskId", "in": "path", @@ -4062,9 +4173,172 @@ } }, { - "name": "fileId", + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "File content returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/taskprocessing/tasks/{taskId}/result": { + "post": { + "operationId": "task_processing_api-set-result", + "summary": "This endpoint sets the task progress", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "output", + "in": "query", + "description": "The resulting task output", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "errorMessage", + "in": "query", + "description": "An error message if the task failed", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "taskId", "in": "path", - "description": "The file id of the file to retrieve", + "description": "The id of the task", "required": true, "schema": { "type": "integer", @@ -4086,10 +4360,37 @@ "200": { "description": "File content returned", "content": { - "*/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } } } } @@ -4133,7 +4434,7 @@ } }, "404": { - "description": "Task or file not found", + "description": "Task not found", "content": { "application/json": { "schema": { From 5031a2ec4a160a7202b260d30140d7010b374aba Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 11:08:01 +0200 Subject: [PATCH 14/63] fix: Typo Signed-off-by: Marcel Klehr --- ...tTextProcessingEvent.php => AbstractTaskProcessingEvent.php} | 2 +- lib/public/TaskProcessing/Events/TaskFailedEvent.php | 2 +- lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename lib/public/TaskProcessing/Events/{AbstractTextProcessingEvent.php => AbstractTaskProcessingEvent.php} (95%) diff --git a/lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php b/lib/public/TaskProcessing/Events/AbstractTaskProcessingEvent.php similarity index 95% rename from lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php rename to lib/public/TaskProcessing/Events/AbstractTaskProcessingEvent.php index 0d8f6ddb2e0b1..fea82e55ed7fb 100644 --- a/lib/public/TaskProcessing/Events/AbstractTextProcessingEvent.php +++ b/lib/public/TaskProcessing/Events/AbstractTaskProcessingEvent.php @@ -31,7 +31,7 @@ /** * @since 30.0.0 */ -abstract class AbstractTextProcessingEvent extends Event { +abstract class AbstractTaskProcessingEvent extends Event { /** * @since 30.0.0 */ diff --git a/lib/public/TaskProcessing/Events/TaskFailedEvent.php b/lib/public/TaskProcessing/Events/TaskFailedEvent.php index 7b118c08b8cf2..531380dd8fa75 100644 --- a/lib/public/TaskProcessing/Events/TaskFailedEvent.php +++ b/lib/public/TaskProcessing/Events/TaskFailedEvent.php @@ -7,7 +7,7 @@ /** * @since 30.0.0 */ -class TaskFailedEvent extends AbstractTextProcessingEvent { +class TaskFailedEvent extends AbstractTaskProcessingEvent { /** * @param Task $task * @param string $errorMessage diff --git a/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php b/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php index 88214a451aacc..5fafb8436f98a 100644 --- a/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php +++ b/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php @@ -5,5 +5,5 @@ /** * @since 30.0.0 */ -class TaskSuccessfulEvent extends AbstractTextProcessingEvent { +class TaskSuccessfulEvent extends AbstractTaskProcessingEvent { } From 843bb62d6dd795803c9e423c4979521b70c55085 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 11:08:15 +0200 Subject: [PATCH 15/63] fix: LazyRoot missing method Signed-off-by: Marcel Klehr --- lib/private/Files/Node/LazyRoot.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/private/Files/Node/LazyRoot.php b/lib/private/Files/Node/LazyRoot.php index dd1596319fa87..80a72ba537f7e 100644 --- a/lib/private/Files/Node/LazyRoot.php +++ b/lib/private/Files/Node/LazyRoot.php @@ -64,4 +64,8 @@ public function getFirstNodeByIdInPath(int $id, string $path): ?Node { public function getNodeFromCacheEntryAndMount(ICacheEntry $cacheEntry, IMountPoint $mountPoint): INode { return $this->getRootFolder()->getNodeFromCacheEntryAndMount($cacheEntry, $mountPoint); } + + public function getAppDataDirectoryName(): string { + return $this->__call(__FUNCTION__, func_get_args()); + } } From c9ea5375d87a90a5c25a5858aed6fdcb1b035a9f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 11:46:36 +0200 Subject: [PATCH 16/63] fix: Fix psalm issues Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 10 +++++++--- lib/public/TaskProcessing/EShapeType.php | 1 + lib/public/TaskProcessing/ShapeDescriptor.php | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 8b145be8a65da..793bcec63643c 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -38,7 +38,9 @@ use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; +use OCP\IL10N; use OCP\IServerContainer; +use OCP\L10N\IFactory; use OCP\Lock\LockedException; use OCP\PreConditionNotMetException; use OCP\SpeechToText\ISpeechToTextProvider; @@ -180,10 +182,12 @@ private function _getTextProcessingTaskTypes(): array { $taskType = new class($oldProvider->getTaskType()) implements ITaskType { private string $oldTaskTypeClass; private \OCP\TextProcessing\ITaskType $oldTaskType; + private IL10N $l; public function __construct(string $oldTaskTypeClass) { $this->oldTaskTypeClass = $oldTaskTypeClass; $this->oldTaskType = \OCP\Server::get($oldTaskTypeClass); + $this->l = \OCP\Server::get(IFactory::class)->get('core'); } public function getId(): string { @@ -199,11 +203,11 @@ public function getDescription(): string { } public function getInputShape(): array { - return ['input' => EShapeType::Text]; + return ['input' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)]; } public function getOutputShape(): array { - return ['output' => EShapeType::Text]; + return ['output' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)]; } }; $newTaskTypes[$taskType->getId()] = $taskType; @@ -283,7 +287,7 @@ public function process(?string $userId, array $input): array { } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } - return ['images' => array_map(fn (File $file) => base64_encode($file->getContent()), $files)]; + return ['images' => array_map(fn (File $file) => $file->getContent(), $files)]; } }; $newProviders[$newProvider->getId()] = $newProvider; diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 8a862fe19a16d..5555671976b67 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -50,6 +50,7 @@ enum EShapeType: int { * @param mixed $value * @return void * @throws ValidationException + * @since 30.0.0 */ private function validateNonFileType(mixed $value): void { if ($this === EShapeType::Text && !is_string($value)) { diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php index c84a638862dc7..6c14bab751e38 100644 --- a/lib/public/TaskProcessing/ShapeDescriptor.php +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -46,6 +46,7 @@ public function getShapeType(): EShapeType { /** * @return array{name: string, description: string, type: int} + * @since 30.0.0 */ public function jsonSerialize(): array { return [ From b150d779f33ac2a417b5dcc5f451412f32552388 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 12:23:59 +0200 Subject: [PATCH 17/63] refactor: rename getTaskType to getTaskTypeId Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 4 +-- lib/private/TaskProcessing/Db/Task.php | 2 +- lib/private/TaskProcessing/Manager.php | 36 +++++++++---------- .../SynchronousBackgroundJob.php | 2 +- lib/public/TaskProcessing/IProvider.php | 2 +- lib/public/TaskProcessing/Task.php | 6 ++-- .../lib/TaskProcessing/TaskProcessingTest.php | 8 ++--- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index cd8b967387193..d1084399b9079 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -263,10 +263,10 @@ public function getFileContents(int $taskId, int $fileId): Http\DataDownloadResp private function extractFileIdsFromTask(Task $task) { $ids = []; $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); - if (!isset($taskTypes[$task->getTaskType()])) { + if (!isset($taskTypes[$task->getTaskTypeId()])) { throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find task type'); } - $taskType = $taskTypes[$task->getTaskType()]; + $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { $ids[] = $task->getInput()[$key]; diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index 3712d0ac4225f..a506ffd86c993 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -102,7 +102,7 @@ public static function fromPublicTask(OCPTask $task): Task { /** @var Task $taskEntity */ $taskEntity = Task::fromParams([ 'id' => $task->getId(), - 'type' => $task->getTaskType(), + 'type' => $task->getTaskTypeId(), 'lastUpdated' => time(), 'status' => $task->getStatus(), 'input' => json_encode($task->getInput(), JSON_THROW_ON_ERROR), diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 793bcec63643c..6582e6c176782 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -121,7 +121,7 @@ public function getName(): string { return $this->provider->getName(); } - public function getTaskType(): string { + public function getTaskTypeId(): string { return match ($this->provider->getTaskType()) { \OCP\TextProcessing\FreePromptTaskType::class => TextToText::ID, \OCP\TextProcessing\HeadlineTaskType::class => TextToTextHeadline::ID, @@ -240,7 +240,7 @@ public function getName(): string { return $this->provider->getName(); } - public function getTaskType(): string { + public function getTaskTypeId(): string { return TextToImage::ID; } @@ -327,7 +327,7 @@ public function getName(): string { return $this->provider->getName(); } - public function getTaskType(): string { + public function getTaskTypeId(): string { return AudioToText::ID; } @@ -451,7 +451,7 @@ private function _getTaskTypes(): array { private function _getPreferredProvider(string $taskType) { $providers = $this->getProviders(); foreach ($providers as $provider) { - if ($provider->getTaskType() === $taskType) { + if ($provider->getTaskTypeId() === $taskType) { return $provider; } } @@ -535,11 +535,11 @@ public function getAvailableTaskTypes(): array { $availableTaskTypes = []; foreach ($providers as $provider) { - if (!isset($taskTypes[$provider->getTaskType()])) { + if (!isset($taskTypes[$provider->getTaskTypeId()])) { continue; } - $taskType = $taskTypes[$provider->getTaskType()]; - $availableTaskTypes[$provider->getTaskType()] = [ + $taskType = $taskTypes[$provider->getTaskTypeId()]; + $availableTaskTypes[$provider->getTaskTypeId()] = [ 'name' => $taskType->getName(), 'description' => $taskType->getDescription(), 'inputShape' => $taskType->getInputShape(), @@ -556,23 +556,23 @@ public function getAvailableTaskTypes(): array { } public function canHandleTask(Task $task): bool { - return isset($this->getAvailableTaskTypes()[$task->getTaskType()]); + return isset($this->getAvailableTaskTypes()[$task->getTaskTypeId()]); } public function scheduleTask(Task $task): void { if (!$this->canHandleTask($task)) { - throw new PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskType()); + throw new PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId()); } $taskTypes = $this->getAvailableTaskTypes(); - $inputShape = $taskTypes[$task->getTaskType()]['inputShape']; - $optionalInputShape = $taskTypes[$task->getTaskType()]['optionalInputShape']; + $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape']; + $optionalInputShape = $taskTypes[$task->getTaskTypeId()]['optionalInputShape']; // validate input $this->validateInput($inputShape, $task->getInput()); $this->validateInput($optionalInputShape, $task->getInput(), true); // remove superfluous keys and set input $task->setInput($this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape)); $task->setStatus(Task::STATUS_SCHEDULED); - $provider = $this->_getPreferredProvider($task->getTaskType()); + $provider = $this->_getPreferredProvider($task->getTaskTypeId()); // calculate expected completion time $completionExpectedAt = new \DateTime('now'); $completionExpectedAt->add(new \DateInterval('PT'.$provider->getExpectedRuntime().'S')); @@ -638,17 +638,17 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void { // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently $task = $this->getTask($id); if ($task->getStatus() === Task::STATUS_CANCELLED) { - $this->logger->info('A TaskProcessing ' . $task->getTaskType() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.'); + $this->logger->info('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.'); return; } if ($error !== null) { $task->setStatus(Task::STATUS_FAILED); $task->setErrorMessage($error); - $this->logger->warning('A TaskProcessing ' . $task->getTaskType() . ' task with id ' . $id . ' failed with the following message: ' . $error); + $this->logger->warning('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' failed with the following message: ' . $error); } elseif ($result !== null) { $taskTypes = $this->getAvailableTaskTypes(); - $outputShape = $taskTypes[$task->getTaskType()]['outputShape']; - $optionalOutputShape = $taskTypes[$task->getTaskType()]['optionalOutputShape']; + $outputShape = $taskTypes[$task->getTaskTypeId()]['outputShape']; + $optionalOutputShape = $taskTypes[$task->getTaskTypeId()]['optionalOutputShape']; try { // validate output $this->validateOutput($outputShape, $result); @@ -823,8 +823,8 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { public function prepareInputData(Task $task): array { $taskTypes = $this->getAvailableTaskTypes(); - $inputShape = $taskTypes[$task->getTaskType()]['inputShape']; - $optionalInputShape = $taskTypes[$task->getTaskType()]['optionalInputShape']; + $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape']; + $optionalInputShape = $taskTypes[$task->getTaskTypeId()]['optionalInputShape']; $input = $task->getInput(); // validate input, again for good measure (should have been validated in scheduleTask) $this->validateInput($inputShape, $input); diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php index ab85d46908938..c7f4706c392a3 100644 --- a/lib/private/TaskProcessing/SynchronousBackgroundJob.php +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -37,7 +37,7 @@ protected function run($argument) { if (!$provider instanceof ISynchronousProvider) { continue; } - $taskType = $provider->getTaskType(); + $taskType = $provider->getTaskTypeId(); try { $task = $this->taskProcessingManager->getNextScheduledTask($taskType); } catch (NotFoundException $e) { diff --git a/lib/public/TaskProcessing/IProvider.php b/lib/public/TaskProcessing/IProvider.php index 5768294fd96ee..dd20fdd8a700f 100644 --- a/lib/public/TaskProcessing/IProvider.php +++ b/lib/public/TaskProcessing/IProvider.php @@ -51,7 +51,7 @@ public function getName(): string; * @since 30.0.0 * @return string */ - public function getTaskType(): string; + public function getTaskTypeId(): string; /** * @return int The expected average runtime of a task in seconds diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index 58c420a9992ce..4bddd06162fe1 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -82,7 +82,7 @@ final class Task implements \JsonSerializable { * @since 30.0.0 */ final public function __construct( - protected readonly string $taskType, + protected readonly string $taskTypeId, protected array $input, protected readonly string $appId, protected readonly ?string $userId, @@ -93,8 +93,8 @@ final public function __construct( /** * @since 30.0.0 */ - final public function getTaskType(): string { - return $this->taskType; + final public function getTaskTypeId(): string { + return $this->taskTypeId; } /** diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index ddf64f23173e1..b9d402bbde82d 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -80,7 +80,7 @@ public function getName(): string { return self::class; } - public function getTaskType(): string { + public function getTaskTypeId(): string { return AudioToImage::ID; } @@ -110,7 +110,7 @@ public function getName(): string { return self::class; } - public function getTaskType(): string { + public function getTaskTypeId(): string { return TextToText::ID; } @@ -145,7 +145,7 @@ public function getName(): string { return self::class; } - public function getTaskType(): string { + public function getTaskTypeId(): string { return TextToText::ID; } @@ -179,7 +179,7 @@ public function getName(): string { return self::class; } - public function getTaskType(): string { + public function getTaskTypeId(): string { return TextToText::ID; } From 4b954d222749610af1afbc2fde78209875ae7489 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 12:24:40 +0200 Subject: [PATCH 18/63] fix: Wire-up the new classes Signed-off-by: Marcel Klehr --- lib/composer/composer/autoload_classmap.php | 27 ++++++++++++++++ lib/composer/composer/autoload_static.php | 35 +++++++++++++++++++++ lib/private/Server.php | 2 ++ 3 files changed, 64 insertions(+) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 2f93910197dbd..211d307e7bd78 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -709,6 +709,26 @@ 'OCP\\Talk\\IConversation' => $baseDir . '/lib/public/Talk/IConversation.php', 'OCP\\Talk\\IConversationOptions' => $baseDir . '/lib/public/Talk/IConversationOptions.php', '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\\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', + 'OCP\\TaskProcessing\\Exception\\NotFoundException' => $baseDir . '/lib/public/TaskProcessing/Exception/NotFoundException.php', + 'OCP\\TaskProcessing\\Exception\\ProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/ProcessingException.php', + 'OCP\\TaskProcessing\\Exception\\ValidationException' => $baseDir . '/lib/public/TaskProcessing/Exception/ValidationException.php', + 'OCP\\TaskProcessing\\IManager' => $baseDir . '/lib/public/TaskProcessing/IManager.php', + 'OCP\\TaskProcessing\\IProvider' => $baseDir . '/lib/public/TaskProcessing/IProvider.php', + 'OCP\\TaskProcessing\\ISynchronousProvider' => $baseDir . '/lib/public/TaskProcessing/ISynchronousProvider.php', + 'OCP\\TaskProcessing\\ITaskType' => $baseDir . '/lib/public/TaskProcessing/ITaskType.php', + 'OCP\\TaskProcessing\\ShapeDescriptor' => $baseDir . '/lib/public/TaskProcessing/ShapeDescriptor.php', + 'OCP\\TaskProcessing\\Task' => $baseDir . '/lib/public/TaskProcessing/Task.php', + 'OCP\\TaskProcessing\\TaskTypes\\AudioToText' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/AudioToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToImage' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToImage.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToText' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextHeadline' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\Teams\\ITeamManager' => $baseDir . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => $baseDir . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => $baseDir . '/lib/public/Teams/Team.php', @@ -1185,6 +1205,7 @@ 'OC\\Core\\Controller\\ReferenceController' => $baseDir . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => $baseDir . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => $baseDir . '/core/Controller/SetupController.php', + 'OC\\Core\\Controller\\TaskProcessingApiController' => $baseDir . '/core/Controller/TaskProcessingApiController.php', 'OC\\Core\\Controller\\TeamsApiController' => $baseDir . '/core/Controller/TeamsApiController.php', 'OC\\Core\\Controller\\TextProcessingApiController' => $baseDir . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TextToImageApiController' => $baseDir . '/core/Controller/TextToImageApiController.php', @@ -1278,6 +1299,7 @@ 'OC\\Core\\Migrations\\Version29000Date20240124132201' => $baseDir . '/core/Migrations/Version29000Date20240124132201.php', 'OC\\Core\\Migrations\\Version29000Date20240124132202' => $baseDir . '/core/Migrations/Version29000Date20240124132202.php', 'OC\\Core\\Migrations\\Version29000Date20240131122720' => $baseDir . '/core/Migrations/Version29000Date20240131122720.php', + 'OC\\Core\\Migrations\\Version30000Date20240429122720' => $baseDir . '/core/Migrations/Version30000Date20240429122720.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', @@ -1827,6 +1849,11 @@ 'OC\\Tags' => $baseDir . '/lib/private/Tags.php', 'OC\\Talk\\Broker' => $baseDir . '/lib/private/Talk/Broker.php', 'OC\\Talk\\ConversationOptions' => $baseDir . '/lib/private/Talk/ConversationOptions.php', + 'OC\\TaskProcessing\\Db\\Task' => $baseDir . '/lib/private/TaskProcessing/Db/Task.php', + 'OC\\TaskProcessing\\Db\\TaskMapper' => $baseDir . '/lib/private/TaskProcessing/Db/TaskMapper.php', + 'OC\\TaskProcessing\\Manager' => $baseDir . '/lib/private/TaskProcessing/Manager.php', + 'OC\\TaskProcessing\\RemoveOldTasksBackgroundJob' => $baseDir . '/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php', + 'OC\\TaskProcessing\\SynchronousBackgroundJob' => $baseDir . '/lib/private/TaskProcessing/SynchronousBackgroundJob.php', 'OC\\Teams\\TeamManager' => $baseDir . '/lib/private/Teams/TeamManager.php', 'OC\\TempManager' => $baseDir . '/lib/private/TempManager.php', 'OC\\TemplateLayout' => $baseDir . '/lib/private/TemplateLayout.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f7b35e24d8f55..f352636e507fd 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -17,6 +17,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\' => 3, 'OCP\\' => 4, ), + 'B' => + array ( + 'Bamarni\\Composer\\Bin\\' => 21, + ), ); public static $prefixDirsPsr4 = array ( @@ -32,6 +36,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), + 'Bamarni\\Composer\\Bin\\' => + array ( + 0 => __DIR__ . '/..' . '/bamarni/composer-bin-plugin/src', + ), ); public static $fallbackDirsPsr4 = array ( @@ -742,6 +750,26 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Talk\\IConversation' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversation.php', 'OCP\\Talk\\IConversationOptions' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversationOptions.php', '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\\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', + 'OCP\\TaskProcessing\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/NotFoundException.php', + 'OCP\\TaskProcessing\\Exception\\ProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ProcessingException.php', + 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', + 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', + 'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php', + 'OCP\\TaskProcessing\\ISynchronousProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ISynchronousProvider.php', + 'OCP\\TaskProcessing\\ITaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ITaskType.php', + 'OCP\\TaskProcessing\\ShapeDescriptor' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ShapeDescriptor.php', + 'OCP\\TaskProcessing\\Task' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Task.php', + 'OCP\\TaskProcessing\\TaskTypes\\AudioToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/AudioToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToImage' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToImage.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextHeadline' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\Teams\\ITeamManager' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => __DIR__ . '/../../..' . '/lib/public/Teams/Team.php', @@ -1218,6 +1246,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Controller\\ReferenceController' => __DIR__ . '/../../..' . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php', + 'OC\\Core\\Controller\\TaskProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TaskProcessingApiController.php', 'OC\\Core\\Controller\\TeamsApiController' => __DIR__ . '/../../..' . '/core/Controller/TeamsApiController.php', 'OC\\Core\\Controller\\TextProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TextToImageApiController' => __DIR__ . '/../../..' . '/core/Controller/TextToImageApiController.php', @@ -1311,6 +1340,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version29000Date20240124132201' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132201.php', 'OC\\Core\\Migrations\\Version29000Date20240124132202' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132202.php', 'OC\\Core\\Migrations\\Version29000Date20240131122720' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240131122720.php', + 'OC\\Core\\Migrations\\Version30000Date20240429122720' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240429122720.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', @@ -1860,6 +1890,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Tags' => __DIR__ . '/../../..' . '/lib/private/Tags.php', 'OC\\Talk\\Broker' => __DIR__ . '/../../..' . '/lib/private/Talk/Broker.php', 'OC\\Talk\\ConversationOptions' => __DIR__ . '/../../..' . '/lib/private/Talk/ConversationOptions.php', + 'OC\\TaskProcessing\\Db\\Task' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/Task.php', + 'OC\\TaskProcessing\\Db\\TaskMapper' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/TaskMapper.php', + 'OC\\TaskProcessing\\Manager' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Manager.php', + 'OC\\TaskProcessing\\RemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php', + 'OC\\TaskProcessing\\SynchronousBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/SynchronousBackgroundJob.php', 'OC\\Teams\\TeamManager' => __DIR__ . '/../../..' . '/lib/private/Teams/TeamManager.php', 'OC\\TempManager' => __DIR__ . '/../../..' . '/lib/private/TempManager.php', 'OC\\TemplateLayout' => __DIR__ . '/../../..' . '/lib/private/TemplateLayout.php', diff --git a/lib/private/Server.php b/lib/private/Server.php index 21d60d558295a..21e120840db30 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1433,6 +1433,8 @@ public function __construct($webRoot, \OC\Config $config) { $this->registerAlias(IDeclarativeManager::class, DeclarativeManager::class); + $this->registerAlias(\OCP\TaskProcessing\IManager::class, \OC\TaskProcessing\Manager::class); + $this->connectDispatcher(); } From eebeb82416c191e29245c1364922e3d4716b8ee1 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 12:33:17 +0200 Subject: [PATCH 19/63] fix: Small fixes Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 6582e6c176782..818d0d1ce83ef 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -262,15 +262,10 @@ public function process(?string $userId, array $input): array { } catch(\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('text2image'); } - try { - $folder = $folder->getFolder((string) rand(1, 100000)); - } catch(\OCP\Files\NotFoundException) { - $folder = $folder->newFolder((string) rand(1, 100000)); - } $resources = []; $files = []; for ($i = 0; $i < $input['numberOfImages']; $i++) { - $file = $folder->newFile((string) $i); + $file = $folder->newFile( time() . '-' . rand(1, 100000) . '-' . $i); $files[] = $file; $resource = $file->write(); if ($resource !== false && $resource !== true && is_resource($resource)) { @@ -349,12 +344,8 @@ public function process(?string $userId, array $input): array { } catch(\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('audio2text'); } - try { - $folder = $folder->getFolder((string) rand(1, 100000)); - } catch(\OCP\Files\NotFoundException) { - $folder = $folder->newFolder((string) rand(1, 100000)); - } - $simpleFile = $folder->newFile((string) rand(0, 100000), base64_decode($input['input'])); + /** @var SimpleFile $file */ + $simpleFile = $folder->newFile(time() . '-' . rand(0, 100000), $input['input']->getContent()); $id = $simpleFile->getId(); /** @var File $file */ $file = current($this->rootFolder->getById($id)); From bd5dfd0b5f5f4bbbc0046924a62dbd54ad5fa2c2 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 14:15:03 +0200 Subject: [PATCH 20/63] test: Add more tests for legacy pass-through Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 5 +- .../lib/TaskProcessing/TaskProcessingTest.php | 233 +++++++++++++++++- 2 files changed, 234 insertions(+), 4 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 818d0d1ce83ef..9a75217910d12 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -38,6 +38,7 @@ use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; use OCP\IL10N; use OCP\IServerContainer; use OCP\L10N\IFactory; @@ -265,7 +266,7 @@ public function process(?string $userId, array $input): array { $resources = []; $files = []; for ($i = 0; $i < $input['numberOfImages']; $i++) { - $file = $folder->newFile( time() . '-' . rand(1, 100000) . '-' . $i); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . '-' . $i); $files[] = $file; $resource = $file->write(); if ($resource !== false && $resource !== true && is_resource($resource)) { @@ -282,7 +283,7 @@ public function process(?string $userId, array $input): array { } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } - return ['images' => array_map(fn (File $file) => $file->getContent(), $files)]; + return ['images' => array_map(fn (ISimpleFile $file) => $file->getContent(), $files)]; } }; $newProviders[$newProvider->getId()] = $newProvider; diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index b9d402bbde82d..5be43314d3e82 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -38,7 +38,10 @@ use OCP\TaskProcessing\ITaskType; use OCP\TaskProcessing\ShapeDescriptor; use OCP\TaskProcessing\Task; +use OCP\TaskProcessing\TaskTypes\TextToImage; use OCP\TaskProcessing\TaskTypes\TextToText; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; +use OCP\TextProcessing\SummaryTaskType; use PHPUnit\Framework\Constraint\IsInstanceOf; use Psr\Log\LoggerInterface; use Test\BackgroundJob\DummyJobList; @@ -204,6 +207,85 @@ public function process(?string $userId, array $input): array { } } +class SuccessfulTextProcessingSummaryProvider implements \OCP\TextProcessing\IProvider { + public bool $ran = false; + + public function getName(): string { + return 'TEST Vanilla LLM Provider'; + } + + public function process(string $prompt): string { + $this->ran = true; + return $prompt . ' Summarize'; + } + + public function getTaskType(): string { + return SummaryTaskType::class; + } +} + +class FailingTextProcessingSummaryProvider implements \OCP\TextProcessing\IProvider { + public bool $ran = false; + + public function getName(): string { + return 'TEST Vanilla LLM Provider'; + } + + public function process(string $prompt): string { + $this->ran = true; + throw new \Exception('ERROR'); + } + + public function getTaskType(): string { + return SummaryTaskType::class; + } +} + +class SuccessfulTextToImageProvider implements \OCP\TextToImage\IProvider { + public bool $ran = false; + + public function getId(): string { + return 'test:successful'; + } + + public function getName(): string { + return 'TEST Provider'; + } + + public function generate(string $prompt, array $resources): void { + $this->ran = true; + foreach($resources as $resource) { + fwrite($resource, 'test'); + fclose($resource); + } + } + + public function getExpectedRuntime(): int { + return 1; + } +} + +class FailingTextToImageProvider implements \OCP\TextToImage\IProvider { + public bool $ran = false; + + public function getId(): string { + return 'test:failing'; + } + + public function getName(): string { + return 'TEST Provider'; + } + + public function generate(string $prompt, array $resources): void { + $this->ran = true; + throw new \RuntimeException('ERROR'); + } + + public function getExpectedRuntime(): int { + return 1; + } +} + /** * @group DB */ @@ -227,6 +309,10 @@ protected function setUp(): void { BrokenSyncProvider::class => new BrokenSyncProvider(), AsyncProvider::class => new AsyncProvider(), AudioToImage::class => new AudioToImage(), + SuccessfulTextProcessingSummaryProvider::class => new SuccessfulTextProcessingSummaryProvider(), + FailingTextProcessingSummaryProvider::class => new FailingTextProcessingSummaryProvider(), + SuccessfulTextToImageProvider::class => new SuccessfulTextToImageProvider(), + FailingTextToImageProvider::class => new FailingTextToImageProvider(), ]; $this->serverContainer = $this->createMock(IServerContainer::class); @@ -257,6 +343,26 @@ protected function setUp(): void { $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $textProcessingManager = new \OC\TextProcessing\Manager( + $this->serverContainer, + $this->coordinator, + \OC::$server->get(LoggerInterface::class), + $this->jobList, + \OC::$server->get(\OC\TextProcessing\Db\TaskMapper::class), + \OC::$server->get(IConfig::class), + ); + + $text2imageManager = new \OC\TextToImage\Manager( + $this->serverContainer, + $this->coordinator, + \OC::$server->get(LoggerInterface::class), + $this->jobList, + \OC::$server->get(\OC\TextToImage\Db\TaskMapper::class), + \OC::$server->get(IConfig::class), + \OC::$server->get(IAppDataFactory::class), + ); + + $this->manager = new Manager( $this->coordinator, $this->serverContainer, @@ -266,8 +372,8 @@ protected function setUp(): void { $this->eventDispatcher, \OC::$server->get(IAppDataFactory::class), \OC::$server->get(IRootFolder::class), - \OC::$server->get(\OCP\TextProcessing\IManager::class), - \OC::$server->get(\OCP\TextToImage\IManager::class), + $textProcessingManager, + $text2imageManager, \OC::$server->get(ISpeechToTextManager::class), ); } @@ -507,4 +613,127 @@ public function testOldTasksShouldBeCleanedUp() { $this->expectException(NotFoundException::class); $this->manager->getTask($task->getId()); } + + public function testShouldTransparentlyHandleTextProcessingProviders() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulTextProcessingSummaryProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + ]); + $taskTypes = $this->manager->getAvailableTaskTypes(); + self::assertCount(1, $taskTypes); + self::assertTrue(isset($taskTypes[TextToTextSummary::ID])); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToTextSummary::ID, ['input' => 'Hello'], 'test', null); + $this->manager->scheduleTask($task); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); + self::assertIsArray($task->getOutput()); + self::assertTrue(isset($task->getOutput()['output'])); + self::assertEquals('Hello Summarize', $task->getOutput()['output']); + self::assertTrue($this->providers[SuccessfulTextProcessingSummaryProvider::class]->ran); + } + + public function testShouldTransparentlyHandleFailingTextProcessingProviders() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', FailingTextProcessingSummaryProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + ]); + $taskTypes = $this->manager->getAvailableTaskTypes(); + self::assertCount(1, $taskTypes); + self::assertTrue(isset($taskTypes[TextToTextSummary::ID])); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToTextSummary::ID, ['input' => 'Hello'], 'test', null); + $this->manager->scheduleTask($task); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_FAILED, $task->getStatus()); + self::assertTrue($task->getOutput() === null); + self::assertEquals('ERROR', $task->getErrorMessage()); + self::assertTrue($this->providers[FailingTextProcessingSummaryProvider::class]->ran); + } + + public function testShouldTransparentlyHandleText2ImageProviders() { + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulTextToImageProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + ]); + $taskTypes = $this->manager->getAvailableTaskTypes(); + self::assertCount(1, $taskTypes); + self::assertTrue(isset($taskTypes[TextToImage::ID])); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToImage::ID, ['input' => 'Hello', 'numberOfImages' => 3], 'test', null); + $this->manager->scheduleTask($task); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); + self::assertIsArray($task->getOutput()); + self::assertTrue(isset($task->getOutput()['images'])); + self::assertIsArray($task->getOutput()['images']); + self::assertCount(3, $task->getOutput()['images']); + self::assertTrue($this->providers[SuccessfulTextToImageProvider::class]->ran); + } + + public function testShouldTransparentlyHandleFailingText2ImageProviders() { + $this->registrationContext->expects($this->any())->method('getTextToImageProviders')->willReturn([ + new ServiceRegistration('test', FailingTextToImageProvider::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + ]); + $taskTypes = $this->manager->getAvailableTaskTypes(); + self::assertCount(1, $taskTypes); + self::assertTrue(isset($taskTypes[TextToImage::ID])); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToImage::ID, ['input' => 'Hello', 'numberOfImages' => 3], 'test', null); + $this->manager->scheduleTask($task); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + $backgroundJob = new \OC\TaskProcessing\SynchronousBackgroundJob( + \OCP\Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + \OCP\Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_FAILED, $task->getStatus()); + self::assertTrue($task->getOutput() === null); + self::assertEquals('ERROR', $task->getErrorMessage()); + self::assertTrue($this->providers[FailingTextToImageProvider::class]->ran); + } } From 3593d9b631e42e987554af299cb511a4262c1d36 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 3 May 2024 14:39:41 +0200 Subject: [PATCH 21/63] fix: psalm issue Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 9a75217910d12..a72c0813e8946 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -345,7 +345,7 @@ public function process(?string $userId, array $input): array { } catch(\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('audio2text'); } - /** @var SimpleFile $file */ + /** @var SimpleFile $simpleFile */ $simpleFile = $folder->newFile(time() . '-' . rand(0, 100000), $input['input']->getContent()); $id = $simpleFile->getId(); /** @var File $file */ From 9a2cd6b914fbd2c4e3234aaf030cc09270863c7f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 09:21:09 +0200 Subject: [PATCH 22/63] fix: Expose task type on CoreTaskProcessingTask json Signed-off-by: Marcel Klehr --- core/ResponseDefinitions.php | 1 + lib/public/TaskProcessing/Task.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index ef577a614204a..68474a6e535c2 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -194,6 +194,7 @@ * * @psalm-type CoreTaskProcessingTask = array{ * id: ?int, + * type: string, * status: 0|1|2|3|4|5, * userId: ?string, * appId: string, diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index 4bddd06162fe1..eb4cbe345e168 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -193,12 +193,13 @@ final public function getUserId(): ?string { } /** - * @psalm-return array{id: ?int, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} + * @psalm-return array{id: ?int, type: string, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} * @since 30.0.0 */ public function jsonSerialize(): array { return [ 'id' => $this->getId(), + 'type' => $this->getTaskTypeId(), 'status' => $this->getStatus(), 'userId' => $this->getUserId(), 'appId' => $this->getAppId(), From 928d04fbbdf973290bca99c75c0cdfb2eed5ce5e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 09:30:48 +0200 Subject: [PATCH 23/63] fix: oc_taskProcessing_tasks.identifier: notnull = false Signed-off-by: Marcel Klehr --- core/Migrations/Version30000Date20240429122720.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/Migrations/Version30000Date20240429122720.php b/core/Migrations/Version30000Date20240429122720.php index 1f53aacd66b85..4bfbd86a6c70f 100644 --- a/core/Migrations/Version30000Date20240429122720.php +++ b/core/Migrations/Version30000Date20240429122720.php @@ -80,7 +80,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'default' => '', ]); $table->addColumn('identifier', Types::STRING, [ - 'notnull' => true, + 'notnull' => false, 'length' => 255, 'default' => '', ]); @@ -95,6 +95,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt ]); $table->addColumn('progress', Types::FLOAT, [ 'notnull' => false, + 'default' => 0, ]); $table->addColumn('error_message', Types::STRING, [ 'notnull' => false, From 9effb55989af9cb0227f7fb1270e73b6160e44d6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 09:37:46 +0200 Subject: [PATCH 24/63] chore: update openapi.json Signed-off-by: Marcel Klehr --- core/openapi.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/openapi.json b/core/openapi.json index 83a6bcc838376..d89aaeca92722 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -490,6 +490,7 @@ "type": "object", "required": [ "id", + "type", "status", "userId", "appId", @@ -505,6 +506,9 @@ "format": "int64", "nullable": true }, + "type": { + "type": "string" + }, "status": { "type": "integer", "format": "int64", From 996e5074ca43fbb049f496292a921012c3a49d63 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 09:44:48 +0200 Subject: [PATCH 25/63] feat: Remove stale files in AppData as well Signed-off-by: Marcel Klehr --- .../RemoveOldTasksBackgroundJob.php | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php index 54b63ac42fbab..c68ead4e67535 100644 --- a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php +++ b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php @@ -5,31 +5,63 @@ use OC\TaskProcessing\Db\TaskMapper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFolder; use Psr\Log\LoggerInterface; class RemoveOldTasksBackgroundJob extends TimedJob { public const MAX_TASK_AGE_SECONDS = 60 * 50 * 24 * 7 * 4; // 4 weeks + private \OCP\Files\IAppData $appData; public function __construct( ITimeFactory $timeFactory, private TaskMapper $taskMapper, private LoggerInterface $logger, + IAppDataFactory $appDataFactory, ) { parent::__construct($timeFactory); $this->setInterval(60 * 60 * 24); // can be deferred to maintenance window $this->setTimeSensitivity(TimedJob::TIME_INSENSITIVE); + $this->appData = $appDataFactory->get('core'); } /** * @inheritDoc */ - protected function run($argument) { + protected function run($argument): void { try { $this->taskMapper->deleteOlderThan(self::MAX_TASK_AGE_SECONDS); } catch (\OCP\DB\Exception $e) { - $this->logger->warning('Failed to delete stale language model tasks', ['exception' => $e]); + $this->logger->warning('Failed to delete stale task processing tasks', ['exception' => $e]); + } + try { + $this->clearFilesOlderThan($this->appData->getFolder('text2image'), self::MAX_TASK_AGE_SECONDS); + $this->clearFilesOlderThan($this->appData->getFolder('audio2text'), self::MAX_TASK_AGE_SECONDS); + $this->clearFilesOlderThan($this->appData->getFolder('TaskProcessing'), self::MAX_TASK_AGE_SECONDS); + } catch (NotFoundException $e) { + // noop + } + } + + /** + * @param ISimpleFolder $folder + * @param int $ageInSeconds + * @return void + */ + private function clearFilesOlderThan(ISimpleFolder $folder, int $ageInSeconds): void { + foreach($folder->getDirectoryListing() as $file) { + if ($file->getMTime() < time() - $ageInSeconds) { + try { + $file->delete(); + } catch (NotPermittedException $e) { + $this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]); + } + } } } + } From 6203c1c7da21041717e0ec2ecb3ba7f957822c74 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 10:03:24 +0200 Subject: [PATCH 26/63] fix: Check if user is authorized to use the files they mentioned Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 17 +++++++++-- lib/private/TaskProcessing/Manager.php | 30 ++++++++++++++++++- .../Exception/PreConditionNotMetException.php | 10 +++++++ .../Exception/UnauthorizedException.php | 10 +++++++ lib/public/TaskProcessing/IManager.php | 4 ++- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 lib/public/TaskProcessing/Exception/PreConditionNotMetException.php create mode 100644 lib/public/TaskProcessing/Exception/UnauthorizedException.php diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index d1084399b9079..de452c30aaa06 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -46,6 +46,7 @@ use OCP\PreConditionNotMetException; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Exception\Exception; +use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\ShapeDescriptor; use OCP\TaskProcessing\Task; @@ -124,10 +125,12 @@ public function schedule(array $input, string $type, string $appId, string $iden return new DataResponse([ 'task' => $json, ]); - } catch (PreConditionNotMetException) { + } catch (\OCP\TaskProcessing\Exception\PreConditionNotMetException) { return new DataResponse(['message' => $this->l->t('The given provider is not available')], Http::STATUS_PRECONDITION_FAILED); } catch (ValidationException $e) { return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (UnauthorizedException $e) { + return new DataResponse(['message' => 'User does not have access to the files mentioned in the task input'], Http::STATUS_UNAUTHORIZED); } catch (\OCP\TaskProcessing\Exception\Exception $e) { return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); } @@ -269,13 +272,21 @@ private function extractFileIdsFromTask(Task $task) { $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - $ids[] = $task->getInput()[$key]; + if (is_array($task->getInput()[$key])) { + $ids += $task->getInput()[$key]; + } else { + $ids[] = $task->getInput()[$key]; + } } } if ($task->getOutput() !== null) { foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - $ids[] = $task->getOutput()[$key]; + if (is_array($task->getInput()[$key])) { + $ids += $task->getOutput()[$key]; + } else { + $ids[] = $task->getOutput()[$key]; + } } } } diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index a72c0813e8946..2a09efacdf1f2 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -52,6 +52,7 @@ use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; +use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\IProvider; @@ -93,6 +94,7 @@ public function __construct( private \OCP\TextProcessing\IManager $textProcessingManager, private \OCP\TextToImage\IManager $textToImageManager, private \OCP\SpeechToText\ISpeechToTextManager $speechToTextManager, + private \OCP\Share\IManager $shareManager, ) { $this->appData = $appDataFactory->get('core'); } @@ -553,7 +555,7 @@ public function canHandleTask(Task $task): bool { public function scheduleTask(Task $task): void { if (!$this->canHandleTask($task)) { - throw new PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId()); + throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId()); } $taskTypes = $this->getAvailableTaskTypes(); $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape']; @@ -561,6 +563,32 @@ public function scheduleTask(Task $task): void { // validate input $this->validateInput($inputShape, $task->getInput()); $this->validateInput($optionalInputShape, $task->getInput(), true); + // authenticate access to mentioned files + $ids = []; + foreach ($inputShape + $optionalInputShape as $key => $descriptor) { + if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + if (is_array($task->getInput()[$key])) { + $ids += $task->getInput()[$key]; + } else { + $ids[] = $task->getInput()[$key]; + } + } + } + foreach ($ids as $fileId) { + $node = $this->rootFolder->getFirstNodeById($fileId); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if ($node === null) { + throw new ValidationException('Could not find file ' . $fileId); + } + } + /** @var array{users:array, remote: array, mail: array} $accessList */ + $accessList = $this->shareManager->getAccessList($node, true, true); + $userIds = array_map(fn ($id) => strval($id), array_keys($accessList['users'])); + if (!in_array($task->getUserId(), $userIds)) { + throw new UnauthorizedException('User ' . $task->getUserId() . ' does not have access to file ' . $fileId); + } + } // remove superfluous keys and set input $task->setInput($this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape)); $task->setStatus(Task::STATUS_SCHEDULED); diff --git a/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php b/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php new file mode 100644 index 0000000000000..11e5d642d03ff --- /dev/null +++ b/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php @@ -0,0 +1,10 @@ + Date: Mon, 6 May 2024 10:22:59 +0200 Subject: [PATCH 27/63] test: Test file authorization check Signed-off-by: Marcel Klehr --- .../lib/TaskProcessing/TaskProcessingTest.php | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 5be43314d3e82..596f831cde41a 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -31,6 +31,7 @@ use OCP\TaskProcessing\Events\TaskSuccessfulEvent; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; +use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\IProvider; @@ -362,6 +363,7 @@ protected function setUp(): void { \OC::$server->get(IAppDataFactory::class), ); + $this->shareManager = $this->createMock(\OCP\Share\IManager::class); $this->manager = new Manager( $this->coordinator, @@ -375,6 +377,7 @@ protected function setUp(): void { $textProcessingManager, $text2imageManager, \OC::$server->get(ISpeechToTextManager::class), + $this->shareManager, ); } @@ -399,7 +402,7 @@ public function testShouldNotHaveAnyProviders() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([]); self::assertCount(0, $this->manager->getAvailableTaskTypes()); self::assertFalse($this->manager->hasProviders()); - self::expectException(PreConditionNotMetException::class); + self::expectException(\OCP\TaskProcessing\Exception\PreConditionNotMetException::class); $this->manager->scheduleTask(new Task(TextToText::ID, ['input' => 'Hello'], 'test', null)); } @@ -415,6 +418,26 @@ public function testProviderShouldBeRegisteredAndTaskFailValidation() { $this->manager->scheduleTask($task); } + public function testProviderShouldBeRegisteredAndTaskWithFilesFailValidation() { + $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => []]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([ + new ServiceRegistration('test', AudioToImage::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', AsyncProvider::class) + ]); + $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => [null]]); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + + self::assertTrue($this->manager->hasProviders()); + $audioId = $this->getFile('audioInput', 'Hello')->getId(); + $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + self::expectException(UnauthorizedException::class); + $this->manager->scheduleTask($task); + } + public function testProviderShouldBeRegisteredAndFail() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', FailingSyncProvider::class) @@ -524,11 +547,12 @@ public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', AsyncProvider::class) ]); + $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => ['testuser' => 1]]); self::assertCount(1, $this->manager->getAvailableTaskTypes()); self::assertTrue($this->manager->hasProviders()); $audioId = $this->getFile('audioInput', 'Hello')->getId(); - $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', null); + $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', 'testuser'); self::assertNull($task->getId()); self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); $this->manager->scheduleTask($task); @@ -606,6 +630,7 @@ public function testOldTasksShouldBeCleanedUp() { $timeFactory, $this->taskMapper, \OC::$server->get(LoggerInterface::class), + \OCP\Server::get(IAppDataFactory::class), ); $bgJob->setArgument([]); $bgJob->start($this->jobList); From 8ccb29ae3b071f59d35f7fdc705ae0a3d2a07ea9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 11:01:23 +0200 Subject: [PATCH 28/63] fix: psalm issues Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index de452c30aaa06..a32b11451b362 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -104,7 +104,7 @@ public function taskTypes(): DataResponse { * @param string $appId ID of the app that will execute the task * @param string $identifier An arbitrary identifier for the task * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: Task scheduled successfully * 400: Scheduling task is not possible From a5053d33c2cad5ff6414132e441520e446245700 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 11:09:53 +0200 Subject: [PATCH 29/63] fix: Run cs:fix Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 1 - lib/private/TaskProcessing/Manager.php | 1 - lib/public/TaskProcessing/IManager.php | 2 +- tests/lib/TaskProcessing/TaskProcessingTest.php | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index a32b11451b362..da23b343cf0c0 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -43,7 +43,6 @@ use OCP\IL10N; use OCP\IRequest; use OCP\Lock\LockedException; -use OCP\PreConditionNotMetException; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\UnauthorizedException; diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 2a09efacdf1f2..d4c99d5c29be7 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -43,7 +43,6 @@ use OCP\IServerContainer; use OCP\L10N\IFactory; use OCP\Lock\LockedException; -use OCP\PreConditionNotMetException; use OCP\SpeechToText\ISpeechToTextProvider; use OCP\SpeechToText\ISpeechToTextProviderWithId; use OCP\SpeechToText\ISpeechToTextProviderWithUserId; diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 99e7938b507c3..8589a8a172128 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -29,9 +29,9 @@ use OCP\Files\GenericFileException; use OCP\Files\NotPermittedException; use OCP\Lock\LockedException; -use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; +use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 596f831cde41a..8b24459197cc0 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -24,7 +24,6 @@ use OCP\IConfig; use OCP\IDBConnection; use OCP\IServerContainer; -use OCP\PreConditionNotMetException; use OCP\SpeechToText\ISpeechToTextManager; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Events\TaskFailedEvent; From b85a0edc92f2894ed1674aa6216206a0e8fb1fcb Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 11:24:35 +0200 Subject: [PATCH 30/63] fix: Update autoloaders Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 1 + core/openapi.json | 38 +++++++++++++++++++ lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + 4 files changed, 43 insertions(+) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index da23b343cf0c0..e42e03424b8fc 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -108,6 +108,7 @@ public function taskTypes(): DataResponse { * 200: Task scheduled successfully * 400: Scheduling task is not possible * 412: Scheduling task is not possible + * 401: Cannot schedule task because it references files in its input that the user doesn't have access to */ #[PublicPage] #[UserRateLimit(limit: 20, period: 120)] diff --git a/core/openapi.json b/core/openapi.json index d89aaeca92722..ef2378d4e4e89 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -3607,6 +3607,44 @@ } } } + }, + "401": { + "description": "Cannot schedule task because it references files in its input that the user doesn't have access to", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } } } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 211d307e7bd78..dc8de79fb9d6d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -715,7 +715,9 @@ 'OCP\\TaskProcessing\\Events\\TaskSuccessfulEvent' => $baseDir . '/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php', 'OCP\\TaskProcessing\\Exception\\Exception' => $baseDir . '/lib/public/TaskProcessing/Exception/Exception.php', 'OCP\\TaskProcessing\\Exception\\NotFoundException' => $baseDir . '/lib/public/TaskProcessing/Exception/NotFoundException.php', + 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => $baseDir . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', 'OCP\\TaskProcessing\\Exception\\ProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/ProcessingException.php', + 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => $baseDir . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => $baseDir . '/lib/public/TaskProcessing/Exception/ValidationException.php', 'OCP\\TaskProcessing\\IManager' => $baseDir . '/lib/public/TaskProcessing/IManager.php', 'OCP\\TaskProcessing\\IProvider' => $baseDir . '/lib/public/TaskProcessing/IProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f352636e507fd..350831b51a707 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -756,7 +756,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\TaskProcessing\\Events\\TaskSuccessfulEvent' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Events/TaskSuccessfulEvent.php', 'OCP\\TaskProcessing\\Exception\\Exception' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/Exception.php', 'OCP\\TaskProcessing\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/NotFoundException.php', + 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', 'OCP\\TaskProcessing\\Exception\\ProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ProcessingException.php', + 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', 'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php', From 5de42a53e263d6dda1896ff223158646bd81867b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 11:57:05 +0200 Subject: [PATCH 31/63] fix: Don't use dynamic property Signed-off-by: Marcel Klehr --- tests/lib/TaskProcessing/TaskProcessingTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 8b24459197cc0..d5a31d3fd97a4 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -300,6 +300,8 @@ class TaskProcessingTest extends \Test\TestCase { private IJobList $jobList; private IAppData $appData; + private \OCP\Share\IManager $shareManager; + protected function setUp(): void { parent::setUp(); From 2c878099f1d2fbfc5b5d117c63fa8723d3e26f3d Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 13:03:03 +0200 Subject: [PATCH 32/63] fix: address review comments Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 20 +++++++++++-------- core/ResponseDefinitions.php | 2 +- core/openapi.json | 3 +-- lib/private/TaskProcessing/Db/Task.php | 4 ++-- lib/private/TaskProcessing/Manager.php | 6 ++++++ .../RemoveOldTasksBackgroundJob.php | 8 ++++++++ 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index e42e03424b8fc..2195d780f4598 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -183,9 +183,9 @@ public function deleteTask(int $id): DataResponse { $this->taskProcessingManager->deleteTask($task); - return new DataResponse([]); + return new DataResponse(null); } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { - return new DataResponse([]); + return new DataResponse(null); } catch (\OCP\TaskProcessing\Exception\Exception $e) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } @@ -272,20 +272,22 @@ private function extractFileIdsFromTask(Task $task) { $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - if (is_array($task->getInput()[$key])) { - $ids += $task->getInput()[$key]; + $inputSlot = $task->getInput()[$key]; + if (is_array($inputSlot)) { + $ids += $inputSlot; } else { - $ids[] = $task->getInput()[$key]; + $ids[] = $inputSlot; } } } if ($task->getOutput() !== null) { foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - if (is_array($task->getInput()[$key])) { - $ids += $task->getOutput()[$key]; + $outputSlot = $task->getOutput()[$key]; + if (is_array($outputSlot)) { + $ids += $outputSlot; } else { - $ids[] = $task->getOutput()[$key]; + $ids[] = $outputSlot; } } } @@ -338,7 +340,9 @@ public function setProgress(int $taskId, float $progress): DataResponse { #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/result', root: '/taskprocessing')] public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { try { + // Check if the current user can access the task $this->taskProcessingManager->getUserTask($taskId, $this->userId); + // set result $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output); $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 68474a6e535c2..75e307e455c15 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -193,7 +193,7 @@ * } * * @psalm-type CoreTaskProcessingTask = array{ - * id: ?int, + * id: int, * type: string, * status: 0|1|2|3|4|5, * userId: ?string, diff --git a/core/openapi.json b/core/openapi.json index ef2378d4e4e89..8adaae0668a59 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -503,8 +503,7 @@ "properties": { "id": { "type": "integer", - "format": "int64", - "nullable": true + "format": "int64" }, "type": { "type": "string" diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index a506ffd86c993..6e181285c9055 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -98,9 +98,9 @@ public function toRow(): array { }, self::$fields)); } - public static function fromPublicTask(OCPTask $task): Task { + public static function fromPublicTask(OCPTask $task): self { /** @var Task $taskEntity */ - $taskEntity = Task::fromParams([ + $taskEntity = self::fromParams([ 'id' => $task->getId(), 'type' => $task->getTaskTypeId(), 'lastUpdated' => time(), diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index d4c99d5c29be7..058437db3881e 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -385,6 +385,9 @@ private function _getProviders(): array { try { /** @var IProvider $provider */ $provider = $this->serverContainer->get($class); + if (isset($providers[$provider->getId()])) { + $this->logger->warning('Task processing provider ' . $class . ' is using ID ' . $provider->getId() . ' which is already used by ' . $providers[$provider->getId()]::class); + } $providers[$provider->getId()] = $provider; } catch (\Throwable $e) { $this->logger->error('Failed to load task processing provider ' . $class, [ @@ -423,6 +426,9 @@ private function _getTaskTypes(): array { try { /** @var ITaskType $provider */ $taskType = $this->serverContainer->get($class); + if (isset($taskTypes[$taskType->getId()])) { + $this->logger->warning('Task processing task type ' . $class . ' is using ID ' . $taskType->getId() . ' which is already used by ' . $taskTypes[$taskType->getId()]::class); + } $taskTypes[$taskType->getId()] = $taskType; } catch (\Throwable $e) { $this->logger->error('Failed to load task processing task type ' . $class, [ diff --git a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php index c68ead4e67535..2619b649e61e2 100644 --- a/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php +++ b/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php @@ -40,7 +40,15 @@ protected function run($argument): void { } try { $this->clearFilesOlderThan($this->appData->getFolder('text2image'), self::MAX_TASK_AGE_SECONDS); + } catch (NotFoundException $e) { + // noop + } + try { $this->clearFilesOlderThan($this->appData->getFolder('audio2text'), self::MAX_TASK_AGE_SECONDS); + } catch (NotFoundException $e) { + // noop + } + try { $this->clearFilesOlderThan($this->appData->getFolder('TaskProcessing'), self::MAX_TASK_AGE_SECONDS); } catch (NotFoundException $e) { // noop From ef61c50f4bcd5ea5911370c682aff19618a4d355 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 13:04:11 +0200 Subject: [PATCH 33/63] fix: address review comments Signed-off-by: Marcel Klehr --- core/Migrations/Version30000Date20240429122720.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/Migrations/Version30000Date20240429122720.php b/core/Migrations/Version30000Date20240429122720.php index 4bfbd86a6c70f..bbd59d9581396 100644 --- a/core/Migrations/Version30000Date20240429122720.php +++ b/core/Migrations/Version30000Date20240429122720.php @@ -102,10 +102,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'length' => 255, ]); - $table->setPrimaryKey(['id'], 'tasks_id_index'); - $table->addIndex(['status', 'type'], 'tasks_status_type'); - $table->addIndex(['last_updated'], 'tasks_updated'); - $table->addIndex(['user_id', 'app_id', 'identifier'], 'tasks_uid_appid_ident'); + $table->setPrimaryKey(['id'], 'taskp_tasks_id_index'); + $table->addIndex(['status', 'type'], 'taskp_tasks_status_type'); + $table->addIndex(['last_updated'], 'taskp_tasks_updated'); + $table->addIndex(['user_id', 'app_id', 'identifier'], 'taskp_tasks_uid_appid_ident'); return $schema; } From ec27c538b531108ab2cf26fe3264d001f9230aa2 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 6 May 2024 16:36:35 +0200 Subject: [PATCH 34/63] fix: address review comments Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 12 ++-- core/ResponseDefinitions.php | 6 +- core/openapi.json | 62 +++++++++++++---- .../Bootstrap/RegistrationContext.php | 4 +- lib/private/TaskProcessing/Manager.php | 69 +++++++++++-------- lib/public/TaskProcessing/IManager.php | 3 +- .../TaskProcessing/ISynchronousProvider.php | 7 +- lib/public/TaskProcessing/Task.php | 42 +++++++---- 8 files changed, 136 insertions(+), 69 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 2195d780f4598..d28d0baeb5db1 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -98,7 +98,7 @@ public function taskTypes(): DataResponse { /** * This endpoint allows scheduling a task * - * @param array $input Input text + * @param array $input Task's input parameters * @param string $type Type of the task * @param string $appId ID of the app that will execute the task * @param string $identifier An arbitrary identifier for the task @@ -171,7 +171,7 @@ public function getTask(int $id): DataResponse { * * @param int $id The id of the task * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: Task returned */ @@ -260,10 +260,10 @@ public function getFileContents(int $taskId, int $fileId): Http\DataDownloadResp /** * @param Task $task - * @return list + * @return list * @throws \OCP\TaskProcessing\Exception\NotFoundException */ - private function extractFileIdsFromTask(Task $task) { + private function extractFileIdsFromTask(Task $task): array { $ids = []; $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); if (!isset($taskTypes[$task->getTaskTypeId()])) { @@ -272,6 +272,7 @@ private function extractFileIdsFromTask(Task $task) { $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + /** @var int|list $inputSlot */ $inputSlot = $task->getInput()[$key]; if (is_array($inputSlot)) { $ids += $inputSlot; @@ -283,6 +284,7 @@ private function extractFileIdsFromTask(Task $task) { if ($task->getOutput() !== null) { foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { + /** @var int|list $outputSlot */ $outputSlot = $task->getOutput()[$key]; if (is_array($outputSlot)) { $ids += $outputSlot; @@ -292,7 +294,7 @@ private function extractFileIdsFromTask(Task $task) { } } } - return $ids; + return array_values($ids); } /** diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 75e307e455c15..bdf0005c9501b 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -195,11 +195,11 @@ * @psalm-type CoreTaskProcessingTask = array{ * id: int, * type: string, - * status: 0|1|2|3|4|5, + * status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', * userId: ?string, * appId: string, - * input: ?array, - * output: ?array, + * input: array|string|list>, + * output: ?array|string|list>, * identifier: ?string, * completionExpectedAt: ?int, * progress: ?float diff --git a/core/openapi.json b/core/openapi.json index 8adaae0668a59..6d5317e5f25dd 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -509,15 +509,14 @@ "type": "string" }, "status": { - "type": "integer", - "format": "int64", + "type": "string", "enum": [ - 0, - 1, - 2, - 3, - 4, - 5 + "STATUS_CANCELLED", + "STATUS_FAILED", + "STATUS_SUCCESSFUL", + "STATUS_RUNNING", + "STATUS_SCHEDULED", + "STATUS_UNKNOWN" ] }, "userId": { @@ -529,16 +528,53 @@ }, "input": { "type": "object", - "nullable": true, "additionalProperties": { - "type": "object" + "anyOf": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } }, "output": { "type": "object", "nullable": true, "additionalProperties": { - "type": "object" + "anyOf": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } }, "identifier": { @@ -3410,7 +3446,7 @@ { "name": "input", "in": "query", - "description": "Input text", + "description": "Task's input parameters", "required": true, "schema": { "type": "string" @@ -3861,7 +3897,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "nullable": true } } } diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 31f3dd7e4d2d2..9750933854cca 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -164,10 +164,10 @@ class RegistrationContext { private array $teamResourceProviders = []; /** @var ServiceRegistration<\OCP\TaskProcessing\IProvider>[] */ - private $taskProcessingProviders = []; + private array $taskProcessingProviders = []; /** @var ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] */ - private $taskProcessingTaskTypes = []; + private array $taskProcessingTaskTypes = []; public function __construct(LoggerInterface $logger) { $this->logger = $logger; diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 058437db3881e..7b0d31047368d 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -505,9 +505,10 @@ private function validateOutput(array $spec, array $io, bool $optional = false): } /** - * @param array $array The array to filter - * @param array ...$specs the specs that define which keys to keep - * @return array + * @param array $array The array to filter + * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep + * @return array + * @psalm-template T */ private function removeSuperfluousArrayKeys(array $array, ...$specs): array { $keys = array_unique(array_reduce($specs, fn ($carry, $spec) => $carry + array_keys($spec), [])); @@ -679,7 +680,7 @@ public function setTaskResult(int $id, ?string $error, ?array $result): void { $this->validateOutput($outputShape, $result); $this->validateOutput($optionalOutputShape, $result, true); $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape); - // extract base64 data and put it in files, replace it with file ids + // extract raw data and put it in files, replace it with file ids $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape); $task->setOutput($output); $task->setProgress(1); @@ -726,36 +727,12 @@ public function getNextScheduledTask(?string $taskTypeId = null): Task { } } - public function getUserTask(int $id, ?string $userId): Task { - try { - $taskEntity = $this->taskMapper->findByIdAndUser($id, $userId); - return $taskEntity->toPublicTask(); - } catch (DoesNotExistException $e) { - throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e); - } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) { - throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); - } catch (\JsonException $e) { - throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e); - } - } - - public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { - try { - $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); - return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); - } catch (\OCP\DB\Exception $e) { - throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); - } catch (\JsonException $e) { - throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e); - } - } - /** * Takes task input or output data and replaces fileIds with base64 data * + * @param array|numeric|string> $input * @param ShapeDescriptor[] ...$specs the specs - * @param array $input - * @return array + * @return array|numeric|string|File> * @throws GenericFileException * @throws LockedException * @throws NotPermittedException @@ -805,6 +782,30 @@ public function fillInputFileData(array $input, ...$specs): array { return $newInputOutput; } + public function getUserTask(int $id, ?string $userId): Task { + try { + $taskEntity = $this->taskMapper->findByIdAndUser($id, $userId); + return $taskEntity->toPublicTask(); + } catch (DoesNotExistException $e) { + throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e); + } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e); + } + } + + public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { + try { + $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); + return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e); + } + } + /** *Takes task input or output and replaces base64 data with file ids * @@ -846,6 +847,14 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { return $newOutput; } + /** + * @param Task $task + * @return array|numeric|string|File> + * @throws GenericFileException + * @throws LockedException + * @throws NotPermittedException + * @throws ValidationException + */ public function prepareInputData(Task $task): array { $taskTypes = $this->getAvailableTaskTypes(); $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape']; diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 8589a8a172128..71c04d009ef4f 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -26,6 +26,7 @@ namespace OCP\TaskProcessing; +use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\NotPermittedException; use OCP\Lock\LockedException; @@ -150,7 +151,7 @@ public function getUserTasksByApp(?string $userId, string $appId, ?string $ident * ie. this replaces file ids with base64 data * * @param Task $task - * @return array + * @return array|numeric|string|File> * @throws NotPermittedException * @throws GenericFileException * @throws LockedException diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php index 0b17c6b6d86ac..16d66414b6451 100644 --- a/lib/public/TaskProcessing/ISynchronousProvider.php +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -26,6 +26,7 @@ namespace OCP\TaskProcessing; +use OCP\Files\File; use OCP\TaskProcessing\Exception\ProcessingException; /** @@ -38,11 +39,11 @@ interface ISynchronousProvider extends IProvider { /** * Returns the shape of optional output parameters * - * @since 30.0.0 * @param null|string $userId The user that created the current task - * @param array $input The task input - * @psalm-return array + * @param array|numeric|string|File> $input The task input + * @psalm-return array|numeric|string> * @throws ProcessingException + *@since 30.0.0 */ public function process(?string $userId, array $input): array; } diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index eb4cbe345e168..3645970e4b34a 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -75,7 +75,8 @@ final class Task implements \JsonSerializable { protected int $status = self::STATUS_UNKNOWN; /** - * @param array $input + * @param string $taskTypeId + * @param array|numeric|string> $input * @param string $appId * @param string|null $userId * @param null|string $identifier An arbitrary identifier for this task. max length: 255 chars @@ -146,6 +147,7 @@ final public function setId(?int $id): void { } /** + * @param null|array|numeric|string> $output * @since 30.0.0 */ final public function setOutput(?array $output): void { @@ -153,7 +155,7 @@ final public function setOutput(?array $output): void { } /** - * @return array|null + * @return array|numeric|string>|null * @since 30.0.0 */ final public function getOutput(): ?array { @@ -161,7 +163,7 @@ final public function getOutput(): ?array { } /** - * @return array + * @return array|numeric|string> * @since 30.0.0 */ final public function getInput(): array { @@ -193,20 +195,20 @@ final public function getUserId(): ?string { } /** - * @psalm-return array{id: ?int, type: string, status: self::STATUS_*, userId: ?string, appId: string, input: ?array, output: ?array, identifier: ?string, completionExpectedAt: ?int, progress: ?float} + * @psalm-return array{id: ?int, type: string, status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', userId: ?string, appId: string, input: array|numeric|string>, output: ?array|numeric|string>, identifier: ?string, completionExpectedAt: ?int, progress: ?float} * @since 30.0.0 */ - public function jsonSerialize(): array { + final public function jsonSerialize(): array { return [ 'id' => $this->getId(), 'type' => $this->getTaskTypeId(), - 'status' => $this->getStatus(), + 'status' => self::statusToString($this->getStatus()), 'userId' => $this->getUserId(), 'appId' => $this->getAppId(), 'input' => $this->getInput(), 'output' => $this->getOutput(), 'identifier' => $this->getIdentifier(), - 'completionExpectedAt' => $this->getCompletionExpectedAt()->getTimestamp(), + 'completionExpectedAt' => $this->getCompletionExpectedAt()?->getTimestamp(), 'progress' => $this->getProgress(), ]; } @@ -216,7 +218,7 @@ public function jsonSerialize(): array { * @return void * @since 30.0.0 */ - public function setErrorMessage(?string $error) { + final public function setErrorMessage(?string $error) { $this->errorMessage = $error; } @@ -224,7 +226,7 @@ public function setErrorMessage(?string $error) { * @return string|null * @since 30.0.0 */ - public function getErrorMessage(): ?string { + final public function getErrorMessage(): ?string { return $this->errorMessage; } @@ -233,7 +235,7 @@ public function getErrorMessage(): ?string { * @return void * @since 30.0.0 */ - public function setInput(array $input): void { + final public function setInput(array $input): void { $this->input = $input; } @@ -243,7 +245,7 @@ public function setInput(array $input): void { * @throws ValidationException * @since 30.0.0 */ - public function setProgress(?float $progress): void { + final public function setProgress(?float $progress): void { if ($progress < 0 || $progress > 1.0) { throw new ValidationException('Progress must be between 0.0 and 1.0 inclusively; ' . $progress . ' given'); } @@ -254,7 +256,23 @@ public function setProgress(?float $progress): void { * @return float|null * @since 30.0.0 */ - public function getProgress(): ?float { + final public function getProgress(): ?float { return $this->progress; } + + /** + * @param int $status + * @return 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN' + * @since 30.0.0 + */ + final public static function statusToString(int $status): string { + return match ($status) { + self::STATUS_CANCELLED => 'STATUS_CANCELLED', + self::STATUS_FAILED => 'STATUS_FAILED', + self::STATUS_SUCCESSFUL => 'STATUS_SUCCESSFUL', + self::STATUS_RUNNING => 'STATUS_RUNNING', + self::STATUS_SCHEDULED => 'STATUS_SCHEDULED', + default => 'STATUS_UNKNOWN', + }; + } } From fff2fb8e77c7a58aa9323c13daa355d6d8f3a118 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 08:56:22 +0200 Subject: [PATCH 35/63] fix: psalm issue Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 7b0d31047368d..2770fd277abe3 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -573,10 +573,12 @@ public function scheduleTask(Task $task): void { $ids = []; foreach ($inputShape + $optionalInputShape as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { - if (is_array($task->getInput()[$key])) { - $ids += $task->getInput()[$key]; + /** @var list|int $inputSlot */ + $inputSlot = $task->getInput()[$key]; + if (is_array($inputSlot)) { + $ids += $inputSlot; } else { - $ids[] = $task->getInput()[$key]; + $ids[] = $inputSlot; } } } From 4a3b9b826ea532991f8636b621f92760c321e93e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 12:46:21 +0200 Subject: [PATCH 36/63] refactor: identifier is now customId/custom_id Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 4 +- core/ResponseDefinitions.php | 2 +- lib/private/Log/PsrLoggerAdapter.php | 37 +++++++++---------- lib/private/TaskProcessing/Db/Task.php | 12 +++--- lib/private/TaskProcessing/Db/TaskMapper.php | 8 ++-- lib/private/TaskProcessing/Manager.php | 4 +- .../SynchronousBackgroundJob.php | 2 +- lib/public/TaskProcessing/IManager.php | 4 +- .../TaskProcessing/ISynchronousProvider.php | 3 +- lib/public/TaskProcessing/Task.php | 12 +++--- 10 files changed, 44 insertions(+), 44 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index d28d0baeb5db1..0a63ccac14b2d 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -114,8 +114,8 @@ public function taskTypes(): DataResponse { #[UserRateLimit(limit: 20, period: 120)] #[AnonRateLimit(limit: 5, period: 120)] #[ApiRoute(verb: 'POST', url: '/schedule', root: '/taskprocessing')] - public function schedule(array $input, string $type, string $appId, string $identifier = ''): DataResponse { - $task = new Task($type, $input, $appId, $this->userId, $identifier); + public function schedule(array $input, string $type, string $appId, string $customId = ''): DataResponse { + $task = new Task($type, $input, $appId, $this->userId, $customId); try { $this->taskProcessingManager->scheduleTask($task); diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index bdf0005c9501b..bd6879796bea5 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -200,7 +200,7 @@ * appId: string, * input: array|string|list>, * output: ?array|string|list>, - * identifier: ?string, + * customId: ?string, * completionExpectedAt: ?int, * progress: ?float * } diff --git a/lib/private/Log/PsrLoggerAdapter.php b/lib/private/Log/PsrLoggerAdapter.php index 8b397ef890566..b126cb52d7778 100644 --- a/lib/private/Log/PsrLoggerAdapter.php +++ b/lib/private/Log/PsrLoggerAdapter.php @@ -31,7 +31,6 @@ use OCP\Log\IDataLogger; use Psr\Log\InvalidArgumentException; use Psr\Log\LoggerInterface; -use Stringable; use Throwable; use function array_key_exists; use function array_merge; @@ -53,10 +52,10 @@ private function containsThrowable(array $context): bool { /** * System is unusable. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function emergency(string|Stringable $message, array $context = []): void { + public function emergency($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -76,10 +75,10 @@ public function emergency(string|Stringable $message, array $context = []): void * Example: Entire website down, database unavailable, etc. This should * trigger the SMS alerts and wake you up. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function alert(string|Stringable $message, array $context = []): void { + public function alert($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -98,10 +97,10 @@ public function alert(string|Stringable $message, array $context = []): void { * * Example: Application component unavailable, unexpected exception. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function critical(string|Stringable $message, array $context = []): void { + public function critical($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -119,10 +118,10 @@ public function critical(string|Stringable $message, array $context = []): void * Runtime errors that do not require immediate action but should typically * be logged and monitored. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function error(string|Stringable $message, array $context = []): void { + public function error($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -142,10 +141,10 @@ public function error(string|Stringable $message, array $context = []): void { * Example: Use of deprecated APIs, poor use of an API, undesirable things * that are not necessarily wrong. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function warning(string|Stringable $message, array $context = []): void { + public function warning($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -162,10 +161,10 @@ public function warning(string|Stringable $message, array $context = []): void { /** * Normal but significant events. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function notice(string|Stringable $message, array $context = []): void { + public function notice($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -184,10 +183,10 @@ public function notice(string|Stringable $message, array $context = []): void { * * Example: User logs in, SQL logs. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function info(string|Stringable $message, array $context = []): void { + public function info($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -204,10 +203,10 @@ public function info(string|Stringable $message, array $context = []): void { /** * Detailed debug information. * - * @param string|Stringable $message + * @param $message * @param mixed[] $context */ - public function debug(string|Stringable $message, array $context = []): void { + public function debug($message, array $context = []): void { if ($this->containsThrowable($context)) { $this->logger->logException($context['exception'], array_merge( [ @@ -225,12 +224,12 @@ public function debug(string|Stringable $message, array $context = []): void { * Logs with an arbitrary level. * * @param mixed $level - * @param string|Stringable $message + * @param $message * @param mixed[] $context * * @throws InvalidArgumentException */ - public function log($level, string|Stringable $message, array $context = []): void { + public function log($level, $message, array $context = []): void { if (!is_int($level) || $level < ILogger::DEBUG || $level > ILogger::FATAL) { throw new InvalidArgumentException('Nextcloud allows only integer log levels'); } diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index 6e181285c9055..11892a1496050 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -43,7 +43,7 @@ * @method string|null getUserId() * @method setAppId(string $type) * @method string getAppId() - * @method setIdentifier(string $identifier) + * @method setIdentifier(string $customId) * @method string getIdentifier() * @method setCompletionExpectedAt(null|\DateTime $completionExpectedAt) * @method null|\DateTime getCompletionExpectedAt() @@ -60,7 +60,7 @@ class Task extends Entity { protected $status; protected $userId; protected $appId; - protected $identifier; + protected $customId; protected $completionExpectedAt; protected $errorMessage; protected $progress; @@ -68,12 +68,12 @@ class Task extends Entity { /** * @var string[] */ - public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'identifier', 'completion_expected_at', 'error_message', 'progress']; + public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'custom_id', 'completion_expected_at', 'error_message', 'progress']; /** * @var string[] */ - public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'identifier', 'completionExpectedAt', 'errorMessage', 'progress']; + public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'customId', 'completionExpectedAt', 'errorMessage', 'progress']; public function __construct() { @@ -86,7 +86,7 @@ public function __construct() { $this->addType('status', 'integer'); $this->addType('userId', 'string'); $this->addType('appId', 'string'); - $this->addType('identifier', 'string'); + $this->addType('customId', 'string'); $this->addType('completionExpectedAt', 'datetime'); $this->addType('errorMessage', 'string'); $this->addType('progress', 'float'); @@ -110,7 +110,7 @@ public static function fromPublicTask(OCPTask $task): self { 'errorMessage' => $task->getErrorMessage(), 'userId' => $task->getUserId(), 'appId' => $task->getAppId(), - 'identifier' => $task->getIdentifier(), + 'customId' => $task->getIdentifier(), 'completionExpectedAt' => $task->getCompletionExpectedAt(), 'progress' => $task->getProgress(), ]); diff --git a/lib/private/TaskProcessing/Db/TaskMapper.php b/lib/private/TaskProcessing/Db/TaskMapper.php index 7ba16105f4c45..bdb59e5180e1a 100644 --- a/lib/private/TaskProcessing/Db/TaskMapper.php +++ b/lib/private/TaskProcessing/Db/TaskMapper.php @@ -103,18 +103,18 @@ public function findByIdAndUser(int $id, ?string $userId): Task { /** * @param string $userId * @param string $appId - * @param string|null $identifier + * @param string|null $customId * @return list * @throws Exception */ - public function findUserTasksByApp(string $userId, string $appId, ?string $identifier = null): array { + public function findUserTasksByApp(string $userId, string $appId, ?string $customId = null): array { $qb = $this->db->getQueryBuilder(); $qb->select(Task::$columns) ->from($this->tableName) ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))) ->andWhere($qb->expr()->eq('app_id', $qb->createPositionalParameter($appId))); - if ($identifier !== null) { - $qb->andWhere($qb->expr()->eq('identifier', $qb->createPositionalParameter($identifier))); + if ($customId !== null) { + $qb->andWhere($qb->expr()->eq('custom_id', $qb->createPositionalParameter($customId))); } return array_values($this->findEntities($qb)); } diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 2770fd277abe3..86587a94c237c 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -797,9 +797,9 @@ public function getUserTask(int $id, ?string $userId): Task { } } - public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array { + public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array { try { - $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $identifier); + $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $customId); return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); } catch (\OCP\DB\Exception $e) { throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e); diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php index c7f4706c392a3..ba1844b1da1ea 100644 --- a/lib/private/TaskProcessing/SynchronousBackgroundJob.php +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -57,7 +57,7 @@ protected function run($argument) { return; } try { - $output = $provider->process($task->getUserId(), $input); + $output = $provider->process($task->getUserId(), $input, fn(float $progress) => $this->taskProcessingManager->setTaskProgress($task->getId(), $progress)); } catch (ProcessingException $e) { $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 71c04d009ef4f..e1a5c16546c53 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -138,13 +138,13 @@ public function getUserTask(int $id, ?string $userId): Task; /** * @param string|null $userId * @param string $appId - * @param string|null $identifier + * @param string|null $customId * @return list * @throws Exception If the query failed * @throws \JsonException If parsing the task input and output failed * @since 30.0.0 */ - public function getUserTasksByApp(?string $userId, string $appId, ?string $identifier = null): array; + public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array; /** * Prepare the task's input data, so it can be processed by the provider diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php index 16d66414b6451..a58ce6faf1bf4 100644 --- a/lib/public/TaskProcessing/ISynchronousProvider.php +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -41,9 +41,10 @@ interface ISynchronousProvider extends IProvider { * * @param null|string $userId The user that created the current task * @param array|numeric|string|File> $input The task input + * @param callable(float):bool $reportProgress Report the task progress. If this returns false, that means the task was cancelled and processing should be stopped. * @psalm-return array|numeric|string> * @throws ProcessingException *@since 30.0.0 */ - public function process(?string $userId, array $input): array; + public function process(?string $userId, array $input, callable $reportProgress): array; } diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index 3645970e4b34a..b6aa637627923 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -79,7 +79,7 @@ final class Task implements \JsonSerializable { * @param array|numeric|string> $input * @param string $appId * @param string|null $userId - * @param null|string $identifier An arbitrary identifier for this task. max length: 255 chars + * @param null|string $customId An arbitrary customId for this task. max length: 255 chars * @since 30.0.0 */ final public function __construct( @@ -87,7 +87,7 @@ final public function __construct( protected array $input, protected readonly string $appId, protected readonly ?string $userId, - protected readonly ?string $identifier = '', + protected readonly ?string $customId = '', ) { } @@ -182,8 +182,8 @@ final public function getAppId(): string { * @return null|string * @since 30.0.0 */ - final public function getIdentifier(): ?string { - return $this->identifier; + final public function getCustomId(): ?string { + return $this->customId; } /** @@ -195,7 +195,7 @@ final public function getUserId(): ?string { } /** - * @psalm-return array{id: ?int, type: string, status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', userId: ?string, appId: string, input: array|numeric|string>, output: ?array|numeric|string>, identifier: ?string, completionExpectedAt: ?int, progress: ?float} + * @psalm-return array{id: ?int, type: string, status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', userId: ?string, appId: string, input: array|numeric|string>, output: ?array|numeric|string>, customId: ?string, completionExpectedAt: ?int, progress: ?float} * @since 30.0.0 */ final public function jsonSerialize(): array { @@ -207,7 +207,7 @@ final public function jsonSerialize(): array { 'appId' => $this->getAppId(), 'input' => $this->getInput(), 'output' => $this->getOutput(), - 'identifier' => $this->getIdentifier(), + 'customId' => $this->getCustomId(), 'completionExpectedAt' => $this->getCompletionExpectedAt()?->getTimestamp(), 'progress' => $this->getProgress(), ]; From 20c09d1afb07302697a4c602ce6d47671fedbb6f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 12:50:53 +0200 Subject: [PATCH 37/63] fix: Don't check in barmani plugin in composer autoloader Signed-off-by: Marcel Klehr --- lib/composer/composer/autoload_static.php | 29 ----------------------- 1 file changed, 29 deletions(-) diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 350831b51a707..f265c984fc299 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -750,28 +750,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Talk\\IConversation' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversation.php', 'OCP\\Talk\\IConversationOptions' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversationOptions.php', '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\\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', - 'OCP\\TaskProcessing\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/NotFoundException.php', - 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', - 'OCP\\TaskProcessing\\Exception\\ProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ProcessingException.php', - 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', - 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', - 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', - 'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php', - 'OCP\\TaskProcessing\\ISynchronousProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ISynchronousProvider.php', - 'OCP\\TaskProcessing\\ITaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ITaskType.php', - 'OCP\\TaskProcessing\\ShapeDescriptor' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ShapeDescriptor.php', - 'OCP\\TaskProcessing\\Task' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Task.php', - 'OCP\\TaskProcessing\\TaskTypes\\AudioToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/AudioToText.php', - 'OCP\\TaskProcessing\\TaskTypes\\TextToImage' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToImage.php', - 'OCP\\TaskProcessing\\TaskTypes\\TextToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToText.php', - 'OCP\\TaskProcessing\\TaskTypes\\TextToTextHeadline' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php', - 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', - 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\Teams\\ITeamManager' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => __DIR__ . '/../../..' . '/lib/public/Teams/Team.php', @@ -1248,7 +1226,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Controller\\ReferenceController' => __DIR__ . '/../../..' . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php', - 'OC\\Core\\Controller\\TaskProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TaskProcessingApiController.php', 'OC\\Core\\Controller\\TeamsApiController' => __DIR__ . '/../../..' . '/core/Controller/TeamsApiController.php', 'OC\\Core\\Controller\\TextProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TextToImageApiController' => __DIR__ . '/../../..' . '/core/Controller/TextToImageApiController.php', @@ -1342,7 +1319,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version29000Date20240124132201' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132201.php', 'OC\\Core\\Migrations\\Version29000Date20240124132202' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132202.php', 'OC\\Core\\Migrations\\Version29000Date20240131122720' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240131122720.php', - 'OC\\Core\\Migrations\\Version30000Date20240429122720' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240429122720.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', @@ -1892,11 +1868,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Tags' => __DIR__ . '/../../..' . '/lib/private/Tags.php', 'OC\\Talk\\Broker' => __DIR__ . '/../../..' . '/lib/private/Talk/Broker.php', 'OC\\Talk\\ConversationOptions' => __DIR__ . '/../../..' . '/lib/private/Talk/ConversationOptions.php', - 'OC\\TaskProcessing\\Db\\Task' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/Task.php', - 'OC\\TaskProcessing\\Db\\TaskMapper' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/TaskMapper.php', - 'OC\\TaskProcessing\\Manager' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Manager.php', - 'OC\\TaskProcessing\\RemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php', - 'OC\\TaskProcessing\\SynchronousBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/SynchronousBackgroundJob.php', 'OC\\Teams\\TeamManager' => __DIR__ . '/../../..' . '/lib/private/Teams/TeamManager.php', 'OC\\TempManager' => __DIR__ . '/../../..' . '/lib/private/TempManager.php', 'OC\\TemplateLayout' => __DIR__ . '/../../..' . '/lib/private/TemplateLayout.php', From beeee262184b69d6ca2d9d55acc292a75f11ddeb Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 12:51:21 +0200 Subject: [PATCH 38/63] fix: bump OC_Version Signed-off-by: Marcel Klehr --- version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.php b/version.php index 97b78783d16f3..28e91a4702c46 100644 --- a/version.php +++ b/version.php @@ -9,7 +9,7 @@ // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level // when updating major/minor version number. -$OC_Version = [30, 0, 0, 0]; +$OC_Version = [30, 0, 0, 1]; // The human-readable string $OC_VersionString = '30.0.0 dev'; From 4d9a0eab5f4df480ebdf72d39e73c626583a2f16 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 13:04:31 +0200 Subject: [PATCH 39/63] fix: update openai specs Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 2 +- core/openapi.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 0a63ccac14b2d..6a88ff9a7315b 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -101,7 +101,7 @@ public function taskTypes(): DataResponse { * @param array $input Task's input parameters * @param string $type Type of the task * @param string $appId ID of the app that will execute the task - * @param string $identifier An arbitrary identifier for the task + * @param string $customId An arbitrary identifier for the task * * @return DataResponse|DataResponse * diff --git a/core/openapi.json b/core/openapi.json index 6d5317e5f25dd..ae64ec1e33dfc 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -496,7 +496,7 @@ "appId", "input", "output", - "identifier", + "customId", "completionExpectedAt", "progress" ], @@ -577,7 +577,7 @@ ] } }, - "identifier": { + "customId": { "type": "string", "nullable": true }, @@ -3471,7 +3471,7 @@ } }, { - "name": "identifier", + "name": "customId", "in": "query", "description": "An arbitrary identifier for the task", "schema": { From f2ab6cb0a9af462df5d8dd00f6487db2efcdea66 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 13:04:44 +0200 Subject: [PATCH 40/63] fix: fix psalm issues Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Db/Task.php | 8 ++++---- lib/private/TaskProcessing/Manager.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index 11892a1496050..9293f4dc2362e 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -43,8 +43,8 @@ * @method string|null getUserId() * @method setAppId(string $type) * @method string getAppId() - * @method setIdentifier(string $customId) - * @method string getIdentifier() + * @method setCustomId(string $customId) + * @method string getCustomId() * @method setCompletionExpectedAt(null|\DateTime $completionExpectedAt) * @method null|\DateTime getCompletionExpectedAt() * @method setErrorMessage(null|string $error) @@ -110,7 +110,7 @@ public static function fromPublicTask(OCPTask $task): self { 'errorMessage' => $task->getErrorMessage(), 'userId' => $task->getUserId(), 'appId' => $task->getAppId(), - 'customId' => $task->getIdentifier(), + 'customId' => $task->getCustomId(), 'completionExpectedAt' => $task->getCompletionExpectedAt(), 'progress' => $task->getProgress(), ]); @@ -122,7 +122,7 @@ public static function fromPublicTask(OCPTask $task): self { * @throws \JsonException */ public function toPublicTask(): OCPTask { - $task = new OCPTask($this->getType(), json_decode($this->getInput(), true, 512, JSON_THROW_ON_ERROR), $this->getAppId(), $this->getuserId(), $this->getIdentifier()); + $task = new OCPTask($this->getType(), json_decode($this->getInput(), true, 512, JSON_THROW_ON_ERROR), $this->getAppId(), $this->getuserId(), $this->getCustomId()); $task->setId($this->getId()); $task->setStatus($this->getStatus()); $task->setOutput(json_decode($this->getOutput(), true, 512, JSON_THROW_ON_ERROR)); diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 86587a94c237c..45d426c9b5427 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -148,7 +148,7 @@ public function getOptionalOutputShape(): array { return []; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { if ($this->provider instanceof \OCP\TextProcessing\IProviderWithUserId) { $this->provider->setUserId($userId); } @@ -258,7 +258,7 @@ public function getOptionalOutputShape(): array { return []; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { try { $folder = $this->appData->getFolder('text2image'); } catch(\OCP\Files\NotFoundException) { @@ -340,7 +340,7 @@ public function getOptionalOutputShape(): array { return []; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { try { $folder = $this->appData->getFolder('audio2text'); } catch(\OCP\Files\NotFoundException) { From c079a611815d973157be0a93b10c85e5cb505b38 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 13:23:53 +0200 Subject: [PATCH 41/63] feat: Add cancel endpoint to OCS API Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 32 ++++ core/openapi.json | 155 ++++++++++++++++++ lib/private/TaskProcessing/Manager.php | 3 + 3 files changed, 190 insertions(+) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 6a88ff9a7315b..9e0d7947a3a4d 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -360,4 +360,36 @@ public function setResult(int $taskId, ?array $output = null, ?string $errorMess return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * This endpoint cancels a task + * + * @param int $taskId The id of the task + * @return DataResponse|DataResponse + * + * 200: File content returned + * 404: Task not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/cancel', root: '/taskprocessing')] + public function cancelTask(int $taskId): DataResponse { + try { + // Check if the current user can access the task + $this->taskProcessingManager->getUserTask($taskId, $this->userId); + // set result + $this->taskProcessingManager->cancelTask($taskId); + $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); + + /** @var CoreTaskProcessingTask $json */ + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (\OCP\TaskProcessing\Exception\NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } } diff --git a/core/openapi.json b/core/openapi.json index ae64ec1e33dfc..eeb366f3031a9 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -4551,6 +4551,161 @@ } } }, + "/ocs/v2.php/taskprocessing/tasks/{taskId}/cancel": { + "post": { + "operationId": "task_processing_api-cancel-task", + "summary": "This endpoint cancels a task", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "File content returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "task" + ], + "properties": { + "task": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/teams/{teamId}/resources": { "get": { "operationId": "teams_api-resolve-one", diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 45d426c9b5427..685ac39ba38de 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -636,6 +636,9 @@ public function getTask(int $id): Task { public function cancelTask(int $id): void { $task = $this->getTask($id); + if ($task->getStatus() !== Task::STATUS_SCHEDULED && $task->getStatus() !== Task::STATUS_RUNNING) { + return; + } $task->setStatus(Task::STATUS_CANCELLED); $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); try { From a045e0c47a2f95b9e3385ba4350b413c07afa2d3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 13:25:33 +0200 Subject: [PATCH 42/63] fix: fix migration Signed-off-by: Marcel Klehr --- core/Migrations/Version30000Date20240429122720.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/Migrations/Version30000Date20240429122720.php b/core/Migrations/Version30000Date20240429122720.php index bbd59d9581396..2b9f3f5439d86 100644 --- a/core/Migrations/Version30000Date20240429122720.php +++ b/core/Migrations/Version30000Date20240429122720.php @@ -79,7 +79,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'length' => 32, 'default' => '', ]); - $table->addColumn('identifier', Types::STRING, [ + $table->addColumn('custom_id', Types::STRING, [ 'notnull' => false, 'length' => 255, 'default' => '', @@ -105,7 +105,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->setPrimaryKey(['id'], 'taskp_tasks_id_index'); $table->addIndex(['status', 'type'], 'taskp_tasks_status_type'); $table->addIndex(['last_updated'], 'taskp_tasks_updated'); - $table->addIndex(['user_id', 'app_id', 'identifier'], 'taskp_tasks_uid_appid_ident'); + $table->addIndex(['user_id', 'app_id', 'custom_id'], 'taskp_tasks_uid_appid_ident'); return $schema; } From 0e06d645d4429809cf080b7b9e7da218ebd549a6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 15:39:33 +0200 Subject: [PATCH 43/63] Update core/Migrations/Version30000Date20240429122720.php Co-authored-by: julien-nc Signed-off-by: Marcel Klehr --- core/Migrations/Version30000Date20240429122720.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Migrations/Version30000Date20240429122720.php b/core/Migrations/Version30000Date20240429122720.php index 2b9f3f5439d86..f4f16e07ada08 100644 --- a/core/Migrations/Version30000Date20240429122720.php +++ b/core/Migrations/Version30000Date20240429122720.php @@ -105,7 +105,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->setPrimaryKey(['id'], 'taskp_tasks_id_index'); $table->addIndex(['status', 'type'], 'taskp_tasks_status_type'); $table->addIndex(['last_updated'], 'taskp_tasks_updated'); - $table->addIndex(['user_id', 'app_id', 'custom_id'], 'taskp_tasks_uid_appid_ident'); + $table->addIndex(['user_id', 'app_id', 'custom_id'], 'taskp_tasks_uid_appid_cid'); return $schema; } From f6f4965294ea99952e45079c39969c8486708da9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 7 May 2024 15:45:34 +0200 Subject: [PATCH 44/63] fix: fix tests Signed-off-by: Marcel Klehr --- tests/lib/TaskProcessing/TaskProcessingTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index d5a31d3fd97a4..846644ab6c315 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -133,7 +133,7 @@ public function getOptionalOutputShape(): array { ]; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { return ['output' => $input['input']]; } } @@ -168,7 +168,7 @@ public function getOptionalOutputShape(): array { ]; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { throw new ProcessingException(self::ERROR_MESSAGE); } } @@ -202,7 +202,7 @@ public function getOptionalOutputShape(): array { ]; } - public function process(?string $userId, array $input): array { + public function process(?string $userId, array $input, callable $reportProgress): array { return []; } } From eff862b583450d870f8dd452181ebb6606a519cc Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 8 May 2024 09:20:25 +0200 Subject: [PATCH 45/63] fix: run cs:fix Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/SynchronousBackgroundJob.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php index ba1844b1da1ea..ee6064aa4c6d9 100644 --- a/lib/private/TaskProcessing/SynchronousBackgroundJob.php +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -57,7 +57,7 @@ protected function run($argument) { return; } try { - $output = $provider->process($task->getUserId(), $input, fn(float $progress) => $this->taskProcessingManager->setTaskProgress($task->getId(), $progress)); + $output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->taskProcessingManager->setTaskProgress($task->getId(), $progress)); } catch (ProcessingException $e) { $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); From ec94a672d72720530ffcfce92f9695d8b9a09e27 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 8 May 2024 09:20:25 +0200 Subject: [PATCH 46/63] fix(ocs): change /tasktypes response to combine optional and non-optional IO slots Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 12 ++++++---- core/ResponseDefinitions.php | 5 ++--- core/openapi.json | 22 +++++-------------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 9e0d7947a3a4d..6c06d17cef067 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -83,10 +83,14 @@ public function taskTypes(): DataResponse { $serializedTaskTypes[$key] = [ 'name' => $taskType['name'], 'description' => $taskType['description'], - 'inputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['inputShape']), - 'optionalInputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalInputShape']), - 'outputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['outputShape']), - 'optionalOutputShape' => array_map(fn (ShapeDescriptor $descriptor) => $descriptor->jsonSerialize(), $taskType['optionalOutputShape']), + 'inputShape' => array_map(fn (ShapeDescriptor $descriptor) => + $descriptor->jsonSerialize() + ['mandatory' => true], $taskType['inputShape']) + + array_map(fn (ShapeDescriptor $descriptor) => + $descriptor->jsonSerialize() + ['mandatory' => false], $taskType['optionalInputShape']), + 'outputShape' => array_map(fn (ShapeDescriptor $descriptor) => + $descriptor->jsonSerialize() + ['mandatory' => true], $taskType['outputShape']) + + array_map(fn (ShapeDescriptor $descriptor) => + $descriptor->jsonSerialize() + ['mandatory' => false], $taskType['optionalOutputShape']), ]; } diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index bd6879796bea5..98227d22edbaf 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -180,16 +180,15 @@ * @psalm-type CoreTaskProcessingShape = array{ * name: string, * description: string, - * type: int + * type: int, + * mandatory: bool, * } * * @psalm-type CoreTaskProcessingTaskType = array{ * name: string, * description: string, * inputShape: CoreTaskProcessingShape[], - * optionalInputShape: CoreTaskProcessingShape[], * outputShape: CoreTaskProcessingShape[], - * optionalOutputShape: CoreTaskProcessingShape[], * } * * @psalm-type CoreTaskProcessingTask = array{ diff --git a/core/openapi.json b/core/openapi.json index eeb366f3031a9..3a98e1c1ff2af 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -471,7 +471,8 @@ "required": [ "name", "description", - "type" + "type", + "mandatory" ], "properties": { "name": { @@ -483,6 +484,9 @@ "type": { "type": "integer", "format": "int64" + }, + "mandatory": { + "type": "boolean" } } }, @@ -599,9 +603,7 @@ "name", "description", "inputShape", - "optionalInputShape", - "outputShape", - "optionalOutputShape" + "outputShape" ], "properties": { "name": { @@ -616,23 +618,11 @@ "$ref": "#/components/schemas/TaskProcessingShape" } }, - "optionalInputShape": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskProcessingShape" - } - }, "outputShape": { "type": "array", "items": { "$ref": "#/components/schemas/TaskProcessingShape" } - }, - "optionalOutputShape": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskProcessingShape" - } } } }, From 19a0aaeb5e71a2431d347dc54b28aeaad4254c2c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 06:51:41 +0200 Subject: [PATCH 47/63] fix(TextToImage): Allow leaving the resources open Signed-off-by: Marcel Klehr --- .htaccess | 4 ++++ lib/private/TaskProcessing/Manager.php | 6 ++++++ tests/lib/TaskProcessing/TaskProcessingTest.php | 17 +++++++++-------- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.htaccess b/.htaccess index f6474c8dbca6a..21cff3568190a 100644 --- a/.htaccess +++ b/.htaccess @@ -108,3 +108,7 @@ AddDefaultCharset utf-8 Options -Indexes +#### DO NOT CHANGE ANYTHING ABOVE THIS LINE #### + +ErrorDocument 403 /index.php/error/403 +ErrorDocument 404 /index.php/error/404 diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 685ac39ba38de..b9259b5a4cd12 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -284,6 +284,12 @@ public function process(?string $userId, array $input, callable $reportProgress) } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } + for ($i = 0; $i < $input['numberOfImages']; $i++) { + if (is_resource($resources[$i])) { + // If $resource hasn't been closed yet, we'll do that here + fclose($resources[$i]); + } + } return ['images' => array_map(fn (ISimpleFile $file) => $file->getContent(), $files)]; } }; diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 846644ab6c315..ac220ad7c63d6 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -256,7 +256,6 @@ public function generate(string $prompt, array $resources): void { $this->ran = true; foreach($resources as $resource) { fwrite($resource, 'test'); - fclose($resource); } } @@ -299,8 +298,8 @@ class TaskProcessingTest extends \Test\TestCase { private TaskMapper $taskMapper; private IJobList $jobList; private IAppData $appData; - private \OCP\Share\IManager $shareManager; + private IRootFolder $rootFolder; protected function setUp(): void { parent::setUp(); @@ -332,6 +331,8 @@ protected function setUp(): void { $this->coordinator = $this->createMock(Coordinator::class); $this->coordinator->expects($this->any())->method('getRegistrationContext')->willReturn($this->registrationContext); + $this->rootFolder = \OCP\Server::get(IRootFolder::class); + $this->taskMapper = \OCP\Server::get(TaskMapper::class); $this->jobList = $this->createPartialMock(DummyJobList::class, ['add']); @@ -383,8 +384,6 @@ protected function setUp(): void { } private function getFile(string $name, string $content): \OCP\Files\File { - /** @var IRootFolder $rootFolder */ - $rootFolder = \OC::$server->get(IRootFolder::class); $this->appData = \OC::$server->get(IAppDataFactory::class)->get('core'); try { $folder = $this->appData->getFolder('test'); @@ -392,7 +391,7 @@ private function getFile(string $name, string $content): \OCP\Files\File { $folder = $this->appData->newFolder('test'); } $file = $folder->newFile($name, $content); - $inputFile = current($rootFolder->getByIdInPath($file->getId(), '/' . $rootFolder->getAppDataDirectoryName() . '/')); + $inputFile = current($this->rootFolder->getByIdInPath($file->getId(), '/' . $this->rootFolder->getAppDataDirectoryName() . '/')); if (!$inputFile instanceof \OCP\Files\File) { throw new \Exception('PEBCAK'); } @@ -581,12 +580,10 @@ public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); self::assertEquals(1, $task->getProgress()); self::assertTrue(isset($task->getOutput()['spectrogram'])); - $root = \OCP\Server::get(IRootFolder::class); - $node = $root->getFirstNodeByIdInPath($task->getOutput()['spectrogram'], '/' . $root->getAppDataDirectoryName() . '/'); + $node = $this->rootFolder->getFirstNodeByIdInPath($task->getOutput()['spectrogram'], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); self::assertNotNull($node); self::assertInstanceOf(\OCP\Files\File::class, $node); self::assertEquals('World', $node->getContent()); - } public function testNonexistentTask() { @@ -731,6 +728,10 @@ public function testShouldTransparentlyHandleText2ImageProviders() { self::assertIsArray($task->getOutput()['images']); self::assertCount(3, $task->getOutput()['images']); self::assertTrue($this->providers[SuccessfulTextToImageProvider::class]->ran); + $node = $this->rootFolder->getFirstNodeByIdInPath($task->getOutput()['images'][0], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + self::assertNotNull($node); + self::assertInstanceOf(\OCP\Files\File::class, $node); + self::assertEquals('test', $node->getContent()); } public function testShouldTransparentlyHandleFailingText2ImageProviders() { From f3a88f04ecb19da81cb53fb95f92a673a6892310 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 06:55:15 +0200 Subject: [PATCH 48/63] fix(OCS-API): No csrf required for /tasks/taskId/file/fileId Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 6c06d17cef067..c44764d83d546 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -237,6 +237,7 @@ public function listTasksByApp(string $appId, ?string $identifier = null): DataR * 404: Task or file not found */ #[NoAdminRequired] + #[Http\Attribute\NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] public function getFileContents(int $taskId, int $fileId): Http\DataDownloadResponse|DataResponse { try { From a8afa7f23d8bf0c1fdb1725669ff9d8d6b5ebebb Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 07:08:31 +0200 Subject: [PATCH 49/63] fix(OCS-API): Add endpoint to list user tasks Signed-off-by: Marcel Klehr --- .../TaskProcessingApiController.php | 36 ++++- core/openapi.json | 130 +++++++++++++++++- lib/private/TaskProcessing/Db/TaskMapper.php | 23 +++- lib/private/TaskProcessing/Manager.php | 11 ++ lib/public/TaskProcessing/IManager.php | 10 ++ 5 files changed, 205 insertions(+), 5 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index c44764d83d546..ce89ebd34bbf2 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -201,16 +201,46 @@ public function deleteTask(int $id): DataResponse { * with a specific appId and optionally with an identifier * * @param string $appId ID of the app - * @param string|null $identifier An arbitrary identifier for the task + * @param string|null $customId An arbitrary identifier for the task * @return DataResponse|DataResponse * * 200: Task list returned */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/taskprocessing')] - public function listTasksByApp(string $appId, ?string $identifier = null): DataResponse { + public function listTasksByApp(string $appId, ?string $customId = null): DataResponse { try { - $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $identifier); + $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $customId); + /** @var CoreTaskProcessingTask[] $json */ + $json = array_map(static function (Task $task) { + return $task->jsonSerialize(); + }, $tasks); + + return new DataResponse([ + 'tasks' => $json, + ]); + } catch (Exception $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\JsonException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * This endpoint returns a list of tasks of a user that are related + * with a specific appId and optionally with an identifier + * + * @param string|null $taskType The task type to filter by + * @param string|null $customId An arbitrary identifier for the task + * @return DataResponse|DataResponse + * + * 200: Task list returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/tasks', root: '/taskprocessing')] + public function listTasksByUser(?string $taskType, ?string $customId = null): DataResponse { + try { + $tasks = $this->taskProcessingManager->getUserTasks($this->userId, $taskType, $customId); /** @var CoreTaskProcessingTask[] $json */ $json = array_map(static function (Task $task) { return $task->jsonSerialize(); diff --git a/core/openapi.json b/core/openapi.json index 3a98e1c1ff2af..c6802be54919b 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -3954,7 +3954,7 @@ ], "parameters": [ { - "name": "identifier", + "name": "customId", "in": "query", "description": "An arbitrary identifier for the task", "schema": { @@ -4065,6 +4065,134 @@ } } }, + "/ocs/v2.php/taskprocessing/tasks": { + "get": { + "operationId": "task_processing_api-list-tasks-by-user", + "summary": "This endpoint returns a list of tasks of a user that are related with a specific appId and optionally with an identifier", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "taskType", + "in": "query", + "description": "The task type to filter by", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "customId", + "in": "query", + "description": "An arbitrary identifier for the task", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Task list returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "tasks" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskProcessingTask" + } + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/taskprocessing/tasks/{taskId}/file/{fileId}": { "get": { "operationId": "task_processing_api-get-file-contents", diff --git a/lib/private/TaskProcessing/Db/TaskMapper.php b/lib/private/TaskProcessing/Db/TaskMapper.php index bdb59e5180e1a..8a6b2eb3dd7f7 100644 --- a/lib/private/TaskProcessing/Db/TaskMapper.php +++ b/lib/private/TaskProcessing/Db/TaskMapper.php @@ -100,6 +100,27 @@ public function findByIdAndUser(int $id, ?string $userId): Task { return $this->findEntity($qb); } + /** + * @param string|null $userId + * @param string|null $taskType + * @param string|null $customId + * @return list + * @throws Exception + */ + public function findByUserAndTaskType(?string $userId, ?string $taskType = null, ?string $customId = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))); + if ($taskType !== null) { + $qb->andWhere($qb->expr()->eq('type', $qb->createPositionalParameter($taskType))); + } + if ($customId !== null) { + $qb->andWhere($qb->expr()->eq('custom_id', $qb->createPositionalParameter($customId))); + } + return array_values($this->findEntities($qb)); + } + /** * @param string $userId * @param string $appId @@ -107,7 +128,7 @@ public function findByIdAndUser(int $id, ?string $userId): Task { * @return list * @throws Exception */ - public function findUserTasksByApp(string $userId, string $appId, ?string $customId = null): array { + public function findUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array { $qb = $this->db->getQueryBuilder(); $qb->select(Task::$columns) ->from($this->tableName) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index b9259b5a4cd12..8fb9463116069 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -806,6 +806,17 @@ public function getUserTask(int $id, ?string $userId): Task { } } + public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array { + try { + $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $taskTypeId, $customId); + return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); + } catch (\OCP\DB\Exception $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e); + } catch (\JsonException $e) { + throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e); + } + } + public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array { try { $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $customId); diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index e1a5c16546c53..1b8bbf5769a00 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -135,6 +135,16 @@ public function getNextScheduledTask(?string $taskTypeId = null): Task; */ public function getUserTask(int $id, ?string $userId): Task; + /** + * @param string|null $userId The user id that scheduled the task + * @param string|null $taskTypeId The task type id to filter by + * @return list + * @throws Exception If the query failed + * @throws NotFoundException If the task could not be found + * @since 30.0.0 + */ + public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array; + /** * @param string|null $userId * @param string $appId From 9cc1a01ea0847b65ab8d2c90a1dd9582d6048058 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 07:18:14 +0200 Subject: [PATCH 50/63] test: Put input files in user storage Signed-off-by: Marcel Klehr --- .../lib/TaskProcessing/TaskProcessingTest.php | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index ac220ad7c63d6..bc4da99cbd954 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -24,6 +24,7 @@ use OCP\IConfig; use OCP\IDBConnection; use OCP\IServerContainer; +use OCP\IUserManager; use OCP\SpeechToText\ISpeechToTextManager; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Events\TaskFailedEvent; @@ -301,6 +302,8 @@ class TaskProcessingTest extends \Test\TestCase { private \OCP\Share\IManager $shareManager; private IRootFolder $rootFolder; + const TEST_USER = 'testuser'; + protected function setUp(): void { parent::setUp(); @@ -316,6 +319,11 @@ protected function setUp(): void { FailingTextToImageProvider::class => new FailingTextToImageProvider(), ]; + $userManager = \OCP\Server::get(IUserManager::class); + if (!$userManager->userExists(self::TEST_USER)) { + $userManager->createUser(self::TEST_USER, 'test'); + } + $this->serverContainer = $this->createMock(IServerContainer::class); $this->serverContainer->expects($this->any())->method('get')->willReturnCallback(function ($class) { return $this->providers[$class]; @@ -384,18 +392,9 @@ protected function setUp(): void { } private function getFile(string $name, string $content): \OCP\Files\File { - $this->appData = \OC::$server->get(IAppDataFactory::class)->get('core'); - try { - $folder = $this->appData->getFolder('test'); - } catch (\OCP\Files\NotFoundException $e) { - $folder = $this->appData->newFolder('test'); - } + $folder = $this->rootFolder->getUserFolder(self::TEST_USER); $file = $folder->newFile($name, $content); - $inputFile = current($this->rootFolder->getByIdInPath($file->getId(), '/' . $this->rootFolder->getAppDataDirectoryName() . '/')); - if (!$inputFile instanceof \OCP\Files\File) { - throw new \Exception('PEBCAK'); - } - return $inputFile; + return $file; } public function testShouldNotHaveAnyProviders() { From 715245a21ab6a2547d5c8cafb80aa9c1deaa9984 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 07:34:15 +0200 Subject: [PATCH 51/63] fix: run cs:fix Signed-off-by: Marcel Klehr --- tests/lib/TaskProcessing/TaskProcessingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index bc4da99cbd954..08be877113092 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -302,7 +302,7 @@ class TaskProcessingTest extends \Test\TestCase { private \OCP\Share\IManager $shareManager; private IRootFolder $rootFolder; - const TEST_USER = 'testuser'; + public const TEST_USER = 'testuser'; protected function setUp(): void { parent::setUp(); From e4b1d237682b34a8056ec1a53eddcf886786c1a9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Fri, 10 May 2024 07:42:03 +0200 Subject: [PATCH 52/63] fix(Manager#fillInputData): Load user folder if needed Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 8fb9463116069..595b33ddc902b 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -749,7 +749,11 @@ public function getNextScheduledTask(?string $taskTypeId = null): Task { * @throws NotPermittedException * @throws ValidationException */ - public function fillInputFileData(array $input, ...$specs): array { + public function fillInputFileData(?string $userId, array $input, ...$specs): array { + if ($userId !== null) { + // load user folder for later + $this->rootFolder->getUserFolder($userId); + } $newInputOutput = []; $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []); foreach($spec as $key => $descriptor) { @@ -886,7 +890,7 @@ public function prepareInputData(Task $task): array { $this->validateInput($inputShape, $input); $this->validateInput($optionalInputShape, $input, true); $input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape); - $input = $this->fillInputFileData($input, $inputShape, $optionalInputShape); + $input = $this->fillInputFileData($task->getUserId(), $input, $inputShape, $optionalInputShape); return $input; } } From c02049033a081d091770a19a7f371981daf3b8c9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 08:24:31 +0200 Subject: [PATCH 53/63] Update lib/private/TaskProcessing/Manager.php Co-authored-by: julien-nc Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 595b33ddc902b..97cb39ae7c8ac 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -812,7 +812,7 @@ public function getUserTask(int $id, ?string $userId): Task { public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array { try { - $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $taskTypeId, $customId); + $taskEntities = $this->taskMapper->findByUserAndTaskType($userId, $taskTypeId, $customId); return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities); } catch (\OCP\DB\Exception $e) { throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e); From b11052fcfa8b76618b81f64af3987b4382256264 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 08:24:46 +0200 Subject: [PATCH 54/63] Update lib/public/TaskProcessing/IManager.php Co-authored-by: julien-nc Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/IManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 1b8bbf5769a00..f031144ec6780 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -138,6 +138,7 @@ public function getUserTask(int $id, ?string $userId): Task; /** * @param string|null $userId The user id that scheduled the task * @param string|null $taskTypeId The task type id to filter by + * @param string|null $customId * @return list * @throws Exception If the query failed * @throws NotFoundException If the task could not be found From ac36c788d764e354b9d8b9ab0bea918e04c39e32 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 08:55:04 +0200 Subject: [PATCH 55/63] fix(SynchronousBackgroundJob): Only reschedule when needed Signed-off-by: Marcel Klehr --- .../SynchronousBackgroundJob.php | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php index ee6064aa4c6d9..d282d21f11322 100644 --- a/lib/private/TaskProcessing/SynchronousBackgroundJob.php +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -79,7 +79,24 @@ protected function run($argument) { } } - // Schedule again - $this->jobList->add(self::class, $argument); + $synchronousProviders = array_filter($providers, fn ($provider) => + $provider instanceof ISynchronousProvider); + $taskTypes = array_values(array_map(fn ($provider) => + $provider->getTaskTypeId(), + $synchronousProviders + )); + $taskTypesWithTasks = array_filter($taskTypes, function ($taskType) { + try { + $this->taskProcessingManager->getNextScheduledTask($taskType); + return true; + } catch (NotFoundException|Exception $e) { + return false; + } + }); + + if (count($taskTypesWithTasks) > 0) { + // Schedule again + $this->jobList->add(self::class, $argument); + } } } From a9a2cbf8bb6f6e81de4e0630615d6262b811c209 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 08:55:18 +0200 Subject: [PATCH 56/63] feat: Add some new task types Signed-off-by: Marcel Klehr --- .../TaskProcessing/TaskTypes/ContextWrite.php | 113 ++++++++++++++++++ .../TaskTypes/GenerateEmoji.php | 108 +++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 lib/public/TaskProcessing/TaskTypes/ContextWrite.php create mode 100644 lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php diff --git a/lib/public/TaskProcessing/TaskTypes/ContextWrite.php b/lib/public/TaskProcessing/TaskTypes/ContextWrite.php new file mode 100644 index 0000000000000..46491ec02833f --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/ContextWrite.php @@ -0,0 +1,113 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for generic text processing + * @since 30.0.0 + */ +class ContextWrite implements ITaskType { + /** + * @since 30.0.0 + */ + public const ID = 'core:contextwrite'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('ContextWrite'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Writes text in a given style based on the provided source material.'); + } + + /** + * @return string + * @since 30.0.0 + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ + public function getInputShape(): array { + return [ + 'style_input' => new ShapeDescriptor( + $this->l->t('Writing style'), + $this->l->t('Demonstrate a writing style that you would like to immitate'), + EShapeType::Text + ), + 'source_input' => new ShapeDescriptor( + $this->l->t('Source material'), + $this->l->t('The content that would like to be rewritten in the new writing style'), + EShapeType::Text + ), + ]; + } + + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Generated text'), + $this->l->t('The generated text with content from the source material in the given style'), + EShapeType::Text + ), + ]; + } +} diff --git a/lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php b/lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php new file mode 100644 index 0000000000000..ad6cec9b97e53 --- /dev/null +++ b/lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php @@ -0,0 +1,108 @@ + + * + * @author Marcel Klehr + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCP\TaskProcessing\TaskTypes; + +use OCP\IL10N; +use OCP\L10N\IFactory; +use OCP\TaskProcessing\EShapeType; +use OCP\TaskProcessing\ITaskType; +use OCP\TaskProcessing\ShapeDescriptor; + +/** + * This is the task processing task type for generic text processing + * @since 30.0.0 + */ +class GenerateEmoji implements ITaskType { + /** + * @since 30.0.0 + */ + public const ID = 'core:generateemoji'; + + private IL10N $l; + + /** + * @param IFactory $l10nFactory + * @since 30.0.0 + */ + public function __construct( + IFactory $l10nFactory, + ) { + $this->l = $l10nFactory->get('core'); + } + + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getName(): string { + return $this->l->t('Emoji generator'); + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getDescription(): string { + return $this->l->t('Takes text and generates a representative emoji for it.'); + } + + /** + * @return string + * @since 30.0.0 + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Input text'), + $this->l->t('The text to generate an emoji for'), + EShapeType::Text + ), + ]; + } + + /** + * @return ShapeDescriptor[] + * @since 30.0.0 + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Generated emoji'), + $this->l->t('The generated emoji based on the input text'), + EShapeType::Text + ), + ]; + } +} From c1f84aaad11e47edd3e1af518b30095259736ba0 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 09:04:56 +0200 Subject: [PATCH 57/63] fix(Manager#fillInputs): Try to setup user FS before access file inputs Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 97cb39ae7c8ac..956f708f09a20 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -741,6 +741,7 @@ public function getNextScheduledTask(?string $taskTypeId = null): Task { /** * Takes task input or output data and replaces fileIds with base64 data * + * @param string|null $userId * @param array|numeric|string> $input * @param ShapeDescriptor[] ...$specs the specs * @return array|numeric|string|File> @@ -751,8 +752,7 @@ public function getNextScheduledTask(?string $taskTypeId = null): Task { */ public function fillInputFileData(?string $userId, array $input, ...$specs): array { if ($userId !== null) { - // load user folder for later - $this->rootFolder->getUserFolder($userId); + \OC_Util::setupFS($userId); } $newInputOutput = []; $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []); @@ -780,9 +780,9 @@ public function fillInputFileData(?string $userId, array $input, ...$specs): arr } else { $newInputOutput[$key] = []; foreach ($input[$key] as $item) { - $node = $this->rootFolder->getFirstNodeById((int)$input[$key]); + $node = $this->rootFolder->getFirstNodeById((int)$item); if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath((int)$input[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + $node = $this->rootFolder->getFirstNodeByIdInPath((int)$item, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); if (!$node instanceof File) { throw new ValidationException('File id given for key "' . $key . '" is not a file'); } From 4ce5aaf54cc83d8362559423d335921250d36130 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 09:50:31 +0200 Subject: [PATCH 58/63] chore: Check in autoloader updates Signed-off-by: Marcel Klehr --- lib/composer/composer/autoload_classmap.php | 2 ++ lib/composer/composer/autoload_static.php | 31 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index dc8de79fb9d6d..16054f71a7305 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -726,6 +726,8 @@ 'OCP\\TaskProcessing\\ShapeDescriptor' => $baseDir . '/lib/public/TaskProcessing/ShapeDescriptor.php', 'OCP\\TaskProcessing\\Task' => $baseDir . '/lib/public/TaskProcessing/Task.php', 'OCP\\TaskProcessing\\TaskTypes\\AudioToText' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/AudioToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\ContextWrite' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/ContextWrite.php', + 'OCP\\TaskProcessing\\TaskTypes\\GenerateEmoji' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToImage' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToImage.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToText' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToText.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToTextHeadline' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f265c984fc299..3e97a6d3f184a 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -750,6 +750,30 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Talk\\IConversation' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversation.php', 'OCP\\Talk\\IConversationOptions' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversationOptions.php', '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\\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', + 'OCP\\TaskProcessing\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/NotFoundException.php', + 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', + 'OCP\\TaskProcessing\\Exception\\ProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ProcessingException.php', + 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', + 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', + 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', + 'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php', + 'OCP\\TaskProcessing\\ISynchronousProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ISynchronousProvider.php', + 'OCP\\TaskProcessing\\ITaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ITaskType.php', + 'OCP\\TaskProcessing\\ShapeDescriptor' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/ShapeDescriptor.php', + 'OCP\\TaskProcessing\\Task' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Task.php', + 'OCP\\TaskProcessing\\TaskTypes\\AudioToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/AudioToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\ContextWrite' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/ContextWrite.php', + 'OCP\\TaskProcessing\\TaskTypes\\GenerateEmoji' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/GenerateEmoji.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToImage' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToImage.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToText' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToText.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextHeadline' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextHeadline.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', + 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\Teams\\ITeamManager' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => __DIR__ . '/../../..' . '/lib/public/Teams/Team.php', @@ -1226,6 +1250,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Controller\\ReferenceController' => __DIR__ . '/../../..' . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php', + 'OC\\Core\\Controller\\TaskProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TaskProcessingApiController.php', 'OC\\Core\\Controller\\TeamsApiController' => __DIR__ . '/../../..' . '/core/Controller/TeamsApiController.php', 'OC\\Core\\Controller\\TextProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TextToImageApiController' => __DIR__ . '/../../..' . '/core/Controller/TextToImageApiController.php', @@ -1319,6 +1344,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version29000Date20240124132201' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132201.php', 'OC\\Core\\Migrations\\Version29000Date20240124132202' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240124132202.php', 'OC\\Core\\Migrations\\Version29000Date20240131122720' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20240131122720.php', + 'OC\\Core\\Migrations\\Version30000Date20240429122720' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240429122720.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', @@ -1868,6 +1894,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Tags' => __DIR__ . '/../../..' . '/lib/private/Tags.php', 'OC\\Talk\\Broker' => __DIR__ . '/../../..' . '/lib/private/Talk/Broker.php', 'OC\\Talk\\ConversationOptions' => __DIR__ . '/../../..' . '/lib/private/Talk/ConversationOptions.php', + 'OC\\TaskProcessing\\Db\\Task' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/Task.php', + 'OC\\TaskProcessing\\Db\\TaskMapper' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Db/TaskMapper.php', + 'OC\\TaskProcessing\\Manager' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/Manager.php', + 'OC\\TaskProcessing\\RemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/RemoveOldTasksBackgroundJob.php', + 'OC\\TaskProcessing\\SynchronousBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TaskProcessing/SynchronousBackgroundJob.php', 'OC\\Teams\\TeamManager' => __DIR__ . '/../../..' . '/lib/private/Teams/TeamManager.php', 'OC\\TempManager' => __DIR__ . '/../../..' . '/lib/private/TempManager.php', 'OC\\TemplateLayout' => __DIR__ . '/../../..' . '/lib/private/TemplateLayout.php', From 9864fc8bfa93bca8ca0cf25185310ac90f4e108b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 10:01:11 +0200 Subject: [PATCH 59/63] chore: fix htaccess Signed-off-by: Marcel Klehr --- .htaccess | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.htaccess b/.htaccess index 21cff3568190a..f6474c8dbca6a 100644 --- a/.htaccess +++ b/.htaccess @@ -108,7 +108,3 @@ AddDefaultCharset utf-8 Options -Indexes -#### DO NOT CHANGE ANYTHING ABOVE THIS LINE #### - -ErrorDocument 403 /index.php/error/403 -ErrorDocument 404 /index.php/error/404 From 8d063386d21fb41f9efba11d35c35dd8edde1c19 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 13 May 2024 11:50:14 +0200 Subject: [PATCH 60/63] fix: Fix pass-through stt provider Wasn't able to load File from app data Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 956f708f09a20..f72d86bb97e97 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -348,20 +348,7 @@ public function getOptionalOutputShape(): array { public function process(?string $userId, array $input, callable $reportProgress): array { try { - $folder = $this->appData->getFolder('audio2text'); - } catch(\OCP\Files\NotFoundException) { - $folder = $this->appData->newFolder('audio2text'); - } - /** @var SimpleFile $simpleFile */ - $simpleFile = $folder->newFile(time() . '-' . rand(0, 100000), $input['input']->getContent()); - $id = $simpleFile->getId(); - /** @var File $file */ - $file = current($this->rootFolder->getById($id)); - if ($this->provider instanceof ISpeechToTextProviderWithUserId) { - $this->provider->setUserId($userId); - } - try { - $result = $this->provider->transcribeFile($file); + $result = $this->provider->transcribeFile($input['input']); } catch (\RuntimeException $e) { throw new ProcessingException($e->getMessage(), 0, $e); } From f5a8bda1bad42e2ecc0e5601171a5f0967de65fa Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 May 2024 09:33:58 +0200 Subject: [PATCH 61/63] Update core/ResponseDefinitions.php Co-authored-by: Kate <26026535+provokateurin@users.noreply.github.com> Signed-off-by: Marcel Klehr --- core/ResponseDefinitions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 98227d22edbaf..23d89b25adfb0 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -198,7 +198,7 @@ * userId: ?string, * appId: string, * input: array|string|list>, - * output: ?array|string|list>, + * output: null|array|string|list>, * customId: ?string, * completionExpectedAt: ?int, * progress: ?float From cac812dc580079f2772623641ba70985714ad7b2 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 May 2024 10:01:09 +0200 Subject: [PATCH 62/63] fix: address review comments Signed-off-by: Marcel Klehr --- core/ResponseDefinitions.php | 8 +- core/openapi.json | 92 +++++++++---------- lib/private/TaskProcessing/Manager.php | 1 - lib/public/TaskProcessing/EShapeType.php | 12 +-- lib/public/TaskProcessing/ShapeDescriptor.php | 6 +- 5 files changed, 58 insertions(+), 61 deletions(-) diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 23d89b25adfb0..6b537a3c461c9 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -180,7 +180,7 @@ * @psalm-type CoreTaskProcessingShape = array{ * name: string, * description: string, - * type: int, + * type: "Number"|"Text"|"Audio"|"Image"|"Video"|"File"|"ListOfNumbers"|"ListOfTexts"|"ListOfImages"|"ListOfAudios"|"ListOfVideos"|"ListOfFiles", * mandatory: bool, * } * @@ -191,14 +191,16 @@ * outputShape: CoreTaskProcessingShape[], * } * + * @psalm-type CoreTaskProcessingIO = array|string|list> + * * @psalm-type CoreTaskProcessingTask = array{ * id: int, * type: string, * status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', * userId: ?string, * appId: string, - * input: array|string|list>, - * output: null|array|string|list>, + * input: CoreTaskProcessingIO, + * output: null|CoreTaskProcessingIO, * customId: ?string, * completionExpectedAt: ?int, * progress: ?float diff --git a/core/openapi.json b/core/openapi.json index c6802be54919b..d4067eac3cda8 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -466,6 +466,31 @@ } } }, + "TaskProcessingIO": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, "TaskProcessingShape": { "type": "object", "required": [ @@ -482,8 +507,21 @@ "type": "string" }, "type": { - "type": "integer", - "format": "int64" + "type": "string", + "enum": [ + "Number", + "Text", + "Audio", + "Image", + "Video", + "File", + "ListOfNumbers", + "ListOfTexts", + "ListOfImages", + "ListOfAudios", + "ListOfVideos", + "ListOfFiles" + ] }, "mandatory": { "type": "boolean" @@ -531,55 +569,11 @@ "type": "string" }, "input": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "array", - "items": { - "type": "number" - } - }, - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/TaskProcessingIO" }, "output": { - "type": "object", - "nullable": true, - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "array", - "items": { - "type": "number" - } - }, - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/components/schemas/TaskProcessingIO", + "nullable": true }, "customId": { "type": "string", diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index f72d86bb97e97..c0336675e6927 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -45,7 +45,6 @@ use OCP\Lock\LockedException; use OCP\SpeechToText\ISpeechToTextProvider; use OCP\SpeechToText\ISpeechToTextProviderWithId; -use OCP\SpeechToText\ISpeechToTextProviderWithUserId; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Events\TaskFailedEvent; use OCP\TaskProcessing\Events\TaskSuccessfulEvent; diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 5555671976b67..1f612a11dd59e 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -42,8 +42,8 @@ enum EShapeType: int { case ListOfNumbers = 10; case ListOfTexts = 11; case ListOfImages = 12; - case ListOfAudio = 13; - case ListOfVideo = 14; + case ListOfAudios = 13; + case ListOfVideos = 14; case ListOfFiles = 15; /** @@ -84,13 +84,13 @@ public function validateInput(mixed $value): void { if ($this === EShapeType::Audio && !is_numeric($value)) { throw new ValidationException('Non-audio item provided for Audio slot'); } - if ($this === EShapeType::ListOfAudio && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); } if ($this === EShapeType::Video && !is_numeric($value)) { throw new ValidationException('Non-video item provided for Video slot'); } - if ($this === EShapeType::ListOfVideo && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { throw new ValidationException('Non-video list item provided for ListOfTexts slot'); } if ($this === EShapeType::File && !is_numeric($value)) { @@ -116,13 +116,13 @@ public function validateOutput(mixed $value) { if ($this === EShapeType::Audio && !is_string($value)) { throw new ValidationException('Non-audio item provided for Audio slot'); } - if ($this === EShapeType::ListOfAudio && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); } if ($this === EShapeType::Video && !is_string($value)) { throw new ValidationException('Non-video item provided for Video slot'); } - if ($this === EShapeType::ListOfVideo && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { throw new ValidationException('Non-video list item provided for ListOfTexts slot'); } if ($this === EShapeType::File && !is_string($value)) { diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php index 6c14bab751e38..b389b0cebd21c 100644 --- a/lib/public/TaskProcessing/ShapeDescriptor.php +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -45,14 +45,16 @@ public function getShapeType(): EShapeType { } /** - * @return array{name: string, description: string, type: int} + * @return array{name: string, description: string, type: "Number"|"Text"|"Audio"|"Image"|"Video"|"File"|"ListOfNumbers"|"ListOfTexts"|"ListOfImages"|"ListOfAudios"|"ListOfVideos"|"ListOfFiles"} * @since 30.0.0 */ public function jsonSerialize(): array { + /** @var "Number"|"Text"|"Audio"|"Image"|"Video"|"File"|"ListOfNumbers"|"ListOfTexts"|"ListOfImages"|"ListOfAudios"|"ListOfVideos"|"ListOfFiles" $type */ + $type = $this->getShapeType()->name; return [ 'name' => $this->getName(), 'description' => $this->getDescription(), - 'type' => $this->getShapeType()->value, + 'type' => $type, ]; } } From 6c4992de54d7ce78ff19f588d403162b0c8f580a Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 15 May 2024 09:30:05 +0200 Subject: [PATCH 63/63] fix: expose lastUpdated in OCS API Signed-off-by: Marcel Klehr --- core/ResponseDefinitions.php | 1 + core/openapi.json | 5 +++++ lib/private/TaskProcessing/Db/Task.php | 1 + lib/public/TaskProcessing/Task.php | 22 +++++++++++++++++++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index 6b537a3c461c9..e0e8bed004459 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -195,6 +195,7 @@ * * @psalm-type CoreTaskProcessingTask = array{ * id: int, + * lastUpdated: int, * type: string, * status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', * userId: ?string, diff --git a/core/openapi.json b/core/openapi.json index d4067eac3cda8..50846c2d198a6 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -532,6 +532,7 @@ "type": "object", "required": [ "id", + "lastUpdated", "type", "status", "userId", @@ -547,6 +548,10 @@ "type": "integer", "format": "int64" }, + "lastUpdated": { + "type": "integer", + "format": "int64" + }, "type": { "type": "string" }, diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index 9293f4dc2362e..8a30f84e6bd9d 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -125,6 +125,7 @@ public function toPublicTask(): OCPTask { $task = new OCPTask($this->getType(), json_decode($this->getInput(), true, 512, JSON_THROW_ON_ERROR), $this->getAppId(), $this->getuserId(), $this->getCustomId()); $task->setId($this->getId()); $task->setStatus($this->getStatus()); + $task->setLastUpdated($this->getLastUpdated()); $task->setOutput(json_decode($this->getOutput(), true, 512, JSON_THROW_ON_ERROR)); $task->setCompletionExpectedAt($this->getCompletionExpectedAt()); $task->setErrorMessage($this->getErrorMessage()); diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index b6aa637627923..ee00446865ec8 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -44,6 +44,8 @@ final class Task implements \JsonSerializable { protected ?float $progress = null; + protected int $lastUpdated; + /** * @since 30.0.0 */ @@ -89,6 +91,7 @@ final public function __construct( protected readonly ?string $userId, protected readonly ?string $customId = '', ) { + $this->lastUpdated = time(); } /** @@ -195,13 +198,30 @@ final public function getUserId(): ?string { } /** - * @psalm-return array{id: ?int, type: string, status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', userId: ?string, appId: string, input: array|numeric|string>, output: ?array|numeric|string>, customId: ?string, completionExpectedAt: ?int, progress: ?float} + * @return int + * @since 30.0.0 + */ + final public function getLastUpdated(): int { + return $this->lastUpdated; + } + + /** + * @param int $lastUpdated + * @since 30.0.0 + */ + final public function setLastUpdated(int $lastUpdated): void { + $this->lastUpdated = $lastUpdated; + } + + /** + * @psalm-return array{id: ?int, lastUpdated: int, type: string, status: 'STATUS_CANCELLED'|'STATUS_FAILED'|'STATUS_SUCCESSFUL'|'STATUS_RUNNING'|'STATUS_SCHEDULED'|'STATUS_UNKNOWN', userId: ?string, appId: string, input: array|numeric|string>, output: ?array|numeric|string>, customId: ?string, completionExpectedAt: ?int, progress: ?float} * @since 30.0.0 */ final public function jsonSerialize(): array { return [ 'id' => $this->getId(), 'type' => $this->getTaskTypeId(), + 'lastUpdated' => $this->getLastUpdated(), 'status' => self::statusToString($this->getStatus()), 'userId' => $this->getUserId(), 'appId' => $this->getAppId(),