Completed
Push — master ( 71c2e9...f96713 )
by Marcel
46:55
created
lib/private/TaskProcessing/Manager.php 1 patch
Indentation   +1634 added lines, -1634 removed lines patch added patch discarded remove patch
@@ -74,1638 +74,1638 @@
 block discarded – undo
74 74
 
75 75
 class Manager implements IManager {
76 76
 
77
-	public const LEGACY_PREFIX_TEXTPROCESSING = 'legacy:TextProcessing:';
78
-	public const LEGACY_PREFIX_TEXTTOIMAGE = 'legacy:TextToImage:';
79
-	public const LEGACY_PREFIX_SPEECHTOTEXT = 'legacy:SpeechToText:';
80
-
81
-	public const LAZY_CONFIG_KEYS = [
82
-		'ai.taskprocessing_type_preferences',
83
-		'ai.taskprocessing_provider_preferences',
84
-	];
85
-
86
-	public const MAX_TASK_AGE_SECONDS = 60 * 60 * 24 * 31 * 6; // 6 months
87
-
88
-	private const TASK_TYPES_CACHE_KEY = 'available_task_types_v3';
89
-	private const TASK_TYPE_IDS_CACHE_KEY = 'available_task_type_ids';
90
-
91
-	/** @var list<IProvider>|null */
92
-	private ?array $providers = null;
93
-
94
-	/**
95
-	 * @var array<array-key,array{name: string, description: string, inputShape: ShapeDescriptor[], inputShapeEnumValues: ShapeEnumValue[][], inputShapeDefaults: array<array-key, numeric|string>, isInternal: bool, optionalInputShape: ShapeDescriptor[], optionalInputShapeEnumValues: ShapeEnumValue[][], optionalInputShapeDefaults: array<array-key, numeric|string>, outputShape: ShapeDescriptor[], outputShapeEnumValues: ShapeEnumValue[][], optionalOutputShape: ShapeDescriptor[], optionalOutputShapeEnumValues: ShapeEnumValue[][]}>
96
-	 */
97
-	private ?array $availableTaskTypes = null;
98
-
99
-	/** @var list<string>|null */
100
-	private ?array $availableTaskTypeIds = null;
101
-
102
-	private IAppData $appData;
103
-	private ?array $preferences = null;
104
-	private ?array $providersById = null;
105
-
106
-	/** @var ITaskType[]|null */
107
-	private ?array $taskTypes = null;
108
-	private ICache $distributedCache;
109
-
110
-	private ?GetTaskProcessingProvidersEvent $eventResult = null;
111
-
112
-	public function __construct(
113
-		private IAppConfig $appConfig,
114
-		private Coordinator $coordinator,
115
-		private IServerContainer $serverContainer,
116
-		private LoggerInterface $logger,
117
-		private TaskMapper $taskMapper,
118
-		private IJobList $jobList,
119
-		private IEventDispatcher $dispatcher,
120
-		IAppDataFactory $appDataFactory,
121
-		private IRootFolder $rootFolder,
122
-		private \OCP\TextToImage\IManager $textToImageManager,
123
-		private IUserMountCache $userMountCache,
124
-		private IClientService $clientService,
125
-		private IAppManager $appManager,
126
-		private IUserManager $userManager,
127
-		private IUserSession $userSession,
128
-		ICacheFactory $cacheFactory,
129
-		private IFactory $l10nFactory,
130
-	) {
131
-		$this->appData = $appDataFactory->get('core');
132
-		$this->distributedCache = $cacheFactory->createDistributed('task_processing::');
133
-	}
134
-
135
-
136
-	/**
137
-	 * This is almost a copy of textProcessingManager->getProviders
138
-	 * to avoid a dependency cycle between TextProcessingManager and TaskProcessingManager
139
-	 */
140
-	private function _getRawTextProcessingProviders(): array {
141
-		$context = $this->coordinator->getRegistrationContext();
142
-		if ($context === null) {
143
-			return [];
144
-		}
145
-
146
-		$providers = [];
147
-
148
-		foreach ($context->getTextProcessingProviders() as $providerServiceRegistration) {
149
-			$class = $providerServiceRegistration->getService();
150
-			try {
151
-				$providers[$class] = $this->serverContainer->get($class);
152
-			} catch (\Throwable $e) {
153
-				$this->logger->error('Failed to load Text processing provider ' . $class, [
154
-					'exception' => $e,
155
-				]);
156
-			}
157
-		}
158
-
159
-		return $providers;
160
-	}
161
-
162
-	private function _getTextProcessingProviders(): array {
163
-		$oldProviders = $this->_getRawTextProcessingProviders();
164
-		$newProviders = [];
165
-		foreach ($oldProviders as $oldProvider) {
166
-			$provider = new class($oldProvider) implements IProvider, ISynchronousProvider {
167
-				private \OCP\TextProcessing\IProvider $provider;
168
-
169
-				public function __construct(\OCP\TextProcessing\IProvider $provider) {
170
-					$this->provider = $provider;
171
-				}
172
-
173
-				public function getId(): string {
174
-					if ($this->provider instanceof \OCP\TextProcessing\IProviderWithId) {
175
-						return $this->provider->getId();
176
-					}
177
-					return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider::class;
178
-				}
179
-
180
-				public function getName(): string {
181
-					return $this->provider->getName();
182
-				}
183
-
184
-				public function getTaskTypeId(): string {
185
-					return match ($this->provider->getTaskType()) {
186
-						\OCP\TextProcessing\FreePromptTaskType::class => TextToText::ID,
187
-						\OCP\TextProcessing\HeadlineTaskType::class => TextToTextHeadline::ID,
188
-						\OCP\TextProcessing\TopicsTaskType::class => TextToTextTopics::ID,
189
-						\OCP\TextProcessing\SummaryTaskType::class => TextToTextSummary::ID,
190
-						default => Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider->getTaskType(),
191
-					};
192
-				}
193
-
194
-				public function getExpectedRuntime(): int {
195
-					if ($this->provider instanceof \OCP\TextProcessing\IProviderWithExpectedRuntime) {
196
-						return $this->provider->getExpectedRuntime();
197
-					}
198
-					return 60;
199
-				}
200
-
201
-				public function getOptionalInputShape(): array {
202
-					return [];
203
-				}
204
-
205
-				public function getOptionalOutputShape(): array {
206
-					return [];
207
-				}
208
-
209
-				public function process(?string $userId, array $input, callable $reportProgress): array {
210
-					if ($this->provider instanceof \OCP\TextProcessing\IProviderWithUserId) {
211
-						$this->provider->setUserId($userId);
212
-					}
213
-					try {
214
-						return ['output' => $this->provider->process($input['input'])];
215
-					} catch (\RuntimeException $e) {
216
-						throw new ProcessingException($e->getMessage(), previous: $e);
217
-					}
218
-				}
219
-
220
-				public function getInputShapeEnumValues(): array {
221
-					return [];
222
-				}
223
-
224
-				public function getInputShapeDefaults(): array {
225
-					return [];
226
-				}
227
-
228
-				public function getOptionalInputShapeEnumValues(): array {
229
-					return [];
230
-				}
231
-
232
-				public function getOptionalInputShapeDefaults(): array {
233
-					return [];
234
-				}
235
-
236
-				public function getOutputShapeEnumValues(): array {
237
-					return [];
238
-				}
239
-
240
-				public function getOptionalOutputShapeEnumValues(): array {
241
-					return [];
242
-				}
243
-			};
244
-			$newProviders[$provider->getId()] = $provider;
245
-		}
246
-
247
-		return $newProviders;
248
-	}
249
-
250
-	/**
251
-	 * @return ITaskType[]
252
-	 */
253
-	private function _getTextProcessingTaskTypes(): array {
254
-		$oldProviders = $this->_getRawTextProcessingProviders();
255
-		$newTaskTypes = [];
256
-		foreach ($oldProviders as $oldProvider) {
257
-			// These are already implemented in the TaskProcessing realm
258
-			if (in_array($oldProvider->getTaskType(), [
259
-				\OCP\TextProcessing\FreePromptTaskType::class,
260
-				\OCP\TextProcessing\HeadlineTaskType::class,
261
-				\OCP\TextProcessing\TopicsTaskType::class,
262
-				\OCP\TextProcessing\SummaryTaskType::class
263
-			], true)) {
264
-				continue;
265
-			}
266
-			$taskType = new class($oldProvider->getTaskType()) implements ITaskType {
267
-				private string $oldTaskTypeClass;
268
-				private \OCP\TextProcessing\ITaskType $oldTaskType;
269
-				private IL10N $l;
270
-
271
-				public function __construct(string $oldTaskTypeClass) {
272
-					$this->oldTaskTypeClass = $oldTaskTypeClass;
273
-					$this->oldTaskType = \OCP\Server::get($oldTaskTypeClass);
274
-					$this->l = \OCP\Server::get(IFactory::class)->get('core');
275
-				}
276
-
277
-				public function getId(): string {
278
-					return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->oldTaskTypeClass;
279
-				}
280
-
281
-				public function getName(): string {
282
-					return $this->oldTaskType->getName();
283
-				}
284
-
285
-				public function getDescription(): string {
286
-					return $this->oldTaskType->getDescription();
287
-				}
288
-
289
-				public function getInputShape(): array {
290
-					return ['input' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
291
-				}
292
-
293
-				public function getOutputShape(): array {
294
-					return ['output' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
295
-				}
296
-			};
297
-			$newTaskTypes[$taskType->getId()] = $taskType;
298
-		}
299
-
300
-		return $newTaskTypes;
301
-	}
302
-
303
-	/**
304
-	 * @return IProvider[]
305
-	 */
306
-	private function _getTextToImageProviders(): array {
307
-		$oldProviders = $this->textToImageManager->getProviders();
308
-		$newProviders = [];
309
-		foreach ($oldProviders as $oldProvider) {
310
-			$newProvider = new class($oldProvider, $this->appData) implements IProvider, ISynchronousProvider {
311
-				private \OCP\TextToImage\IProvider $provider;
312
-				private IAppData $appData;
313
-
314
-				public function __construct(\OCP\TextToImage\IProvider $provider, IAppData $appData) {
315
-					$this->provider = $provider;
316
-					$this->appData = $appData;
317
-				}
318
-
319
-				public function getId(): string {
320
-					return Manager::LEGACY_PREFIX_TEXTTOIMAGE . $this->provider->getId();
321
-				}
322
-
323
-				public function getName(): string {
324
-					return $this->provider->getName();
325
-				}
326
-
327
-				public function getTaskTypeId(): string {
328
-					return TextToImage::ID;
329
-				}
330
-
331
-				public function getExpectedRuntime(): int {
332
-					return $this->provider->getExpectedRuntime();
333
-				}
334
-
335
-				public function getOptionalInputShape(): array {
336
-					return [];
337
-				}
338
-
339
-				public function getOptionalOutputShape(): array {
340
-					return [];
341
-				}
342
-
343
-				public function process(?string $userId, array $input, callable $reportProgress): array {
344
-					try {
345
-						$folder = $this->appData->getFolder('text2image');
346
-					} catch (\OCP\Files\NotFoundException) {
347
-						$folder = $this->appData->newFolder('text2image');
348
-					}
349
-					$resources = [];
350
-					$files = [];
351
-					for ($i = 0; $i < $input['numberOfImages']; $i++) {
352
-						$file = $folder->newFile(time() . '-' . rand(1, 100000) . '-' . $i);
353
-						$files[] = $file;
354
-						$resource = $file->write();
355
-						if ($resource !== false && $resource !== true && is_resource($resource)) {
356
-							$resources[] = $resource;
357
-						} else {
358
-							throw new ProcessingException('Text2Image generation using provider "' . $this->getName() . '" failed: Couldn\'t open file to write.');
359
-						}
360
-					}
361
-					if ($this->provider instanceof \OCP\TextToImage\IProviderWithUserId) {
362
-						$this->provider->setUserId($userId);
363
-					}
364
-					try {
365
-						$this->provider->generate($input['input'], $resources);
366
-					} catch (\RuntimeException $e) {
367
-						throw new ProcessingException($e->getMessage(), previous: $e);
368
-					}
369
-					for ($i = 0; $i < $input['numberOfImages']; $i++) {
370
-						if (is_resource($resources[$i])) {
371
-							// If $resource hasn't been closed yet, we'll do that here
372
-							fclose($resources[$i]);
373
-						}
374
-					}
375
-					return ['images' => array_map(fn (ISimpleFile $file) => $file->getContent(), $files)];
376
-				}
377
-
378
-				public function getInputShapeEnumValues(): array {
379
-					return [];
380
-				}
381
-
382
-				public function getInputShapeDefaults(): array {
383
-					return [];
384
-				}
385
-
386
-				public function getOptionalInputShapeEnumValues(): array {
387
-					return [];
388
-				}
389
-
390
-				public function getOptionalInputShapeDefaults(): array {
391
-					return [];
392
-				}
393
-
394
-				public function getOutputShapeEnumValues(): array {
395
-					return [];
396
-				}
397
-
398
-				public function getOptionalOutputShapeEnumValues(): array {
399
-					return [];
400
-				}
401
-			};
402
-			$newProviders[$newProvider->getId()] = $newProvider;
403
-		}
404
-
405
-		return $newProviders;
406
-	}
407
-
408
-	/**
409
-	 * This is almost a copy of SpeechToTextManager->getProviders
410
-	 * to avoid a dependency cycle between SpeechToTextManager and TaskProcessingManager
411
-	 */
412
-	private function _getRawSpeechToTextProviders(): array {
413
-		$context = $this->coordinator->getRegistrationContext();
414
-		if ($context === null) {
415
-			return [];
416
-		}
417
-		$providers = [];
418
-		foreach ($context->getSpeechToTextProviders() as $providerServiceRegistration) {
419
-			$class = $providerServiceRegistration->getService();
420
-			try {
421
-				$providers[$class] = $this->serverContainer->get($class);
422
-			} catch (NotFoundExceptionInterface|ContainerExceptionInterface|\Throwable $e) {
423
-				$this->logger->error('Failed to load SpeechToText provider ' . $class, [
424
-					'exception' => $e,
425
-				]);
426
-			}
427
-		}
428
-
429
-		return $providers;
430
-	}
431
-
432
-	/**
433
-	 * @return IProvider[]
434
-	 */
435
-	private function _getSpeechToTextProviders(): array {
436
-		$oldProviders = $this->_getRawSpeechToTextProviders();
437
-		$newProviders = [];
438
-		foreach ($oldProviders as $oldProvider) {
439
-			$newProvider = new class($oldProvider, $this->rootFolder, $this->appData) implements IProvider, ISynchronousProvider {
440
-				private ISpeechToTextProvider $provider;
441
-				private IAppData $appData;
442
-
443
-				private IRootFolder $rootFolder;
444
-
445
-				public function __construct(ISpeechToTextProvider $provider, IRootFolder $rootFolder, IAppData $appData) {
446
-					$this->provider = $provider;
447
-					$this->rootFolder = $rootFolder;
448
-					$this->appData = $appData;
449
-				}
450
-
451
-				public function getId(): string {
452
-					if ($this->provider instanceof ISpeechToTextProviderWithId) {
453
-						return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider->getId();
454
-					}
455
-					return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider::class;
456
-				}
457
-
458
-				public function getName(): string {
459
-					return $this->provider->getName();
460
-				}
461
-
462
-				public function getTaskTypeId(): string {
463
-					return AudioToText::ID;
464
-				}
465
-
466
-				public function getExpectedRuntime(): int {
467
-					return 60;
468
-				}
469
-
470
-				public function getOptionalInputShape(): array {
471
-					return [];
472
-				}
473
-
474
-				public function getOptionalOutputShape(): array {
475
-					return [];
476
-				}
477
-
478
-				public function process(?string $userId, array $input, callable $reportProgress): array {
479
-					if ($this->provider instanceof \OCP\SpeechToText\ISpeechToTextProviderWithUserId) {
480
-						$this->provider->setUserId($userId);
481
-					}
482
-					try {
483
-						$result = $this->provider->transcribeFile($input['input']);
484
-					} catch (\RuntimeException $e) {
485
-						throw new ProcessingException($e->getMessage(), previous: $e);
486
-					}
487
-					return ['output' => $result];
488
-				}
489
-
490
-				public function getInputShapeEnumValues(): array {
491
-					return [];
492
-				}
493
-
494
-				public function getInputShapeDefaults(): array {
495
-					return [];
496
-				}
497
-
498
-				public function getOptionalInputShapeEnumValues(): array {
499
-					return [];
500
-				}
501
-
502
-				public function getOptionalInputShapeDefaults(): array {
503
-					return [];
504
-				}
505
-
506
-				public function getOutputShapeEnumValues(): array {
507
-					return [];
508
-				}
509
-
510
-				public function getOptionalOutputShapeEnumValues(): array {
511
-					return [];
512
-				}
513
-			};
514
-			$newProviders[$newProvider->getId()] = $newProvider;
515
-		}
516
-
517
-		return $newProviders;
518
-	}
519
-
520
-	/**
521
-	 * Dispatches the event to collect external providers and task types.
522
-	 * Caches the result within the request.
523
-	 */
524
-	private function dispatchGetProvidersEvent(): GetTaskProcessingProvidersEvent {
525
-		if ($this->eventResult !== null) {
526
-			return $this->eventResult;
527
-		}
528
-
529
-		$this->eventResult = new GetTaskProcessingProvidersEvent();
530
-		$this->dispatcher->dispatchTyped($this->eventResult);
531
-		return $this->eventResult ;
532
-	}
533
-
534
-	/**
535
-	 * @return IProvider[]
536
-	 */
537
-	private function _getProviders(): array {
538
-		$context = $this->coordinator->getRegistrationContext();
539
-
540
-		if ($context === null) {
541
-			return [];
542
-		}
543
-
544
-		$providers = [];
545
-
546
-		foreach ($context->getTaskProcessingProviders() as $providerServiceRegistration) {
547
-			$class = $providerServiceRegistration->getService();
548
-			try {
549
-				/** @var IProvider $provider */
550
-				$provider = $this->serverContainer->get($class);
551
-				if (isset($providers[$provider->getId()])) {
552
-					$this->logger->warning('Task processing provider ' . $class . ' is using ID ' . $provider->getId() . ' which is already used by ' . $providers[$provider->getId()]::class);
553
-				}
554
-				$providers[$provider->getId()] = $provider;
555
-			} catch (\Throwable $e) {
556
-				$this->logger->error('Failed to load task processing provider ' . $class, [
557
-					'exception' => $e,
558
-				]);
559
-			}
560
-		}
561
-
562
-		$event = $this->dispatchGetProvidersEvent();
563
-		$externalProviders = $event->getProviders();
564
-		foreach ($externalProviders as $provider) {
565
-			if (!isset($providers[$provider->getId()])) {
566
-				$providers[$provider->getId()] = $provider;
567
-			} else {
568
-				$this->logger->info('Skipping external task processing provider with ID ' . $provider->getId() . ' because a local provider with the same ID already exists.');
569
-			}
570
-		}
571
-
572
-		$providers += $this->_getTextProcessingProviders() + $this->_getTextToImageProviders() + $this->_getSpeechToTextProviders();
573
-
574
-		return $providers;
575
-	}
576
-
577
-	/**
578
-	 * @return ITaskType[]
579
-	 */
580
-	private function _getTaskTypes(): array {
581
-		$context = $this->coordinator->getRegistrationContext();
582
-
583
-		if ($context === null) {
584
-			return [];
585
-		}
586
-
587
-		if ($this->taskTypes !== null) {
588
-			return $this->taskTypes;
589
-		}
590
-
591
-		// Default task types
592
-		$taskTypes = [
593
-			\OCP\TaskProcessing\TaskTypes\TextToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToText::class),
594
-			\OCP\TaskProcessing\TaskTypes\TextToTextTopics::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTopics::class),
595
-			\OCP\TaskProcessing\TaskTypes\TextToTextHeadline::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextHeadline::class),
596
-			\OCP\TaskProcessing\TaskTypes\TextToTextSummary::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSummary::class),
597
-			\OCP\TaskProcessing\TaskTypes\TextToTextFormalization::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextFormalization::class),
598
-			\OCP\TaskProcessing\TaskTypes\TextToTextSimplification::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSimplification::class),
599
-			\OCP\TaskProcessing\TaskTypes\TextToTextChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChat::class),
600
-			\OCP\TaskProcessing\TaskTypes\TextToTextTranslate::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTranslate::class),
601
-			\OCP\TaskProcessing\TaskTypes\TextToTextReformulation::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextReformulation::class),
602
-			\OCP\TaskProcessing\TaskTypes\TextToImage::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToImage::class),
603
-			\OCP\TaskProcessing\TaskTypes\AudioToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToText::class),
604
-			\OCP\TaskProcessing\TaskTypes\ContextWrite::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextWrite::class),
605
-			\OCP\TaskProcessing\TaskTypes\GenerateEmoji::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\GenerateEmoji::class),
606
-			\OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::class),
607
-			\OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::class),
608
-			\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::class),
609
-			\OCP\TaskProcessing\TaskTypes\TextToTextProofread::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextProofread::class),
610
-			\OCP\TaskProcessing\TaskTypes\TextToSpeech::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToSpeech::class),
611
-			\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::class),
612
-			\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::class),
613
-			\OCP\TaskProcessing\TaskTypes\AnalyzeImages::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AnalyzeImages::class),
614
-			\OCP\TaskProcessing\TaskTypes\ImageToTextOpticalCharacterRecognition::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ImageToTextOpticalCharacterRecognition::class),
615
-		];
616
-
617
-		foreach ($context->getTaskProcessingTaskTypes() as $providerServiceRegistration) {
618
-			$class = $providerServiceRegistration->getService();
619
-			try {
620
-				/** @var ITaskType $provider */
621
-				$taskType = $this->serverContainer->get($class);
622
-				if (isset($taskTypes[$taskType->getId()])) {
623
-					$this->logger->warning('Task processing task type ' . $class . ' is using ID ' . $taskType->getId() . ' which is already used by ' . $taskTypes[$taskType->getId()]::class);
624
-				}
625
-				$taskTypes[$taskType->getId()] = $taskType;
626
-			} catch (\Throwable $e) {
627
-				$this->logger->error('Failed to load task processing task type ' . $class, [
628
-					'exception' => $e,
629
-				]);
630
-			}
631
-		}
632
-
633
-		$event = $this->dispatchGetProvidersEvent();
634
-		$externalTaskTypes = $event->getTaskTypes();
635
-		foreach ($externalTaskTypes as $taskType) {
636
-			if (isset($taskTypes[$taskType->getId()])) {
637
-				$this->logger->warning('External task processing task type is using ID ' . $taskType->getId() . ' which is already used by a locally registered task type (' . get_class($taskTypes[$taskType->getId()]) . ')');
638
-			}
639
-			$taskTypes[$taskType->getId()] = $taskType;
640
-		}
641
-
642
-		$taskTypes += $this->_getTextProcessingTaskTypes();
643
-
644
-		$this->taskTypes = $taskTypes;
645
-		return $this->taskTypes;
646
-	}
647
-
648
-	/**
649
-	 * @return array
650
-	 */
651
-	private function _getTaskTypeSettings(): array {
652
-		try {
653
-			$json = $this->appConfig->getValueString('core', 'ai.taskprocessing_type_preferences', '', lazy: true);
654
-			if ($json === '') {
655
-				return [];
656
-			}
657
-			return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
658
-		} catch (\JsonException $e) {
659
-			$this->logger->error('Failed to get settings. JSON Error in ai.taskprocessing_type_preferences', ['exception' => $e]);
660
-			$taskTypeSettings = [];
661
-			$taskTypes = $this->_getTaskTypes();
662
-			foreach ($taskTypes as $taskType) {
663
-				$taskTypeSettings[$taskType->getId()] = false;
664
-			};
665
-
666
-			return $taskTypeSettings;
667
-		}
668
-
669
-	}
670
-
671
-	/**
672
-	 * @param ShapeDescriptor[] $spec
673
-	 * @param array<array-key, string|numeric> $defaults
674
-	 * @param array<array-key, ShapeEnumValue[]> $enumValues
675
-	 * @param array $io
676
-	 * @param bool $optional
677
-	 * @return void
678
-	 * @throws ValidationException
679
-	 */
680
-	private static function validateInput(array $spec, array $defaults, array $enumValues, array $io, bool $optional = false): void {
681
-		foreach ($spec as $key => $descriptor) {
682
-			$type = $descriptor->getShapeType();
683
-			if (!isset($io[$key])) {
684
-				if ($optional) {
685
-					continue;
686
-				}
687
-				if (isset($defaults[$key])) {
688
-					if (EShapeType::getScalarType($type) !== $type) {
689
-						throw new ValidationException('Provider tried to set a default value for a non-scalar slot');
690
-					}
691
-					if (EShapeType::isFileType($type)) {
692
-						throw new ValidationException('Provider tried to set a default value for a slot that is not text or number');
693
-					}
694
-					$type->validateInput($defaults[$key]);
695
-					continue;
696
-				}
697
-				throw new ValidationException('Missing key: "' . $key . '"');
698
-			}
699
-			try {
700
-				$type->validateInput($io[$key]);
701
-				if ($type === EShapeType::Enum) {
702
-					if (!isset($enumValues[$key])) {
703
-						throw new ValidationException('Provider did not provide enum values for an enum slot: "' . $key . '"');
704
-					}
705
-					$type->validateEnum($io[$key], $enumValues[$key]);
706
-				}
707
-			} catch (ValidationException $e) {
708
-				throw new ValidationException('Failed to validate input key "' . $key . '": ' . $e->getMessage());
709
-			}
710
-		}
711
-	}
712
-
713
-	/**
714
-	 * Takes task input data and replaces fileIds with File objects
715
-	 *
716
-	 * @param array<array-key, list<numeric|string>|numeric|string> $input
717
-	 * @param array<array-key, numeric|string> ...$defaultSpecs the specs
718
-	 * @return array<array-key, list<numeric|string>|numeric|string>
719
-	 */
720
-	public function fillInputDefaults(array $input, ...$defaultSpecs): array {
721
-		$spec = array_reduce($defaultSpecs, fn ($carry, $spec) => array_merge($carry, $spec), []);
722
-		return array_merge($spec, $input);
723
-	}
724
-
725
-	/**
726
-	 * @param ShapeDescriptor[] $spec
727
-	 * @param array<array-key, ShapeEnumValue[]> $enumValues
728
-	 * @param array $io
729
-	 * @param bool $optional
730
-	 * @return void
731
-	 * @throws ValidationException
732
-	 */
733
-	private static function validateOutputWithFileIds(array $spec, array $enumValues, array $io, bool $optional = false): void {
734
-		foreach ($spec as $key => $descriptor) {
735
-			$type = $descriptor->getShapeType();
736
-			if (!isset($io[$key])) {
737
-				if ($optional) {
738
-					continue;
739
-				}
740
-				throw new ValidationException('Missing key: "' . $key . '"');
741
-			}
742
-			try {
743
-				$type->validateOutputWithFileIds($io[$key]);
744
-				if (isset($enumValues[$key])) {
745
-					$type->validateEnum($io[$key], $enumValues[$key]);
746
-				}
747
-			} catch (ValidationException $e) {
748
-				throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
749
-			}
750
-		}
751
-	}
752
-
753
-	/**
754
-	 * @param ShapeDescriptor[] $spec
755
-	 * @param array<array-key, ShapeEnumValue[]> $enumValues
756
-	 * @param array $io
757
-	 * @param bool $optional
758
-	 * @return void
759
-	 * @throws ValidationException
760
-	 */
761
-	private static function validateOutputWithFileData(array $spec, array $enumValues, array $io, bool $optional = false): void {
762
-		foreach ($spec as $key => $descriptor) {
763
-			$type = $descriptor->getShapeType();
764
-			if (!isset($io[$key])) {
765
-				if ($optional) {
766
-					continue;
767
-				}
768
-				throw new ValidationException('Missing key: "' . $key . '"');
769
-			}
770
-			try {
771
-				$type->validateOutputWithFileData($io[$key]);
772
-				if (isset($enumValues[$key])) {
773
-					$type->validateEnum($io[$key], $enumValues[$key]);
774
-				}
775
-			} catch (ValidationException $e) {
776
-				throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
777
-			}
778
-		}
779
-	}
780
-
781
-	/**
782
-	 * @param array<array-key, T> $array The array to filter
783
-	 * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
784
-	 * @return array<array-key, T>
785
-	 * @psalm-template T
786
-	 */
787
-	private function removeSuperfluousArrayKeys(array $array, ...$specs): array {
788
-		$keys = array_unique(array_reduce($specs, fn ($carry, $spec) => array_merge($carry, array_keys($spec)), []));
789
-		$keys = array_filter($keys, fn ($key) => array_key_exists($key, $array));
790
-		$values = array_map(fn (string $key) => $array[$key], $keys);
791
-		return array_combine($keys, $values);
792
-	}
793
-
794
-	public function hasProviders(): bool {
795
-		return count($this->getProviders()) !== 0;
796
-	}
797
-
798
-	public function getProviders(): array {
799
-		if ($this->providers === null) {
800
-			$this->providers = $this->_getProviders();
801
-		}
802
-
803
-		return $this->providers;
804
-	}
805
-
806
-	public function getPreferredProvider(string $taskTypeId) {
807
-		try {
808
-			if ($this->preferences === null) {
809
-				$this->preferences = $this->distributedCache->get('ai.taskprocessing_provider_preferences');
810
-				if ($this->preferences === null) {
811
-					$this->preferences = json_decode(
812
-						$this->appConfig->getValueString('core', 'ai.taskprocessing_provider_preferences', 'null', lazy: true),
813
-						associative: true,
814
-						flags: JSON_THROW_ON_ERROR,
815
-					);
816
-					$this->distributedCache->set('ai.taskprocessing_provider_preferences', $this->preferences, 60 * 3);
817
-				}
818
-			}
819
-
820
-			$providers = $this->getProviders();
821
-			if (isset($this->preferences[$taskTypeId])) {
822
-				$providersById = $this->providersById ?? array_reduce($providers, static function (array $carry, IProvider $provider) {
823
-					$carry[$provider->getId()] = $provider;
824
-					return $carry;
825
-				}, []);
826
-				$this->providersById = $providersById;
827
-				if (isset($providersById[$this->preferences[$taskTypeId]])) {
828
-					return $providersById[$this->preferences[$taskTypeId]];
829
-				}
830
-			}
831
-			// By default, use the first available provider
832
-			foreach ($providers as $provider) {
833
-				if ($provider->getTaskTypeId() === $taskTypeId) {
834
-					return $provider;
835
-				}
836
-			}
837
-		} catch (\JsonException $e) {
838
-			$this->logger->warning('Failed to parse provider preferences while getting preferred provider for task type ' . $taskTypeId, ['exception' => $e]);
839
-		}
840
-		throw new \OCP\TaskProcessing\Exception\Exception('No matching provider found');
841
-	}
842
-
843
-	public function getAvailableTaskTypes(bool $showDisabled = false, ?string $userId = null): array {
844
-		// We cache by language, because some task type fields are translated
845
-		$cacheKey = self::TASK_TYPES_CACHE_KEY . ':' . $this->l10nFactory->findLanguage();
846
-
847
-		// userId will be obtained from the session if left to null
848
-		if (!$this->checkGuestAccess($userId)) {
849
-			return [];
850
-		}
851
-		if ($this->availableTaskTypes === null) {
852
-			$cachedValue = $this->distributedCache->get($cacheKey);
853
-			if ($cachedValue !== null) {
854
-				$this->availableTaskTypes = unserialize($cachedValue);
855
-			}
856
-		}
857
-		// Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
858
-		if ($this->availableTaskTypes === null || $showDisabled) {
859
-			$taskTypes = $this->_getTaskTypes();
860
-			$taskTypeSettings = $this->_getTaskTypeSettings();
861
-
862
-			$availableTaskTypes = [];
863
-			foreach ($taskTypes as $taskType) {
864
-				if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
865
-					continue;
866
-				}
867
-				try {
868
-					$provider = $this->getPreferredProvider($taskType->getId());
869
-				} catch (\OCP\TaskProcessing\Exception\Exception $e) {
870
-					continue;
871
-				}
872
-				try {
873
-					$availableTaskTypes[$provider->getTaskTypeId()] = [
874
-						'name' => $taskType->getName(),
875
-						'description' => $taskType->getDescription(),
876
-						'optionalInputShape' => $provider->getOptionalInputShape(),
877
-						'inputShapeEnumValues' => $provider->getInputShapeEnumValues(),
878
-						'inputShapeDefaults' => $provider->getInputShapeDefaults(),
879
-						'inputShape' => $taskType->getInputShape(),
880
-						'optionalInputShapeEnumValues' => $provider->getOptionalInputShapeEnumValues(),
881
-						'optionalInputShapeDefaults' => $provider->getOptionalInputShapeDefaults(),
882
-						'outputShape' => $taskType->getOutputShape(),
883
-						'outputShapeEnumValues' => $provider->getOutputShapeEnumValues(),
884
-						'optionalOutputShape' => $provider->getOptionalOutputShape(),
885
-						'optionalOutputShapeEnumValues' => $provider->getOptionalOutputShapeEnumValues(),
886
-						'isInternal' => $taskType instanceof IInternalTaskType,
887
-					];
888
-				} catch (\Throwable $e) {
889
-					$this->logger->error('Failed to set up TaskProcessing provider ' . $provider::class, ['exception' => $e]);
890
-				}
891
-			}
892
-
893
-			if ($showDisabled) {
894
-				// Do not cache showDisabled, ever.
895
-				return $availableTaskTypes;
896
-			}
897
-
898
-			$this->availableTaskTypes = $availableTaskTypes;
899
-			$this->distributedCache->set($cacheKey, serialize($this->availableTaskTypes), 60);
900
-		}
901
-
902
-
903
-		return $this->availableTaskTypes;
904
-	}
905
-	public function getAvailableTaskTypeIds(bool $showDisabled = false, ?string $userId = null): array {
906
-		// userId will be obtained from the session if left to null
907
-		if (!$this->checkGuestAccess($userId)) {
908
-			return [];
909
-		}
910
-		if ($this->availableTaskTypeIds === null) {
911
-			$cachedValue = $this->distributedCache->get(self::TASK_TYPE_IDS_CACHE_KEY);
912
-			if ($cachedValue !== null) {
913
-				$this->availableTaskTypeIds = $cachedValue;
914
-			}
915
-		}
916
-		// Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
917
-		if ($this->availableTaskTypeIds === null || $showDisabled) {
918
-			$taskTypes = $this->_getTaskTypes();
919
-			$taskTypeSettings = $this->_getTaskTypeSettings();
920
-
921
-			$availableTaskTypeIds = [];
922
-			foreach ($taskTypes as $taskType) {
923
-				if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
924
-					continue;
925
-				}
926
-				try {
927
-					$provider = $this->getPreferredProvider($taskType->getId());
928
-				} catch (\OCP\TaskProcessing\Exception\Exception $e) {
929
-					continue;
930
-				}
931
-				$availableTaskTypeIds[] = $taskType->getId();
932
-			}
933
-
934
-			if ($showDisabled) {
935
-				// Do not cache showDisabled, ever.
936
-				return $availableTaskTypeIds;
937
-			}
938
-
939
-			$this->availableTaskTypeIds = $availableTaskTypeIds;
940
-			$this->distributedCache->set(self::TASK_TYPE_IDS_CACHE_KEY, $this->availableTaskTypeIds, 60);
941
-		}
942
-
943
-
944
-		return $this->availableTaskTypeIds;
945
-	}
946
-
947
-	public function canHandleTask(Task $task): bool {
948
-		return isset($this->getAvailableTaskTypes()[$task->getTaskTypeId()]);
949
-	}
950
-
951
-	private function checkGuestAccess(?string $userId = null): bool {
952
-		if ($userId === null && !$this->userSession->isLoggedIn()) {
953
-			return true;
954
-		}
955
-		if ($userId === null) {
956
-			$user = $this->userSession->getUser();
957
-		} else {
958
-			$user = $this->userManager->get($userId);
959
-		}
960
-
961
-		$guestsAllowed = $this->appConfig->getValueString('core', 'ai.taskprocessing_guests', 'false');
962
-		if ($guestsAllowed == 'true' || !class_exists(\OCA\Guests\UserBackend::class) || !($user->getBackend() instanceof \OCA\Guests\UserBackend)) {
963
-			return true;
964
-		}
965
-		return false;
966
-	}
967
-
968
-	public function scheduleTask(Task $task): void {
969
-		if (!$this->checkGuestAccess($task->getUserId())) {
970
-			throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
971
-		}
972
-		if (!$this->canHandleTask($task)) {
973
-			throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
974
-		}
975
-		$this->prepareTask($task);
976
-		$task->setStatus(Task::STATUS_SCHEDULED);
977
-		$this->storeTask($task);
978
-		// schedule synchronous job if the provider is synchronous
979
-		$provider = $this->getPreferredProvider($task->getTaskTypeId());
980
-		if ($provider instanceof ISynchronousProvider) {
981
-			$this->jobList->add(SynchronousBackgroundJob::class, null);
982
-		}
983
-		if ($provider instanceof ITriggerableProvider) {
984
-			try {
985
-				if (!$this->taskMapper->hasRunningTasksForTaskType($task->getTaskTypeId())) {
986
-					// If no tasks are currently running for this task type, nudge the provider to ask for tasks
987
-					try {
988
-						$provider->trigger();
989
-					} catch (\Throwable $e) {
990
-						$this->logger->error('Failed to trigger the provider after scheduling a task.', [
991
-							'exception' => $e,
992
-							'taskId' => $task->getId(),
993
-							'providerId' => $provider->getId(),
994
-						]);
995
-					}
996
-				}
997
-			} catch (Exception $e) {
998
-				$this->logger->error('Failed to check DB for running tasks after a task was scheduled for a triggerable provider. Not triggering the provider.', [
999
-					'exception' => $e,
1000
-					'taskId' => $task->getId(),
1001
-					'providerId' => $provider->getId()
1002
-				]);
1003
-			}
1004
-		}
1005
-	}
1006
-
1007
-	public function runTask(Task $task): Task {
1008
-		if (!$this->checkGuestAccess($task->getUserId())) {
1009
-			throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
1010
-		}
1011
-		if (!$this->canHandleTask($task)) {
1012
-			throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
1013
-		}
1014
-
1015
-		$provider = $this->getPreferredProvider($task->getTaskTypeId());
1016
-		if ($provider instanceof ISynchronousProvider) {
1017
-			$this->prepareTask($task);
1018
-			$task->setStatus(Task::STATUS_SCHEDULED);
1019
-			$this->storeTask($task);
1020
-			$this->processTask($task, $provider);
1021
-			$task = $this->getTask($task->getId());
1022
-		} else {
1023
-			$this->scheduleTask($task);
1024
-			// poll task
1025
-			while ($task->getStatus() === Task::STATUS_SCHEDULED || $task->getStatus() === Task::STATUS_RUNNING) {
1026
-				sleep(1);
1027
-				$task = $this->getTask($task->getId());
1028
-			}
1029
-		}
1030
-		return $task;
1031
-	}
1032
-
1033
-	public function processTask(Task $task, ISynchronousProvider $provider): bool {
1034
-		try {
1035
-			try {
1036
-				$input = $this->prepareInputData($task);
1037
-			} catch (GenericFileException|NotPermittedException|LockedException|ValidationException|UnauthorizedException $e) {
1038
-				$this->logger->warning('Failed to prepare input data for a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
1039
-				$this->setTaskResult($task->getId(), $e->getMessage(), null);
1040
-				return false;
1041
-			}
1042
-			try {
1043
-				$this->setTaskStatus($task, Task::STATUS_RUNNING);
1044
-				if ($provider instanceof ISynchronousWatermarkingProvider) {
1045
-					$output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress), $task->getIncludeWatermark());
1046
-				} else {
1047
-					$output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress));
1048
-				}
1049
-			} catch (ProcessingException $e) {
1050
-				$this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
1051
-				$userFacingErrorMessage = $e instanceof UserFacingProcessingException ? $e->getUserFacingMessage() : null;
1052
-				$this->setTaskResult($task->getId(), $e->getMessage(), null, userFacingError: $userFacingErrorMessage);
1053
-				return false;
1054
-			} catch (\Throwable $e) {
1055
-				$this->logger->error('Unknown error while processing TaskProcessing task', ['exception' => $e]);
1056
-				$this->setTaskResult($task->getId(), $e->getMessage(), null);
1057
-				return false;
1058
-			}
1059
-			$this->setTaskResult($task->getId(), null, $output);
1060
-		} catch (NotFoundException $e) {
1061
-			$this->logger->info('Could not find task anymore after execution. Moving on.', ['exception' => $e]);
1062
-		} catch (Exception $e) {
1063
-			$this->logger->error('Failed to report result of TaskProcessing task', ['exception' => $e]);
1064
-		}
1065
-		return true;
1066
-	}
1067
-
1068
-	public function deleteTask(Task $task): void {
1069
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1070
-		$this->taskMapper->delete($taskEntity);
1071
-	}
1072
-
1073
-	public function getTask(int $id): Task {
1074
-		try {
1075
-			$taskEntity = $this->taskMapper->find($id);
1076
-			return $taskEntity->toPublicTask();
1077
-		} catch (DoesNotExistException $e) {
1078
-			throw new NotFoundException('Couldn\'t find task with id ' . $id, 0, $e);
1079
-		} catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
1080
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1081
-		} catch (\JsonException $e) {
1082
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
1083
-		}
1084
-	}
1085
-
1086
-	public function cancelTask(int $id): void {
1087
-		$task = $this->getTask($id);
1088
-		if ($task->getStatus() !== Task::STATUS_SCHEDULED && $task->getStatus() !== Task::STATUS_RUNNING) {
1089
-			return;
1090
-		}
1091
-		$task->setStatus(Task::STATUS_CANCELLED);
1092
-		$task->setEndedAt(time());
1093
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1094
-		try {
1095
-			$this->taskMapper->update($taskEntity);
1096
-			$this->runWebhook($task);
1097
-		} catch (\OCP\DB\Exception $e) {
1098
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1099
-		}
1100
-	}
1101
-
1102
-	public function setTaskProgress(int $id, float $progress): bool {
1103
-		// TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
1104
-		$task = $this->getTask($id);
1105
-		if ($task->getStatus() === Task::STATUS_CANCELLED) {
1106
-			return false;
1107
-		}
1108
-		// only set the start time if the task is going from scheduled to running
1109
-		if ($task->getstatus() === Task::STATUS_SCHEDULED) {
1110
-			$task->setStartedAt(time());
1111
-		}
1112
-		$task->setStatus(Task::STATUS_RUNNING);
1113
-		$task->setProgress($progress);
1114
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1115
-		try {
1116
-			$this->taskMapper->update($taskEntity);
1117
-		} catch (\OCP\DB\Exception $e) {
1118
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1119
-		}
1120
-		return true;
1121
-	}
1122
-
1123
-	public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false, ?string $userFacingError = null): void {
1124
-		// TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
1125
-		$task = $this->getTask($id);
1126
-		if ($task->getStatus() === Task::STATUS_CANCELLED) {
1127
-			$this->logger->info('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.');
1128
-			return;
1129
-		}
1130
-		if ($error !== null) {
1131
-			$task->setStatus(Task::STATUS_FAILED);
1132
-			$task->setEndedAt(time());
1133
-			// truncate error message to 4000 characters
1134
-			$task->setErrorMessage(substr($error, 0, 4000));
1135
-			// truncate error message to 4000 characters
1136
-			if ($userFacingError !== null) {
1137
-				$task->setUserFacingErrorMessage(substr($userFacingError, 0, 4000));
1138
-			}
1139
-			$this->logger->warning('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' failed with the following message: ' . $error);
1140
-		} elseif ($result !== null) {
1141
-			$taskTypes = $this->getAvailableTaskTypes();
1142
-			$outputShape = $taskTypes[$task->getTaskTypeId()]['outputShape'];
1143
-			$outputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['outputShapeEnumValues'];
1144
-			$optionalOutputShape = $taskTypes[$task->getTaskTypeId()]['optionalOutputShape'];
1145
-			$optionalOutputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['optionalOutputShapeEnumValues'];
1146
-			try {
1147
-				// validate output
1148
-				if (!$isUsingFileIds) {
1149
-					$this->validateOutputWithFileData($outputShape, $outputShapeEnumValues, $result);
1150
-					$this->validateOutputWithFileData($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
1151
-				} else {
1152
-					$this->validateOutputWithFileIds($outputShape, $outputShapeEnumValues, $result);
1153
-					$this->validateOutputWithFileIds($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
1154
-				}
1155
-				$output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape);
1156
-				// extract raw data and put it in files, replace it with file ids
1157
-				if (!$isUsingFileIds) {
1158
-					$output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape);
1159
-				} else {
1160
-					$this->validateOutputFileIds($output, $outputShape, $optionalOutputShape);
1161
-				}
1162
-				// Turn file objects into IDs
1163
-				foreach ($output as $key => $value) {
1164
-					if ($value instanceof Node) {
1165
-						$output[$key] = $value->getId();
1166
-					}
1167
-					if (is_array($value) && isset($value[0]) && $value[0] instanceof Node) {
1168
-						$output[$key] = array_map(fn ($node) => $node->getId(), $value);
1169
-					}
1170
-				}
1171
-				$task->setOutput($output);
1172
-				$task->setProgress(1);
1173
-				$task->setStatus(Task::STATUS_SUCCESSFUL);
1174
-				$task->setEndedAt(time());
1175
-			} catch (ValidationException $e) {
1176
-				$task->setProgress(1);
1177
-				$task->setStatus(Task::STATUS_FAILED);
1178
-				$task->setEndedAt(time());
1179
-				$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';
1180
-				$task->setErrorMessage($error);
1181
-				$this->logger->error($error, ['exception' => $e, 'output' => $result]);
1182
-			} catch (NotPermittedException $e) {
1183
-				$task->setProgress(1);
1184
-				$task->setStatus(Task::STATUS_FAILED);
1185
-				$task->setEndedAt(time());
1186
-				$error = 'The task was processed successfully but storing the output in a file failed';
1187
-				$task->setErrorMessage($error);
1188
-				$this->logger->error($error, ['exception' => $e]);
1189
-			} catch (InvalidPathException|\OCP\Files\NotFoundException $e) {
1190
-				$task->setProgress(1);
1191
-				$task->setStatus(Task::STATUS_FAILED);
1192
-				$task->setEndedAt(time());
1193
-				$error = 'The task was processed successfully but the result file could not be found';
1194
-				$task->setErrorMessage($error);
1195
-				$this->logger->error($error, ['exception' => $e]);
1196
-			}
1197
-		}
1198
-		try {
1199
-			$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1200
-		} catch (\JsonException $e) {
1201
-			throw new \OCP\TaskProcessing\Exception\Exception('The task was processed successfully but the provider\'s output could not be encoded as JSON for the database.', 0, $e);
1202
-		}
1203
-		try {
1204
-			$this->taskMapper->update($taskEntity);
1205
-			$this->runWebhook($task);
1206
-		} catch (\OCP\DB\Exception $e) {
1207
-			throw new \OCP\TaskProcessing\Exception\Exception($e->getMessage());
1208
-		}
1209
-		if ($task->getStatus() === Task::STATUS_SUCCESSFUL) {
1210
-			$event = new TaskSuccessfulEvent($task);
1211
-		} else {
1212
-			$event = new TaskFailedEvent($task, $error);
1213
-		}
1214
-		$this->dispatcher->dispatchTyped($event);
1215
-	}
1216
-
1217
-	public function getNextScheduledTask(array $taskTypeIds = [], array $taskIdsToIgnore = []): Task {
1218
-		try {
1219
-			$taskEntity = $this->taskMapper->findOldestScheduledByType($taskTypeIds, $taskIdsToIgnore);
1220
-			return $taskEntity->toPublicTask();
1221
-		} catch (DoesNotExistException $e) {
1222
-			throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
1223
-		} catch (\OCP\DB\Exception $e) {
1224
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
1225
-		} catch (\JsonException $e) {
1226
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
1227
-		}
1228
-	}
1229
-
1230
-	public function getNextScheduledTasks(array $taskTypeIds = [], array $taskIdsToIgnore = [], int $numberOfTasks = 1): array {
1231
-		try {
1232
-			return array_map(fn ($taskEntity) => $taskEntity->toPublicTask(), $this->taskMapper->findNOldestScheduledByType($taskTypeIds, $taskIdsToIgnore, $numberOfTasks));
1233
-		} catch (DoesNotExistException $e) {
1234
-			throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
1235
-		} catch (\OCP\DB\Exception $e) {
1236
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
1237
-		} catch (\JsonException $e) {
1238
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
1239
-		}
1240
-	}
1241
-
1242
-	/**
1243
-	 * Takes task input data and replaces fileIds with File objects
1244
-	 *
1245
-	 * @param string|null $userId
1246
-	 * @param array<array-key, list<numeric|string>|numeric|string> $input
1247
-	 * @param ShapeDescriptor[] ...$specs the specs
1248
-	 * @return array<array-key, list<File|numeric|string>|numeric|string|File>
1249
-	 * @throws GenericFileException|LockedException|NotPermittedException|ValidationException|UnauthorizedException
1250
-	 */
1251
-	public function fillInputFileData(?string $userId, array $input, ...$specs): array {
1252
-		if ($userId !== null) {
1253
-			\OC_Util::setupFS($userId);
1254
-		}
1255
-		$newInputOutput = [];
1256
-		$spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1257
-		foreach ($spec as $key => $descriptor) {
1258
-			$type = $descriptor->getShapeType();
1259
-			if (!isset($input[$key])) {
1260
-				continue;
1261
-			}
1262
-			if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1263
-				$newInputOutput[$key] = $input[$key];
1264
-				continue;
1265
-			}
1266
-			if (EShapeType::getScalarType($type) === $type) {
1267
-				// is scalar
1268
-				$node = $this->validateFileId((int)$input[$key]);
1269
-				$this->validateUserAccessToFile($input[$key], $userId);
1270
-				$newInputOutput[$key] = $node;
1271
-			} else {
1272
-				// is list
1273
-				$newInputOutput[$key] = [];
1274
-				foreach ($input[$key] as $item) {
1275
-					$node = $this->validateFileId((int)$item);
1276
-					$this->validateUserAccessToFile($item, $userId);
1277
-					$newInputOutput[$key][] = $node;
1278
-				}
1279
-			}
1280
-		}
1281
-		return $newInputOutput;
1282
-	}
1283
-
1284
-	public function getUserTask(int $id, ?string $userId): Task {
1285
-		try {
1286
-			$taskEntity = $this->taskMapper->findByIdAndUser($id, $userId);
1287
-			return $taskEntity->toPublicTask();
1288
-		} catch (DoesNotExistException $e) {
1289
-			throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e);
1290
-		} catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
1291
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1292
-		} catch (\JsonException $e) {
1293
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
1294
-		}
1295
-	}
1296
-
1297
-	public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array {
1298
-		try {
1299
-			$taskEntities = $this->taskMapper->findByUserAndTaskType($userId, $taskTypeId, $customId);
1300
-			return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1301
-		} catch (\OCP\DB\Exception $e) {
1302
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
1303
-		} catch (\JsonException $e) {
1304
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
1305
-		}
1306
-	}
1307
-
1308
-	public function getTasks(
1309
-		?string $userId, ?string $taskTypeId = null, ?string $appId = null, ?string $customId = null,
1310
-		?int $status = null, ?int $scheduleAfter = null, ?int $endedBefore = null,
1311
-	): array {
1312
-		try {
1313
-			$taskEntities = $this->taskMapper->findTasks($userId, $taskTypeId, $appId, $customId, $status, $scheduleAfter, $endedBefore);
1314
-			return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1315
-		} catch (\OCP\DB\Exception $e) {
1316
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
1317
-		} catch (\JsonException $e) {
1318
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
1319
-		}
1320
-	}
1321
-
1322
-	public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array {
1323
-		try {
1324
-			$taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $customId);
1325
-			return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1326
-		} catch (\OCP\DB\Exception $e) {
1327
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e);
1328
-		} catch (\JsonException $e) {
1329
-			throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e);
1330
-		}
1331
-	}
1332
-
1333
-	/**
1334
-	 *Takes task input or output and replaces base64 data with file ids
1335
-	 *
1336
-	 * @param array $output
1337
-	 * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
1338
-	 * @return array
1339
-	 * @throws NotPermittedException
1340
-	 */
1341
-	public function encapsulateOutputFileData(array $output, ...$specs): array {
1342
-		$newOutput = [];
1343
-		try {
1344
-			$folder = $this->appData->getFolder('TaskProcessing');
1345
-		} catch (\OCP\Files\NotFoundException) {
1346
-			$folder = $this->appData->newFolder('TaskProcessing');
1347
-		}
1348
-		$spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1349
-		foreach ($spec as $key => $descriptor) {
1350
-			$type = $descriptor->getShapeType();
1351
-			if (!isset($output[$key])) {
1352
-				continue;
1353
-			}
1354
-			if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1355
-				$newOutput[$key] = $output[$key];
1356
-				continue;
1357
-			}
1358
-			if (EShapeType::getScalarType($type) === $type) {
1359
-				/** @var SimpleFile $file */
1360
-				$file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]);
1361
-				$newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile
1362
-			} else {
1363
-				$newOutput = [];
1364
-				foreach ($output[$key] as $item) {
1365
-					/** @var SimpleFile $file */
1366
-					$file = $folder->newFile(time() . '-' . rand(1, 100000), $item);
1367
-					$newOutput[$key][] = $file->getId();
1368
-				}
1369
-			}
1370
-		}
1371
-		return $newOutput;
1372
-	}
1373
-
1374
-	/**
1375
-	 * @param Task $task
1376
-	 * @return array<array-key, list<numeric|string|File>|numeric|string|File>
1377
-	 * @throws GenericFileException
1378
-	 * @throws LockedException
1379
-	 * @throws NotPermittedException
1380
-	 * @throws ValidationException|UnauthorizedException
1381
-	 */
1382
-	public function prepareInputData(Task $task): array {
1383
-		$taskTypes = $this->getAvailableTaskTypes();
1384
-		$inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape'];
1385
-		$optionalInputShape = $taskTypes[$task->getTaskTypeId()]['optionalInputShape'];
1386
-		$input = $task->getInput();
1387
-		$input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape);
1388
-		$input = $this->fillInputFileData($task->getUserId(), $input, $inputShape, $optionalInputShape);
1389
-		return $input;
1390
-	}
1391
-
1392
-	public function lockTask(Task $task): bool {
1393
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1394
-		if ($this->taskMapper->lockTask($taskEntity) === 0) {
1395
-			return false;
1396
-		}
1397
-		$task->setStatus(Task::STATUS_RUNNING);
1398
-		return true;
1399
-	}
1400
-
1401
-	/**
1402
-	 * @throws \JsonException
1403
-	 * @throws Exception
1404
-	 */
1405
-	public function setTaskStatus(Task $task, int $status): void {
1406
-		$currentTaskStatus = $task->getStatus();
1407
-		if ($currentTaskStatus === Task::STATUS_SCHEDULED && $status === Task::STATUS_RUNNING) {
1408
-			$task->setStartedAt(time());
1409
-		} elseif ($currentTaskStatus === Task::STATUS_RUNNING && ($status === Task::STATUS_FAILED || $status === Task::STATUS_CANCELLED)) {
1410
-			$task->setEndedAt(time());
1411
-		} elseif ($currentTaskStatus === Task::STATUS_UNKNOWN && $status === Task::STATUS_SCHEDULED) {
1412
-			$task->setScheduledAt(time());
1413
-		}
1414
-		$task->setStatus($status);
1415
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1416
-		$this->taskMapper->update($taskEntity);
1417
-	}
1418
-
1419
-	/**
1420
-	 * Validate input, fill input default values, set completionExpectedAt, set scheduledAt
1421
-	 *
1422
-	 * @param Task $task
1423
-	 * @return void
1424
-	 * @throws UnauthorizedException
1425
-	 * @throws ValidationException
1426
-	 * @throws \OCP\TaskProcessing\Exception\Exception
1427
-	 */
1428
-	private function prepareTask(Task $task): void {
1429
-		$taskTypes = $this->getAvailableTaskTypes();
1430
-		$taskType = $taskTypes[$task->getTaskTypeId()];
1431
-		$inputShape = $taskType['inputShape'];
1432
-		$inputShapeDefaults = $taskType['inputShapeDefaults'];
1433
-		$inputShapeEnumValues = $taskType['inputShapeEnumValues'];
1434
-		$optionalInputShape = $taskType['optionalInputShape'];
1435
-		$optionalInputShapeEnumValues = $taskType['optionalInputShapeEnumValues'];
1436
-		$optionalInputShapeDefaults = $taskType['optionalInputShapeDefaults'];
1437
-		// validate input
1438
-		$this->validateInput($inputShape, $inputShapeDefaults, $inputShapeEnumValues, $task->getInput());
1439
-		$this->validateInput($optionalInputShape, $optionalInputShapeDefaults, $optionalInputShapeEnumValues, $task->getInput(), true);
1440
-		// authenticate access to mentioned files
1441
-		$ids = [];
1442
-		foreach ($inputShape + $optionalInputShape as $key => $descriptor) {
1443
-			if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1444
-				/** @var list<int>|int $inputSlot */
1445
-				$inputSlot = $task->getInput()[$key];
1446
-				if (is_array($inputSlot)) {
1447
-					$ids += $inputSlot;
1448
-				} else {
1449
-					$ids[] = $inputSlot;
1450
-				}
1451
-			}
1452
-		}
1453
-		foreach ($ids as $fileId) {
1454
-			$this->validateFileId($fileId);
1455
-			$this->validateUserAccessToFile($fileId, $task->getUserId());
1456
-		}
1457
-		// remove superfluous keys and set input
1458
-		$input = $this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape);
1459
-		$inputWithDefaults = $this->fillInputDefaults($input, $inputShapeDefaults, $optionalInputShapeDefaults);
1460
-		$task->setInput($inputWithDefaults);
1461
-		$task->setScheduledAt(time());
1462
-		$provider = $this->getPreferredProvider($task->getTaskTypeId());
1463
-		// calculate expected completion time
1464
-		$completionExpectedAt = new \DateTime('now');
1465
-		$completionExpectedAt->add(new \DateInterval('PT' . $provider->getExpectedRuntime() . 'S'));
1466
-		$task->setCompletionExpectedAt($completionExpectedAt);
1467
-	}
1468
-
1469
-	/**
1470
-	 * Store the task in the DB and set its ID in the \OCP\TaskProcessing\Task input param
1471
-	 *
1472
-	 * @param Task $task
1473
-	 * @return void
1474
-	 * @throws Exception
1475
-	 * @throws \JsonException
1476
-	 */
1477
-	private function storeTask(Task $task): void {
1478
-		// create a db entity and insert into db table
1479
-		$taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1480
-		$this->taskMapper->insert($taskEntity);
1481
-		// make sure the scheduler knows the id
1482
-		$task->setId($taskEntity->getId());
1483
-	}
1484
-
1485
-	/**
1486
-	 * @param array $output
1487
-	 * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
1488
-	 * @return array
1489
-	 * @throws NotPermittedException
1490
-	 */
1491
-	private function validateOutputFileIds(array $output, ...$specs): array {
1492
-		$newOutput = [];
1493
-		$spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1494
-		foreach ($spec as $key => $descriptor) {
1495
-			$type = $descriptor->getShapeType();
1496
-			if (!isset($output[$key])) {
1497
-				continue;
1498
-			}
1499
-			if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1500
-				$newOutput[$key] = $output[$key];
1501
-				continue;
1502
-			}
1503
-			if (EShapeType::getScalarType($type) === $type) {
1504
-				// Is scalar file ID
1505
-				$newOutput[$key] = $this->validateFileId($output[$key]);
1506
-			} else {
1507
-				// Is list of file IDs
1508
-				$newOutput = [];
1509
-				foreach ($output[$key] as $item) {
1510
-					$newOutput[$key][] = $this->validateFileId($item);
1511
-				}
1512
-			}
1513
-		}
1514
-		return $newOutput;
1515
-	}
1516
-
1517
-	/**
1518
-	 * @param mixed $id
1519
-	 * @return File
1520
-	 * @throws ValidationException
1521
-	 */
1522
-	private function validateFileId(mixed $id): File {
1523
-		$node = $this->rootFolder->getFirstNodeById($id);
1524
-		if ($node === null) {
1525
-			$node = $this->rootFolder->getFirstNodeByIdInPath($id, '/' . $this->rootFolder->getAppDataDirectoryName() . '/');
1526
-			if ($node === null) {
1527
-				throw new ValidationException('Could not find file ' . $id);
1528
-			} elseif (!$node instanceof File) {
1529
-				throw new ValidationException('File with id "' . $id . '" is not a file');
1530
-			}
1531
-		} elseif (!$node instanceof File) {
1532
-			throw new ValidationException('File with id "' . $id . '" is not a file');
1533
-		}
1534
-		return $node;
1535
-	}
1536
-
1537
-	/**
1538
-	 * @param mixed $fileId
1539
-	 * @param string|null $userId
1540
-	 * @return void
1541
-	 * @throws UnauthorizedException
1542
-	 */
1543
-	private function validateUserAccessToFile(mixed $fileId, ?string $userId): void {
1544
-		if ($userId === null) {
1545
-			throw new UnauthorizedException('User does not have access to file ' . $fileId);
1546
-		}
1547
-		$mounts = $this->userMountCache->getMountsForFileId($fileId);
1548
-		$userIds = array_map(fn ($mount) => $mount->getUser()->getUID(), $mounts);
1549
-		if (!in_array($userId, $userIds)) {
1550
-			throw new UnauthorizedException('User ' . $userId . ' does not have access to file ' . $fileId);
1551
-		}
1552
-	}
1553
-
1554
-	/**
1555
-	 * @param Task $task
1556
-	 * @return list<int>
1557
-	 * @throws NotFoundException
1558
-	 */
1559
-	public function extractFileIdsFromTask(Task $task): array {
1560
-		$ids = [];
1561
-		$taskTypes = $this->getAvailableTaskTypes();
1562
-		if (!isset($taskTypes[$task->getTaskTypeId()])) {
1563
-			throw new NotFoundException('Could not find task type');
1564
-		}
1565
-		$taskType = $taskTypes[$task->getTaskTypeId()];
1566
-		foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) {
1567
-			if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1568
-				/** @var int|list<int> $inputSlot */
1569
-				$inputSlot = $task->getInput()[$key];
1570
-				if (is_array($inputSlot)) {
1571
-					$ids = array_merge($inputSlot, $ids);
1572
-				} else {
1573
-					$ids[] = $inputSlot;
1574
-				}
1575
-			}
1576
-		}
1577
-		if ($task->getOutput() !== null) {
1578
-			foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) {
1579
-				if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1580
-					/** @var int|list<int> $outputSlot */
1581
-					$outputSlot = $task->getOutput()[$key];
1582
-					if (is_array($outputSlot)) {
1583
-						$ids = array_merge($outputSlot, $ids);
1584
-					} else {
1585
-						$ids[] = $outputSlot;
1586
-					}
1587
-				}
1588
-			}
1589
-		}
1590
-		return $ids;
1591
-	}
1592
-
1593
-	/**
1594
-	 * @param ISimpleFolder $folder
1595
-	 * @param int $ageInSeconds
1596
-	 * @return \Generator
1597
-	 */
1598
-	public function clearFilesOlderThan(ISimpleFolder $folder, int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
1599
-		foreach ($folder->getDirectoryListing() as $file) {
1600
-			if ($file->getMTime() < time() - $ageInSeconds) {
1601
-				try {
1602
-					$fileName = $file->getName();
1603
-					$file->delete();
1604
-					yield $fileName;
1605
-				} catch (NotPermittedException $e) {
1606
-					$this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
1607
-				}
1608
-			}
1609
-		}
1610
-	}
1611
-
1612
-	/**
1613
-	 * @param int $ageInSeconds
1614
-	 * @return \Generator
1615
-	 * @throws Exception
1616
-	 * @throws InvalidPathException
1617
-	 * @throws NotFoundException
1618
-	 * @throws \JsonException
1619
-	 * @throws \OCP\Files\NotFoundException
1620
-	 */
1621
-	public function cleanupTaskProcessingTaskFiles(int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
1622
-		$taskIdsToCleanup = [];
1623
-		foreach ($this->taskMapper->getTasksToCleanup($ageInSeconds) as $task) {
1624
-			$taskIdsToCleanup[] = $task->getId();
1625
-			$ocpTask = $task->toPublicTask();
1626
-			$fileIds = $this->extractFileIdsFromTask($ocpTask);
1627
-			foreach ($fileIds as $fileId) {
1628
-				// only look for output files stored in appData/TaskProcessing/
1629
-				$file = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/core/TaskProcessing/');
1630
-				if ($file instanceof File) {
1631
-					try {
1632
-						$fileId = $file->getId();
1633
-						$fileName = $file->getName();
1634
-						$file->delete();
1635
-						yield ['task_id' => $task->getId(), 'file_id' => $fileId, 'file_name' => $fileName];
1636
-					} catch (NotPermittedException $e) {
1637
-						$this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
1638
-					}
1639
-				}
1640
-			}
1641
-		}
1642
-		return $taskIdsToCleanup;
1643
-	}
1644
-
1645
-	/**
1646
-	 * Make a request to the task's webhookUri if necessary
1647
-	 *
1648
-	 * @param Task $task
1649
-	 */
1650
-	private function runWebhook(Task $task): void {
1651
-		$uri = $task->getWebhookUri();
1652
-		$method = $task->getWebhookMethod();
1653
-
1654
-		if (!$uri || !$method) {
1655
-			return;
1656
-		}
1657
-
1658
-		if (in_array($method, ['HTTP:GET', 'HTTP:POST', 'HTTP:PUT', 'HTTP:DELETE'], true)) {
1659
-			$client = $this->clientService->newClient();
1660
-			$httpMethod = preg_replace('/^HTTP:/', '', $method);
1661
-			$options = [
1662
-				'timeout' => 30,
1663
-				'body' => json_encode([
1664
-					'task' => $task->jsonSerialize(),
1665
-				]),
1666
-				'headers' => ['Content-Type' => 'application/json'],
1667
-			];
1668
-			try {
1669
-				$client->request($httpMethod, $uri, $options);
1670
-			} catch (ClientException|ServerException $e) {
1671
-				$this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Request failed', ['exception' => $e]);
1672
-			} catch (\Exception|\Throwable $e) {
1673
-				$this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Unknown error', ['exception' => $e]);
1674
-			}
1675
-		} elseif (str_starts_with($method, 'AppAPI:') && str_starts_with($uri, '/')) {
1676
-			$parsedMethod = explode(':', $method, 4);
1677
-			if (count($parsedMethod) < 3) {
1678
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Invalid method: ' . $method);
1679
-			}
1680
-			[, $exAppId, $httpMethod] = $parsedMethod;
1681
-			if (!$this->appManager->isEnabledForAnyone('app_api')) {
1682
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. AppAPI is disabled or not installed.');
1683
-				return;
1684
-			}
1685
-			try {
1686
-				$appApiFunctions = \OCP\Server::get(\OCA\AppAPI\PublicFunctions::class);
1687
-			} catch (ContainerExceptionInterface|NotFoundExceptionInterface) {
1688
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Could not get AppAPI public functions.');
1689
-				return;
1690
-			}
1691
-			$exApp = $appApiFunctions->getExApp($exAppId);
1692
-			if ($exApp === null) {
1693
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is missing.');
1694
-				return;
1695
-			} elseif (!$exApp['enabled']) {
1696
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is disabled.');
1697
-				return;
1698
-			}
1699
-			$requestParams = [
1700
-				'task' => $task->jsonSerialize(),
1701
-			];
1702
-			$requestOptions = [
1703
-				'timeout' => 30,
1704
-			];
1705
-			$response = $appApiFunctions->exAppRequest($exAppId, $uri, $task->getUserId(), $httpMethod, $requestParams, $requestOptions);
1706
-			if (is_array($response) && isset($response['error'])) {
1707
-				$this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Error during request to ExApp(' . $exAppId . '): ', $response['error']);
1708
-			}
1709
-		}
1710
-	}
77
+    public const LEGACY_PREFIX_TEXTPROCESSING = 'legacy:TextProcessing:';
78
+    public const LEGACY_PREFIX_TEXTTOIMAGE = 'legacy:TextToImage:';
79
+    public const LEGACY_PREFIX_SPEECHTOTEXT = 'legacy:SpeechToText:';
80
+
81
+    public const LAZY_CONFIG_KEYS = [
82
+        'ai.taskprocessing_type_preferences',
83
+        'ai.taskprocessing_provider_preferences',
84
+    ];
85
+
86
+    public const MAX_TASK_AGE_SECONDS = 60 * 60 * 24 * 31 * 6; // 6 months
87
+
88
+    private const TASK_TYPES_CACHE_KEY = 'available_task_types_v3';
89
+    private const TASK_TYPE_IDS_CACHE_KEY = 'available_task_type_ids';
90
+
91
+    /** @var list<IProvider>|null */
92
+    private ?array $providers = null;
93
+
94
+    /**
95
+     * @var array<array-key,array{name: string, description: string, inputShape: ShapeDescriptor[], inputShapeEnumValues: ShapeEnumValue[][], inputShapeDefaults: array<array-key, numeric|string>, isInternal: bool, optionalInputShape: ShapeDescriptor[], optionalInputShapeEnumValues: ShapeEnumValue[][], optionalInputShapeDefaults: array<array-key, numeric|string>, outputShape: ShapeDescriptor[], outputShapeEnumValues: ShapeEnumValue[][], optionalOutputShape: ShapeDescriptor[], optionalOutputShapeEnumValues: ShapeEnumValue[][]}>
96
+     */
97
+    private ?array $availableTaskTypes = null;
98
+
99
+    /** @var list<string>|null */
100
+    private ?array $availableTaskTypeIds = null;
101
+
102
+    private IAppData $appData;
103
+    private ?array $preferences = null;
104
+    private ?array $providersById = null;
105
+
106
+    /** @var ITaskType[]|null */
107
+    private ?array $taskTypes = null;
108
+    private ICache $distributedCache;
109
+
110
+    private ?GetTaskProcessingProvidersEvent $eventResult = null;
111
+
112
+    public function __construct(
113
+        private IAppConfig $appConfig,
114
+        private Coordinator $coordinator,
115
+        private IServerContainer $serverContainer,
116
+        private LoggerInterface $logger,
117
+        private TaskMapper $taskMapper,
118
+        private IJobList $jobList,
119
+        private IEventDispatcher $dispatcher,
120
+        IAppDataFactory $appDataFactory,
121
+        private IRootFolder $rootFolder,
122
+        private \OCP\TextToImage\IManager $textToImageManager,
123
+        private IUserMountCache $userMountCache,
124
+        private IClientService $clientService,
125
+        private IAppManager $appManager,
126
+        private IUserManager $userManager,
127
+        private IUserSession $userSession,
128
+        ICacheFactory $cacheFactory,
129
+        private IFactory $l10nFactory,
130
+    ) {
131
+        $this->appData = $appDataFactory->get('core');
132
+        $this->distributedCache = $cacheFactory->createDistributed('task_processing::');
133
+    }
134
+
135
+
136
+    /**
137
+     * This is almost a copy of textProcessingManager->getProviders
138
+     * to avoid a dependency cycle between TextProcessingManager and TaskProcessingManager
139
+     */
140
+    private function _getRawTextProcessingProviders(): array {
141
+        $context = $this->coordinator->getRegistrationContext();
142
+        if ($context === null) {
143
+            return [];
144
+        }
145
+
146
+        $providers = [];
147
+
148
+        foreach ($context->getTextProcessingProviders() as $providerServiceRegistration) {
149
+            $class = $providerServiceRegistration->getService();
150
+            try {
151
+                $providers[$class] = $this->serverContainer->get($class);
152
+            } catch (\Throwable $e) {
153
+                $this->logger->error('Failed to load Text processing provider ' . $class, [
154
+                    'exception' => $e,
155
+                ]);
156
+            }
157
+        }
158
+
159
+        return $providers;
160
+    }
161
+
162
+    private function _getTextProcessingProviders(): array {
163
+        $oldProviders = $this->_getRawTextProcessingProviders();
164
+        $newProviders = [];
165
+        foreach ($oldProviders as $oldProvider) {
166
+            $provider = new class($oldProvider) implements IProvider, ISynchronousProvider {
167
+                private \OCP\TextProcessing\IProvider $provider;
168
+
169
+                public function __construct(\OCP\TextProcessing\IProvider $provider) {
170
+                    $this->provider = $provider;
171
+                }
172
+
173
+                public function getId(): string {
174
+                    if ($this->provider instanceof \OCP\TextProcessing\IProviderWithId) {
175
+                        return $this->provider->getId();
176
+                    }
177
+                    return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider::class;
178
+                }
179
+
180
+                public function getName(): string {
181
+                    return $this->provider->getName();
182
+                }
183
+
184
+                public function getTaskTypeId(): string {
185
+                    return match ($this->provider->getTaskType()) {
186
+                        \OCP\TextProcessing\FreePromptTaskType::class => TextToText::ID,
187
+                        \OCP\TextProcessing\HeadlineTaskType::class => TextToTextHeadline::ID,
188
+                        \OCP\TextProcessing\TopicsTaskType::class => TextToTextTopics::ID,
189
+                        \OCP\TextProcessing\SummaryTaskType::class => TextToTextSummary::ID,
190
+                        default => Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider->getTaskType(),
191
+                    };
192
+                }
193
+
194
+                public function getExpectedRuntime(): int {
195
+                    if ($this->provider instanceof \OCP\TextProcessing\IProviderWithExpectedRuntime) {
196
+                        return $this->provider->getExpectedRuntime();
197
+                    }
198
+                    return 60;
199
+                }
200
+
201
+                public function getOptionalInputShape(): array {
202
+                    return [];
203
+                }
204
+
205
+                public function getOptionalOutputShape(): array {
206
+                    return [];
207
+                }
208
+
209
+                public function process(?string $userId, array $input, callable $reportProgress): array {
210
+                    if ($this->provider instanceof \OCP\TextProcessing\IProviderWithUserId) {
211
+                        $this->provider->setUserId($userId);
212
+                    }
213
+                    try {
214
+                        return ['output' => $this->provider->process($input['input'])];
215
+                    } catch (\RuntimeException $e) {
216
+                        throw new ProcessingException($e->getMessage(), previous: $e);
217
+                    }
218
+                }
219
+
220
+                public function getInputShapeEnumValues(): array {
221
+                    return [];
222
+                }
223
+
224
+                public function getInputShapeDefaults(): array {
225
+                    return [];
226
+                }
227
+
228
+                public function getOptionalInputShapeEnumValues(): array {
229
+                    return [];
230
+                }
231
+
232
+                public function getOptionalInputShapeDefaults(): array {
233
+                    return [];
234
+                }
235
+
236
+                public function getOutputShapeEnumValues(): array {
237
+                    return [];
238
+                }
239
+
240
+                public function getOptionalOutputShapeEnumValues(): array {
241
+                    return [];
242
+                }
243
+            };
244
+            $newProviders[$provider->getId()] = $provider;
245
+        }
246
+
247
+        return $newProviders;
248
+    }
249
+
250
+    /**
251
+     * @return ITaskType[]
252
+     */
253
+    private function _getTextProcessingTaskTypes(): array {
254
+        $oldProviders = $this->_getRawTextProcessingProviders();
255
+        $newTaskTypes = [];
256
+        foreach ($oldProviders as $oldProvider) {
257
+            // These are already implemented in the TaskProcessing realm
258
+            if (in_array($oldProvider->getTaskType(), [
259
+                \OCP\TextProcessing\FreePromptTaskType::class,
260
+                \OCP\TextProcessing\HeadlineTaskType::class,
261
+                \OCP\TextProcessing\TopicsTaskType::class,
262
+                \OCP\TextProcessing\SummaryTaskType::class
263
+            ], true)) {
264
+                continue;
265
+            }
266
+            $taskType = new class($oldProvider->getTaskType()) implements ITaskType {
267
+                private string $oldTaskTypeClass;
268
+                private \OCP\TextProcessing\ITaskType $oldTaskType;
269
+                private IL10N $l;
270
+
271
+                public function __construct(string $oldTaskTypeClass) {
272
+                    $this->oldTaskTypeClass = $oldTaskTypeClass;
273
+                    $this->oldTaskType = \OCP\Server::get($oldTaskTypeClass);
274
+                    $this->l = \OCP\Server::get(IFactory::class)->get('core');
275
+                }
276
+
277
+                public function getId(): string {
278
+                    return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->oldTaskTypeClass;
279
+                }
280
+
281
+                public function getName(): string {
282
+                    return $this->oldTaskType->getName();
283
+                }
284
+
285
+                public function getDescription(): string {
286
+                    return $this->oldTaskType->getDescription();
287
+                }
288
+
289
+                public function getInputShape(): array {
290
+                    return ['input' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
291
+                }
292
+
293
+                public function getOutputShape(): array {
294
+                    return ['output' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
295
+                }
296
+            };
297
+            $newTaskTypes[$taskType->getId()] = $taskType;
298
+        }
299
+
300
+        return $newTaskTypes;
301
+    }
302
+
303
+    /**
304
+     * @return IProvider[]
305
+     */
306
+    private function _getTextToImageProviders(): array {
307
+        $oldProviders = $this->textToImageManager->getProviders();
308
+        $newProviders = [];
309
+        foreach ($oldProviders as $oldProvider) {
310
+            $newProvider = new class($oldProvider, $this->appData) implements IProvider, ISynchronousProvider {
311
+                private \OCP\TextToImage\IProvider $provider;
312
+                private IAppData $appData;
313
+
314
+                public function __construct(\OCP\TextToImage\IProvider $provider, IAppData $appData) {
315
+                    $this->provider = $provider;
316
+                    $this->appData = $appData;
317
+                }
318
+
319
+                public function getId(): string {
320
+                    return Manager::LEGACY_PREFIX_TEXTTOIMAGE . $this->provider->getId();
321
+                }
322
+
323
+                public function getName(): string {
324
+                    return $this->provider->getName();
325
+                }
326
+
327
+                public function getTaskTypeId(): string {
328
+                    return TextToImage::ID;
329
+                }
330
+
331
+                public function getExpectedRuntime(): int {
332
+                    return $this->provider->getExpectedRuntime();
333
+                }
334
+
335
+                public function getOptionalInputShape(): array {
336
+                    return [];
337
+                }
338
+
339
+                public function getOptionalOutputShape(): array {
340
+                    return [];
341
+                }
342
+
343
+                public function process(?string $userId, array $input, callable $reportProgress): array {
344
+                    try {
345
+                        $folder = $this->appData->getFolder('text2image');
346
+                    } catch (\OCP\Files\NotFoundException) {
347
+                        $folder = $this->appData->newFolder('text2image');
348
+                    }
349
+                    $resources = [];
350
+                    $files = [];
351
+                    for ($i = 0; $i < $input['numberOfImages']; $i++) {
352
+                        $file = $folder->newFile(time() . '-' . rand(1, 100000) . '-' . $i);
353
+                        $files[] = $file;
354
+                        $resource = $file->write();
355
+                        if ($resource !== false && $resource !== true && is_resource($resource)) {
356
+                            $resources[] = $resource;
357
+                        } else {
358
+                            throw new ProcessingException('Text2Image generation using provider "' . $this->getName() . '" failed: Couldn\'t open file to write.');
359
+                        }
360
+                    }
361
+                    if ($this->provider instanceof \OCP\TextToImage\IProviderWithUserId) {
362
+                        $this->provider->setUserId($userId);
363
+                    }
364
+                    try {
365
+                        $this->provider->generate($input['input'], $resources);
366
+                    } catch (\RuntimeException $e) {
367
+                        throw new ProcessingException($e->getMessage(), previous: $e);
368
+                    }
369
+                    for ($i = 0; $i < $input['numberOfImages']; $i++) {
370
+                        if (is_resource($resources[$i])) {
371
+                            // If $resource hasn't been closed yet, we'll do that here
372
+                            fclose($resources[$i]);
373
+                        }
374
+                    }
375
+                    return ['images' => array_map(fn (ISimpleFile $file) => $file->getContent(), $files)];
376
+                }
377
+
378
+                public function getInputShapeEnumValues(): array {
379
+                    return [];
380
+                }
381
+
382
+                public function getInputShapeDefaults(): array {
383
+                    return [];
384
+                }
385
+
386
+                public function getOptionalInputShapeEnumValues(): array {
387
+                    return [];
388
+                }
389
+
390
+                public function getOptionalInputShapeDefaults(): array {
391
+                    return [];
392
+                }
393
+
394
+                public function getOutputShapeEnumValues(): array {
395
+                    return [];
396
+                }
397
+
398
+                public function getOptionalOutputShapeEnumValues(): array {
399
+                    return [];
400
+                }
401
+            };
402
+            $newProviders[$newProvider->getId()] = $newProvider;
403
+        }
404
+
405
+        return $newProviders;
406
+    }
407
+
408
+    /**
409
+     * This is almost a copy of SpeechToTextManager->getProviders
410
+     * to avoid a dependency cycle between SpeechToTextManager and TaskProcessingManager
411
+     */
412
+    private function _getRawSpeechToTextProviders(): array {
413
+        $context = $this->coordinator->getRegistrationContext();
414
+        if ($context === null) {
415
+            return [];
416
+        }
417
+        $providers = [];
418
+        foreach ($context->getSpeechToTextProviders() as $providerServiceRegistration) {
419
+            $class = $providerServiceRegistration->getService();
420
+            try {
421
+                $providers[$class] = $this->serverContainer->get($class);
422
+            } catch (NotFoundExceptionInterface|ContainerExceptionInterface|\Throwable $e) {
423
+                $this->logger->error('Failed to load SpeechToText provider ' . $class, [
424
+                    'exception' => $e,
425
+                ]);
426
+            }
427
+        }
428
+
429
+        return $providers;
430
+    }
431
+
432
+    /**
433
+     * @return IProvider[]
434
+     */
435
+    private function _getSpeechToTextProviders(): array {
436
+        $oldProviders = $this->_getRawSpeechToTextProviders();
437
+        $newProviders = [];
438
+        foreach ($oldProviders as $oldProvider) {
439
+            $newProvider = new class($oldProvider, $this->rootFolder, $this->appData) implements IProvider, ISynchronousProvider {
440
+                private ISpeechToTextProvider $provider;
441
+                private IAppData $appData;
442
+
443
+                private IRootFolder $rootFolder;
444
+
445
+                public function __construct(ISpeechToTextProvider $provider, IRootFolder $rootFolder, IAppData $appData) {
446
+                    $this->provider = $provider;
447
+                    $this->rootFolder = $rootFolder;
448
+                    $this->appData = $appData;
449
+                }
450
+
451
+                public function getId(): string {
452
+                    if ($this->provider instanceof ISpeechToTextProviderWithId) {
453
+                        return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider->getId();
454
+                    }
455
+                    return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider::class;
456
+                }
457
+
458
+                public function getName(): string {
459
+                    return $this->provider->getName();
460
+                }
461
+
462
+                public function getTaskTypeId(): string {
463
+                    return AudioToText::ID;
464
+                }
465
+
466
+                public function getExpectedRuntime(): int {
467
+                    return 60;
468
+                }
469
+
470
+                public function getOptionalInputShape(): array {
471
+                    return [];
472
+                }
473
+
474
+                public function getOptionalOutputShape(): array {
475
+                    return [];
476
+                }
477
+
478
+                public function process(?string $userId, array $input, callable $reportProgress): array {
479
+                    if ($this->provider instanceof \OCP\SpeechToText\ISpeechToTextProviderWithUserId) {
480
+                        $this->provider->setUserId($userId);
481
+                    }
482
+                    try {
483
+                        $result = $this->provider->transcribeFile($input['input']);
484
+                    } catch (\RuntimeException $e) {
485
+                        throw new ProcessingException($e->getMessage(), previous: $e);
486
+                    }
487
+                    return ['output' => $result];
488
+                }
489
+
490
+                public function getInputShapeEnumValues(): array {
491
+                    return [];
492
+                }
493
+
494
+                public function getInputShapeDefaults(): array {
495
+                    return [];
496
+                }
497
+
498
+                public function getOptionalInputShapeEnumValues(): array {
499
+                    return [];
500
+                }
501
+
502
+                public function getOptionalInputShapeDefaults(): array {
503
+                    return [];
504
+                }
505
+
506
+                public function getOutputShapeEnumValues(): array {
507
+                    return [];
508
+                }
509
+
510
+                public function getOptionalOutputShapeEnumValues(): array {
511
+                    return [];
512
+                }
513
+            };
514
+            $newProviders[$newProvider->getId()] = $newProvider;
515
+        }
516
+
517
+        return $newProviders;
518
+    }
519
+
520
+    /**
521
+     * Dispatches the event to collect external providers and task types.
522
+     * Caches the result within the request.
523
+     */
524
+    private function dispatchGetProvidersEvent(): GetTaskProcessingProvidersEvent {
525
+        if ($this->eventResult !== null) {
526
+            return $this->eventResult;
527
+        }
528
+
529
+        $this->eventResult = new GetTaskProcessingProvidersEvent();
530
+        $this->dispatcher->dispatchTyped($this->eventResult);
531
+        return $this->eventResult ;
532
+    }
533
+
534
+    /**
535
+     * @return IProvider[]
536
+     */
537
+    private function _getProviders(): array {
538
+        $context = $this->coordinator->getRegistrationContext();
539
+
540
+        if ($context === null) {
541
+            return [];
542
+        }
543
+
544
+        $providers = [];
545
+
546
+        foreach ($context->getTaskProcessingProviders() as $providerServiceRegistration) {
547
+            $class = $providerServiceRegistration->getService();
548
+            try {
549
+                /** @var IProvider $provider */
550
+                $provider = $this->serverContainer->get($class);
551
+                if (isset($providers[$provider->getId()])) {
552
+                    $this->logger->warning('Task processing provider ' . $class . ' is using ID ' . $provider->getId() . ' which is already used by ' . $providers[$provider->getId()]::class);
553
+                }
554
+                $providers[$provider->getId()] = $provider;
555
+            } catch (\Throwable $e) {
556
+                $this->logger->error('Failed to load task processing provider ' . $class, [
557
+                    'exception' => $e,
558
+                ]);
559
+            }
560
+        }
561
+
562
+        $event = $this->dispatchGetProvidersEvent();
563
+        $externalProviders = $event->getProviders();
564
+        foreach ($externalProviders as $provider) {
565
+            if (!isset($providers[$provider->getId()])) {
566
+                $providers[$provider->getId()] = $provider;
567
+            } else {
568
+                $this->logger->info('Skipping external task processing provider with ID ' . $provider->getId() . ' because a local provider with the same ID already exists.');
569
+            }
570
+        }
571
+
572
+        $providers += $this->_getTextProcessingProviders() + $this->_getTextToImageProviders() + $this->_getSpeechToTextProviders();
573
+
574
+        return $providers;
575
+    }
576
+
577
+    /**
578
+     * @return ITaskType[]
579
+     */
580
+    private function _getTaskTypes(): array {
581
+        $context = $this->coordinator->getRegistrationContext();
582
+
583
+        if ($context === null) {
584
+            return [];
585
+        }
586
+
587
+        if ($this->taskTypes !== null) {
588
+            return $this->taskTypes;
589
+        }
590
+
591
+        // Default task types
592
+        $taskTypes = [
593
+            \OCP\TaskProcessing\TaskTypes\TextToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToText::class),
594
+            \OCP\TaskProcessing\TaskTypes\TextToTextTopics::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTopics::class),
595
+            \OCP\TaskProcessing\TaskTypes\TextToTextHeadline::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextHeadline::class),
596
+            \OCP\TaskProcessing\TaskTypes\TextToTextSummary::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSummary::class),
597
+            \OCP\TaskProcessing\TaskTypes\TextToTextFormalization::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextFormalization::class),
598
+            \OCP\TaskProcessing\TaskTypes\TextToTextSimplification::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSimplification::class),
599
+            \OCP\TaskProcessing\TaskTypes\TextToTextChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChat::class),
600
+            \OCP\TaskProcessing\TaskTypes\TextToTextTranslate::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTranslate::class),
601
+            \OCP\TaskProcessing\TaskTypes\TextToTextReformulation::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextReformulation::class),
602
+            \OCP\TaskProcessing\TaskTypes\TextToImage::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToImage::class),
603
+            \OCP\TaskProcessing\TaskTypes\AudioToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToText::class),
604
+            \OCP\TaskProcessing\TaskTypes\ContextWrite::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextWrite::class),
605
+            \OCP\TaskProcessing\TaskTypes\GenerateEmoji::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\GenerateEmoji::class),
606
+            \OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::class),
607
+            \OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::class),
608
+            \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::class),
609
+            \OCP\TaskProcessing\TaskTypes\TextToTextProofread::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextProofread::class),
610
+            \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToSpeech::class),
611
+            \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::class),
612
+            \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::class),
613
+            \OCP\TaskProcessing\TaskTypes\AnalyzeImages::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AnalyzeImages::class),
614
+            \OCP\TaskProcessing\TaskTypes\ImageToTextOpticalCharacterRecognition::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ImageToTextOpticalCharacterRecognition::class),
615
+        ];
616
+
617
+        foreach ($context->getTaskProcessingTaskTypes() as $providerServiceRegistration) {
618
+            $class = $providerServiceRegistration->getService();
619
+            try {
620
+                /** @var ITaskType $provider */
621
+                $taskType = $this->serverContainer->get($class);
622
+                if (isset($taskTypes[$taskType->getId()])) {
623
+                    $this->logger->warning('Task processing task type ' . $class . ' is using ID ' . $taskType->getId() . ' which is already used by ' . $taskTypes[$taskType->getId()]::class);
624
+                }
625
+                $taskTypes[$taskType->getId()] = $taskType;
626
+            } catch (\Throwable $e) {
627
+                $this->logger->error('Failed to load task processing task type ' . $class, [
628
+                    'exception' => $e,
629
+                ]);
630
+            }
631
+        }
632
+
633
+        $event = $this->dispatchGetProvidersEvent();
634
+        $externalTaskTypes = $event->getTaskTypes();
635
+        foreach ($externalTaskTypes as $taskType) {
636
+            if (isset($taskTypes[$taskType->getId()])) {
637
+                $this->logger->warning('External task processing task type is using ID ' . $taskType->getId() . ' which is already used by a locally registered task type (' . get_class($taskTypes[$taskType->getId()]) . ')');
638
+            }
639
+            $taskTypes[$taskType->getId()] = $taskType;
640
+        }
641
+
642
+        $taskTypes += $this->_getTextProcessingTaskTypes();
643
+
644
+        $this->taskTypes = $taskTypes;
645
+        return $this->taskTypes;
646
+    }
647
+
648
+    /**
649
+     * @return array
650
+     */
651
+    private function _getTaskTypeSettings(): array {
652
+        try {
653
+            $json = $this->appConfig->getValueString('core', 'ai.taskprocessing_type_preferences', '', lazy: true);
654
+            if ($json === '') {
655
+                return [];
656
+            }
657
+            return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
658
+        } catch (\JsonException $e) {
659
+            $this->logger->error('Failed to get settings. JSON Error in ai.taskprocessing_type_preferences', ['exception' => $e]);
660
+            $taskTypeSettings = [];
661
+            $taskTypes = $this->_getTaskTypes();
662
+            foreach ($taskTypes as $taskType) {
663
+                $taskTypeSettings[$taskType->getId()] = false;
664
+            };
665
+
666
+            return $taskTypeSettings;
667
+        }
668
+
669
+    }
670
+
671
+    /**
672
+     * @param ShapeDescriptor[] $spec
673
+     * @param array<array-key, string|numeric> $defaults
674
+     * @param array<array-key, ShapeEnumValue[]> $enumValues
675
+     * @param array $io
676
+     * @param bool $optional
677
+     * @return void
678
+     * @throws ValidationException
679
+     */
680
+    private static function validateInput(array $spec, array $defaults, array $enumValues, array $io, bool $optional = false): void {
681
+        foreach ($spec as $key => $descriptor) {
682
+            $type = $descriptor->getShapeType();
683
+            if (!isset($io[$key])) {
684
+                if ($optional) {
685
+                    continue;
686
+                }
687
+                if (isset($defaults[$key])) {
688
+                    if (EShapeType::getScalarType($type) !== $type) {
689
+                        throw new ValidationException('Provider tried to set a default value for a non-scalar slot');
690
+                    }
691
+                    if (EShapeType::isFileType($type)) {
692
+                        throw new ValidationException('Provider tried to set a default value for a slot that is not text or number');
693
+                    }
694
+                    $type->validateInput($defaults[$key]);
695
+                    continue;
696
+                }
697
+                throw new ValidationException('Missing key: "' . $key . '"');
698
+            }
699
+            try {
700
+                $type->validateInput($io[$key]);
701
+                if ($type === EShapeType::Enum) {
702
+                    if (!isset($enumValues[$key])) {
703
+                        throw new ValidationException('Provider did not provide enum values for an enum slot: "' . $key . '"');
704
+                    }
705
+                    $type->validateEnum($io[$key], $enumValues[$key]);
706
+                }
707
+            } catch (ValidationException $e) {
708
+                throw new ValidationException('Failed to validate input key "' . $key . '": ' . $e->getMessage());
709
+            }
710
+        }
711
+    }
712
+
713
+    /**
714
+     * Takes task input data and replaces fileIds with File objects
715
+     *
716
+     * @param array<array-key, list<numeric|string>|numeric|string> $input
717
+     * @param array<array-key, numeric|string> ...$defaultSpecs the specs
718
+     * @return array<array-key, list<numeric|string>|numeric|string>
719
+     */
720
+    public function fillInputDefaults(array $input, ...$defaultSpecs): array {
721
+        $spec = array_reduce($defaultSpecs, fn ($carry, $spec) => array_merge($carry, $spec), []);
722
+        return array_merge($spec, $input);
723
+    }
724
+
725
+    /**
726
+     * @param ShapeDescriptor[] $spec
727
+     * @param array<array-key, ShapeEnumValue[]> $enumValues
728
+     * @param array $io
729
+     * @param bool $optional
730
+     * @return void
731
+     * @throws ValidationException
732
+     */
733
+    private static function validateOutputWithFileIds(array $spec, array $enumValues, array $io, bool $optional = false): void {
734
+        foreach ($spec as $key => $descriptor) {
735
+            $type = $descriptor->getShapeType();
736
+            if (!isset($io[$key])) {
737
+                if ($optional) {
738
+                    continue;
739
+                }
740
+                throw new ValidationException('Missing key: "' . $key . '"');
741
+            }
742
+            try {
743
+                $type->validateOutputWithFileIds($io[$key]);
744
+                if (isset($enumValues[$key])) {
745
+                    $type->validateEnum($io[$key], $enumValues[$key]);
746
+                }
747
+            } catch (ValidationException $e) {
748
+                throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
749
+            }
750
+        }
751
+    }
752
+
753
+    /**
754
+     * @param ShapeDescriptor[] $spec
755
+     * @param array<array-key, ShapeEnumValue[]> $enumValues
756
+     * @param array $io
757
+     * @param bool $optional
758
+     * @return void
759
+     * @throws ValidationException
760
+     */
761
+    private static function validateOutputWithFileData(array $spec, array $enumValues, array $io, bool $optional = false): void {
762
+        foreach ($spec as $key => $descriptor) {
763
+            $type = $descriptor->getShapeType();
764
+            if (!isset($io[$key])) {
765
+                if ($optional) {
766
+                    continue;
767
+                }
768
+                throw new ValidationException('Missing key: "' . $key . '"');
769
+            }
770
+            try {
771
+                $type->validateOutputWithFileData($io[$key]);
772
+                if (isset($enumValues[$key])) {
773
+                    $type->validateEnum($io[$key], $enumValues[$key]);
774
+                }
775
+            } catch (ValidationException $e) {
776
+                throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
777
+            }
778
+        }
779
+    }
780
+
781
+    /**
782
+     * @param array<array-key, T> $array The array to filter
783
+     * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
784
+     * @return array<array-key, T>
785
+     * @psalm-template T
786
+     */
787
+    private function removeSuperfluousArrayKeys(array $array, ...$specs): array {
788
+        $keys = array_unique(array_reduce($specs, fn ($carry, $spec) => array_merge($carry, array_keys($spec)), []));
789
+        $keys = array_filter($keys, fn ($key) => array_key_exists($key, $array));
790
+        $values = array_map(fn (string $key) => $array[$key], $keys);
791
+        return array_combine($keys, $values);
792
+    }
793
+
794
+    public function hasProviders(): bool {
795
+        return count($this->getProviders()) !== 0;
796
+    }
797
+
798
+    public function getProviders(): array {
799
+        if ($this->providers === null) {
800
+            $this->providers = $this->_getProviders();
801
+        }
802
+
803
+        return $this->providers;
804
+    }
805
+
806
+    public function getPreferredProvider(string $taskTypeId) {
807
+        try {
808
+            if ($this->preferences === null) {
809
+                $this->preferences = $this->distributedCache->get('ai.taskprocessing_provider_preferences');
810
+                if ($this->preferences === null) {
811
+                    $this->preferences = json_decode(
812
+                        $this->appConfig->getValueString('core', 'ai.taskprocessing_provider_preferences', 'null', lazy: true),
813
+                        associative: true,
814
+                        flags: JSON_THROW_ON_ERROR,
815
+                    );
816
+                    $this->distributedCache->set('ai.taskprocessing_provider_preferences', $this->preferences, 60 * 3);
817
+                }
818
+            }
819
+
820
+            $providers = $this->getProviders();
821
+            if (isset($this->preferences[$taskTypeId])) {
822
+                $providersById = $this->providersById ?? array_reduce($providers, static function (array $carry, IProvider $provider) {
823
+                    $carry[$provider->getId()] = $provider;
824
+                    return $carry;
825
+                }, []);
826
+                $this->providersById = $providersById;
827
+                if (isset($providersById[$this->preferences[$taskTypeId]])) {
828
+                    return $providersById[$this->preferences[$taskTypeId]];
829
+                }
830
+            }
831
+            // By default, use the first available provider
832
+            foreach ($providers as $provider) {
833
+                if ($provider->getTaskTypeId() === $taskTypeId) {
834
+                    return $provider;
835
+                }
836
+            }
837
+        } catch (\JsonException $e) {
838
+            $this->logger->warning('Failed to parse provider preferences while getting preferred provider for task type ' . $taskTypeId, ['exception' => $e]);
839
+        }
840
+        throw new \OCP\TaskProcessing\Exception\Exception('No matching provider found');
841
+    }
842
+
843
+    public function getAvailableTaskTypes(bool $showDisabled = false, ?string $userId = null): array {
844
+        // We cache by language, because some task type fields are translated
845
+        $cacheKey = self::TASK_TYPES_CACHE_KEY . ':' . $this->l10nFactory->findLanguage();
846
+
847
+        // userId will be obtained from the session if left to null
848
+        if (!$this->checkGuestAccess($userId)) {
849
+            return [];
850
+        }
851
+        if ($this->availableTaskTypes === null) {
852
+            $cachedValue = $this->distributedCache->get($cacheKey);
853
+            if ($cachedValue !== null) {
854
+                $this->availableTaskTypes = unserialize($cachedValue);
855
+            }
856
+        }
857
+        // Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
858
+        if ($this->availableTaskTypes === null || $showDisabled) {
859
+            $taskTypes = $this->_getTaskTypes();
860
+            $taskTypeSettings = $this->_getTaskTypeSettings();
861
+
862
+            $availableTaskTypes = [];
863
+            foreach ($taskTypes as $taskType) {
864
+                if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
865
+                    continue;
866
+                }
867
+                try {
868
+                    $provider = $this->getPreferredProvider($taskType->getId());
869
+                } catch (\OCP\TaskProcessing\Exception\Exception $e) {
870
+                    continue;
871
+                }
872
+                try {
873
+                    $availableTaskTypes[$provider->getTaskTypeId()] = [
874
+                        'name' => $taskType->getName(),
875
+                        'description' => $taskType->getDescription(),
876
+                        'optionalInputShape' => $provider->getOptionalInputShape(),
877
+                        'inputShapeEnumValues' => $provider->getInputShapeEnumValues(),
878
+                        'inputShapeDefaults' => $provider->getInputShapeDefaults(),
879
+                        'inputShape' => $taskType->getInputShape(),
880
+                        'optionalInputShapeEnumValues' => $provider->getOptionalInputShapeEnumValues(),
881
+                        'optionalInputShapeDefaults' => $provider->getOptionalInputShapeDefaults(),
882
+                        'outputShape' => $taskType->getOutputShape(),
883
+                        'outputShapeEnumValues' => $provider->getOutputShapeEnumValues(),
884
+                        'optionalOutputShape' => $provider->getOptionalOutputShape(),
885
+                        'optionalOutputShapeEnumValues' => $provider->getOptionalOutputShapeEnumValues(),
886
+                        'isInternal' => $taskType instanceof IInternalTaskType,
887
+                    ];
888
+                } catch (\Throwable $e) {
889
+                    $this->logger->error('Failed to set up TaskProcessing provider ' . $provider::class, ['exception' => $e]);
890
+                }
891
+            }
892
+
893
+            if ($showDisabled) {
894
+                // Do not cache showDisabled, ever.
895
+                return $availableTaskTypes;
896
+            }
897
+
898
+            $this->availableTaskTypes = $availableTaskTypes;
899
+            $this->distributedCache->set($cacheKey, serialize($this->availableTaskTypes), 60);
900
+        }
901
+
902
+
903
+        return $this->availableTaskTypes;
904
+    }
905
+    public function getAvailableTaskTypeIds(bool $showDisabled = false, ?string $userId = null): array {
906
+        // userId will be obtained from the session if left to null
907
+        if (!$this->checkGuestAccess($userId)) {
908
+            return [];
909
+        }
910
+        if ($this->availableTaskTypeIds === null) {
911
+            $cachedValue = $this->distributedCache->get(self::TASK_TYPE_IDS_CACHE_KEY);
912
+            if ($cachedValue !== null) {
913
+                $this->availableTaskTypeIds = $cachedValue;
914
+            }
915
+        }
916
+        // Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
917
+        if ($this->availableTaskTypeIds === null || $showDisabled) {
918
+            $taskTypes = $this->_getTaskTypes();
919
+            $taskTypeSettings = $this->_getTaskTypeSettings();
920
+
921
+            $availableTaskTypeIds = [];
922
+            foreach ($taskTypes as $taskType) {
923
+                if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
924
+                    continue;
925
+                }
926
+                try {
927
+                    $provider = $this->getPreferredProvider($taskType->getId());
928
+                } catch (\OCP\TaskProcessing\Exception\Exception $e) {
929
+                    continue;
930
+                }
931
+                $availableTaskTypeIds[] = $taskType->getId();
932
+            }
933
+
934
+            if ($showDisabled) {
935
+                // Do not cache showDisabled, ever.
936
+                return $availableTaskTypeIds;
937
+            }
938
+
939
+            $this->availableTaskTypeIds = $availableTaskTypeIds;
940
+            $this->distributedCache->set(self::TASK_TYPE_IDS_CACHE_KEY, $this->availableTaskTypeIds, 60);
941
+        }
942
+
943
+
944
+        return $this->availableTaskTypeIds;
945
+    }
946
+
947
+    public function canHandleTask(Task $task): bool {
948
+        return isset($this->getAvailableTaskTypes()[$task->getTaskTypeId()]);
949
+    }
950
+
951
+    private function checkGuestAccess(?string $userId = null): bool {
952
+        if ($userId === null && !$this->userSession->isLoggedIn()) {
953
+            return true;
954
+        }
955
+        if ($userId === null) {
956
+            $user = $this->userSession->getUser();
957
+        } else {
958
+            $user = $this->userManager->get($userId);
959
+        }
960
+
961
+        $guestsAllowed = $this->appConfig->getValueString('core', 'ai.taskprocessing_guests', 'false');
962
+        if ($guestsAllowed == 'true' || !class_exists(\OCA\Guests\UserBackend::class) || !($user->getBackend() instanceof \OCA\Guests\UserBackend)) {
963
+            return true;
964
+        }
965
+        return false;
966
+    }
967
+
968
+    public function scheduleTask(Task $task): void {
969
+        if (!$this->checkGuestAccess($task->getUserId())) {
970
+            throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
971
+        }
972
+        if (!$this->canHandleTask($task)) {
973
+            throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
974
+        }
975
+        $this->prepareTask($task);
976
+        $task->setStatus(Task::STATUS_SCHEDULED);
977
+        $this->storeTask($task);
978
+        // schedule synchronous job if the provider is synchronous
979
+        $provider = $this->getPreferredProvider($task->getTaskTypeId());
980
+        if ($provider instanceof ISynchronousProvider) {
981
+            $this->jobList->add(SynchronousBackgroundJob::class, null);
982
+        }
983
+        if ($provider instanceof ITriggerableProvider) {
984
+            try {
985
+                if (!$this->taskMapper->hasRunningTasksForTaskType($task->getTaskTypeId())) {
986
+                    // If no tasks are currently running for this task type, nudge the provider to ask for tasks
987
+                    try {
988
+                        $provider->trigger();
989
+                    } catch (\Throwable $e) {
990
+                        $this->logger->error('Failed to trigger the provider after scheduling a task.', [
991
+                            'exception' => $e,
992
+                            'taskId' => $task->getId(),
993
+                            'providerId' => $provider->getId(),
994
+                        ]);
995
+                    }
996
+                }
997
+            } catch (Exception $e) {
998
+                $this->logger->error('Failed to check DB for running tasks after a task was scheduled for a triggerable provider. Not triggering the provider.', [
999
+                    'exception' => $e,
1000
+                    'taskId' => $task->getId(),
1001
+                    'providerId' => $provider->getId()
1002
+                ]);
1003
+            }
1004
+        }
1005
+    }
1006
+
1007
+    public function runTask(Task $task): Task {
1008
+        if (!$this->checkGuestAccess($task->getUserId())) {
1009
+            throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
1010
+        }
1011
+        if (!$this->canHandleTask($task)) {
1012
+            throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
1013
+        }
1014
+
1015
+        $provider = $this->getPreferredProvider($task->getTaskTypeId());
1016
+        if ($provider instanceof ISynchronousProvider) {
1017
+            $this->prepareTask($task);
1018
+            $task->setStatus(Task::STATUS_SCHEDULED);
1019
+            $this->storeTask($task);
1020
+            $this->processTask($task, $provider);
1021
+            $task = $this->getTask($task->getId());
1022
+        } else {
1023
+            $this->scheduleTask($task);
1024
+            // poll task
1025
+            while ($task->getStatus() === Task::STATUS_SCHEDULED || $task->getStatus() === Task::STATUS_RUNNING) {
1026
+                sleep(1);
1027
+                $task = $this->getTask($task->getId());
1028
+            }
1029
+        }
1030
+        return $task;
1031
+    }
1032
+
1033
+    public function processTask(Task $task, ISynchronousProvider $provider): bool {
1034
+        try {
1035
+            try {
1036
+                $input = $this->prepareInputData($task);
1037
+            } catch (GenericFileException|NotPermittedException|LockedException|ValidationException|UnauthorizedException $e) {
1038
+                $this->logger->warning('Failed to prepare input data for a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
1039
+                $this->setTaskResult($task->getId(), $e->getMessage(), null);
1040
+                return false;
1041
+            }
1042
+            try {
1043
+                $this->setTaskStatus($task, Task::STATUS_RUNNING);
1044
+                if ($provider instanceof ISynchronousWatermarkingProvider) {
1045
+                    $output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress), $task->getIncludeWatermark());
1046
+                } else {
1047
+                    $output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress));
1048
+                }
1049
+            } catch (ProcessingException $e) {
1050
+                $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
1051
+                $userFacingErrorMessage = $e instanceof UserFacingProcessingException ? $e->getUserFacingMessage() : null;
1052
+                $this->setTaskResult($task->getId(), $e->getMessage(), null, userFacingError: $userFacingErrorMessage);
1053
+                return false;
1054
+            } catch (\Throwable $e) {
1055
+                $this->logger->error('Unknown error while processing TaskProcessing task', ['exception' => $e]);
1056
+                $this->setTaskResult($task->getId(), $e->getMessage(), null);
1057
+                return false;
1058
+            }
1059
+            $this->setTaskResult($task->getId(), null, $output);
1060
+        } catch (NotFoundException $e) {
1061
+            $this->logger->info('Could not find task anymore after execution. Moving on.', ['exception' => $e]);
1062
+        } catch (Exception $e) {
1063
+            $this->logger->error('Failed to report result of TaskProcessing task', ['exception' => $e]);
1064
+        }
1065
+        return true;
1066
+    }
1067
+
1068
+    public function deleteTask(Task $task): void {
1069
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1070
+        $this->taskMapper->delete($taskEntity);
1071
+    }
1072
+
1073
+    public function getTask(int $id): Task {
1074
+        try {
1075
+            $taskEntity = $this->taskMapper->find($id);
1076
+            return $taskEntity->toPublicTask();
1077
+        } catch (DoesNotExistException $e) {
1078
+            throw new NotFoundException('Couldn\'t find task with id ' . $id, 0, $e);
1079
+        } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
1080
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1081
+        } catch (\JsonException $e) {
1082
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
1083
+        }
1084
+    }
1085
+
1086
+    public function cancelTask(int $id): void {
1087
+        $task = $this->getTask($id);
1088
+        if ($task->getStatus() !== Task::STATUS_SCHEDULED && $task->getStatus() !== Task::STATUS_RUNNING) {
1089
+            return;
1090
+        }
1091
+        $task->setStatus(Task::STATUS_CANCELLED);
1092
+        $task->setEndedAt(time());
1093
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1094
+        try {
1095
+            $this->taskMapper->update($taskEntity);
1096
+            $this->runWebhook($task);
1097
+        } catch (\OCP\DB\Exception $e) {
1098
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1099
+        }
1100
+    }
1101
+
1102
+    public function setTaskProgress(int $id, float $progress): bool {
1103
+        // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
1104
+        $task = $this->getTask($id);
1105
+        if ($task->getStatus() === Task::STATUS_CANCELLED) {
1106
+            return false;
1107
+        }
1108
+        // only set the start time if the task is going from scheduled to running
1109
+        if ($task->getstatus() === Task::STATUS_SCHEDULED) {
1110
+            $task->setStartedAt(time());
1111
+        }
1112
+        $task->setStatus(Task::STATUS_RUNNING);
1113
+        $task->setProgress($progress);
1114
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1115
+        try {
1116
+            $this->taskMapper->update($taskEntity);
1117
+        } catch (\OCP\DB\Exception $e) {
1118
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1119
+        }
1120
+        return true;
1121
+    }
1122
+
1123
+    public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false, ?string $userFacingError = null): void {
1124
+        // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
1125
+        $task = $this->getTask($id);
1126
+        if ($task->getStatus() === Task::STATUS_CANCELLED) {
1127
+            $this->logger->info('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.');
1128
+            return;
1129
+        }
1130
+        if ($error !== null) {
1131
+            $task->setStatus(Task::STATUS_FAILED);
1132
+            $task->setEndedAt(time());
1133
+            // truncate error message to 4000 characters
1134
+            $task->setErrorMessage(substr($error, 0, 4000));
1135
+            // truncate error message to 4000 characters
1136
+            if ($userFacingError !== null) {
1137
+                $task->setUserFacingErrorMessage(substr($userFacingError, 0, 4000));
1138
+            }
1139
+            $this->logger->warning('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' failed with the following message: ' . $error);
1140
+        } elseif ($result !== null) {
1141
+            $taskTypes = $this->getAvailableTaskTypes();
1142
+            $outputShape = $taskTypes[$task->getTaskTypeId()]['outputShape'];
1143
+            $outputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['outputShapeEnumValues'];
1144
+            $optionalOutputShape = $taskTypes[$task->getTaskTypeId()]['optionalOutputShape'];
1145
+            $optionalOutputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['optionalOutputShapeEnumValues'];
1146
+            try {
1147
+                // validate output
1148
+                if (!$isUsingFileIds) {
1149
+                    $this->validateOutputWithFileData($outputShape, $outputShapeEnumValues, $result);
1150
+                    $this->validateOutputWithFileData($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
1151
+                } else {
1152
+                    $this->validateOutputWithFileIds($outputShape, $outputShapeEnumValues, $result);
1153
+                    $this->validateOutputWithFileIds($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
1154
+                }
1155
+                $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape);
1156
+                // extract raw data and put it in files, replace it with file ids
1157
+                if (!$isUsingFileIds) {
1158
+                    $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape);
1159
+                } else {
1160
+                    $this->validateOutputFileIds($output, $outputShape, $optionalOutputShape);
1161
+                }
1162
+                // Turn file objects into IDs
1163
+                foreach ($output as $key => $value) {
1164
+                    if ($value instanceof Node) {
1165
+                        $output[$key] = $value->getId();
1166
+                    }
1167
+                    if (is_array($value) && isset($value[0]) && $value[0] instanceof Node) {
1168
+                        $output[$key] = array_map(fn ($node) => $node->getId(), $value);
1169
+                    }
1170
+                }
1171
+                $task->setOutput($output);
1172
+                $task->setProgress(1);
1173
+                $task->setStatus(Task::STATUS_SUCCESSFUL);
1174
+                $task->setEndedAt(time());
1175
+            } catch (ValidationException $e) {
1176
+                $task->setProgress(1);
1177
+                $task->setStatus(Task::STATUS_FAILED);
1178
+                $task->setEndedAt(time());
1179
+                $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';
1180
+                $task->setErrorMessage($error);
1181
+                $this->logger->error($error, ['exception' => $e, 'output' => $result]);
1182
+            } catch (NotPermittedException $e) {
1183
+                $task->setProgress(1);
1184
+                $task->setStatus(Task::STATUS_FAILED);
1185
+                $task->setEndedAt(time());
1186
+                $error = 'The task was processed successfully but storing the output in a file failed';
1187
+                $task->setErrorMessage($error);
1188
+                $this->logger->error($error, ['exception' => $e]);
1189
+            } catch (InvalidPathException|\OCP\Files\NotFoundException $e) {
1190
+                $task->setProgress(1);
1191
+                $task->setStatus(Task::STATUS_FAILED);
1192
+                $task->setEndedAt(time());
1193
+                $error = 'The task was processed successfully but the result file could not be found';
1194
+                $task->setErrorMessage($error);
1195
+                $this->logger->error($error, ['exception' => $e]);
1196
+            }
1197
+        }
1198
+        try {
1199
+            $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1200
+        } catch (\JsonException $e) {
1201
+            throw new \OCP\TaskProcessing\Exception\Exception('The task was processed successfully but the provider\'s output could not be encoded as JSON for the database.', 0, $e);
1202
+        }
1203
+        try {
1204
+            $this->taskMapper->update($taskEntity);
1205
+            $this->runWebhook($task);
1206
+        } catch (\OCP\DB\Exception $e) {
1207
+            throw new \OCP\TaskProcessing\Exception\Exception($e->getMessage());
1208
+        }
1209
+        if ($task->getStatus() === Task::STATUS_SUCCESSFUL) {
1210
+            $event = new TaskSuccessfulEvent($task);
1211
+        } else {
1212
+            $event = new TaskFailedEvent($task, $error);
1213
+        }
1214
+        $this->dispatcher->dispatchTyped($event);
1215
+    }
1216
+
1217
+    public function getNextScheduledTask(array $taskTypeIds = [], array $taskIdsToIgnore = []): Task {
1218
+        try {
1219
+            $taskEntity = $this->taskMapper->findOldestScheduledByType($taskTypeIds, $taskIdsToIgnore);
1220
+            return $taskEntity->toPublicTask();
1221
+        } catch (DoesNotExistException $e) {
1222
+            throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
1223
+        } catch (\OCP\DB\Exception $e) {
1224
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
1225
+        } catch (\JsonException $e) {
1226
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
1227
+        }
1228
+    }
1229
+
1230
+    public function getNextScheduledTasks(array $taskTypeIds = [], array $taskIdsToIgnore = [], int $numberOfTasks = 1): array {
1231
+        try {
1232
+            return array_map(fn ($taskEntity) => $taskEntity->toPublicTask(), $this->taskMapper->findNOldestScheduledByType($taskTypeIds, $taskIdsToIgnore, $numberOfTasks));
1233
+        } catch (DoesNotExistException $e) {
1234
+            throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
1235
+        } catch (\OCP\DB\Exception $e) {
1236
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
1237
+        } catch (\JsonException $e) {
1238
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
1239
+        }
1240
+    }
1241
+
1242
+    /**
1243
+     * Takes task input data and replaces fileIds with File objects
1244
+     *
1245
+     * @param string|null $userId
1246
+     * @param array<array-key, list<numeric|string>|numeric|string> $input
1247
+     * @param ShapeDescriptor[] ...$specs the specs
1248
+     * @return array<array-key, list<File|numeric|string>|numeric|string|File>
1249
+     * @throws GenericFileException|LockedException|NotPermittedException|ValidationException|UnauthorizedException
1250
+     */
1251
+    public function fillInputFileData(?string $userId, array $input, ...$specs): array {
1252
+        if ($userId !== null) {
1253
+            \OC_Util::setupFS($userId);
1254
+        }
1255
+        $newInputOutput = [];
1256
+        $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1257
+        foreach ($spec as $key => $descriptor) {
1258
+            $type = $descriptor->getShapeType();
1259
+            if (!isset($input[$key])) {
1260
+                continue;
1261
+            }
1262
+            if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1263
+                $newInputOutput[$key] = $input[$key];
1264
+                continue;
1265
+            }
1266
+            if (EShapeType::getScalarType($type) === $type) {
1267
+                // is scalar
1268
+                $node = $this->validateFileId((int)$input[$key]);
1269
+                $this->validateUserAccessToFile($input[$key], $userId);
1270
+                $newInputOutput[$key] = $node;
1271
+            } else {
1272
+                // is list
1273
+                $newInputOutput[$key] = [];
1274
+                foreach ($input[$key] as $item) {
1275
+                    $node = $this->validateFileId((int)$item);
1276
+                    $this->validateUserAccessToFile($item, $userId);
1277
+                    $newInputOutput[$key][] = $node;
1278
+                }
1279
+            }
1280
+        }
1281
+        return $newInputOutput;
1282
+    }
1283
+
1284
+    public function getUserTask(int $id, ?string $userId): Task {
1285
+        try {
1286
+            $taskEntity = $this->taskMapper->findByIdAndUser($id, $userId);
1287
+            return $taskEntity->toPublicTask();
1288
+        } catch (DoesNotExistException $e) {
1289
+            throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e);
1290
+        } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
1291
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
1292
+        } catch (\JsonException $e) {
1293
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
1294
+        }
1295
+    }
1296
+
1297
+    public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array {
1298
+        try {
1299
+            $taskEntities = $this->taskMapper->findByUserAndTaskType($userId, $taskTypeId, $customId);
1300
+            return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1301
+        } catch (\OCP\DB\Exception $e) {
1302
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
1303
+        } catch (\JsonException $e) {
1304
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
1305
+        }
1306
+    }
1307
+
1308
+    public function getTasks(
1309
+        ?string $userId, ?string $taskTypeId = null, ?string $appId = null, ?string $customId = null,
1310
+        ?int $status = null, ?int $scheduleAfter = null, ?int $endedBefore = null,
1311
+    ): array {
1312
+        try {
1313
+            $taskEntities = $this->taskMapper->findTasks($userId, $taskTypeId, $appId, $customId, $status, $scheduleAfter, $endedBefore);
1314
+            return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1315
+        } catch (\OCP\DB\Exception $e) {
1316
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
1317
+        } catch (\JsonException $e) {
1318
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
1319
+        }
1320
+    }
1321
+
1322
+    public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array {
1323
+        try {
1324
+            $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $customId);
1325
+            return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
1326
+        } catch (\OCP\DB\Exception $e) {
1327
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e);
1328
+        } catch (\JsonException $e) {
1329
+            throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e);
1330
+        }
1331
+    }
1332
+
1333
+    /**
1334
+     *Takes task input or output and replaces base64 data with file ids
1335
+     *
1336
+     * @param array $output
1337
+     * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
1338
+     * @return array
1339
+     * @throws NotPermittedException
1340
+     */
1341
+    public function encapsulateOutputFileData(array $output, ...$specs): array {
1342
+        $newOutput = [];
1343
+        try {
1344
+            $folder = $this->appData->getFolder('TaskProcessing');
1345
+        } catch (\OCP\Files\NotFoundException) {
1346
+            $folder = $this->appData->newFolder('TaskProcessing');
1347
+        }
1348
+        $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1349
+        foreach ($spec as $key => $descriptor) {
1350
+            $type = $descriptor->getShapeType();
1351
+            if (!isset($output[$key])) {
1352
+                continue;
1353
+            }
1354
+            if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1355
+                $newOutput[$key] = $output[$key];
1356
+                continue;
1357
+            }
1358
+            if (EShapeType::getScalarType($type) === $type) {
1359
+                /** @var SimpleFile $file */
1360
+                $file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]);
1361
+                $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile
1362
+            } else {
1363
+                $newOutput = [];
1364
+                foreach ($output[$key] as $item) {
1365
+                    /** @var SimpleFile $file */
1366
+                    $file = $folder->newFile(time() . '-' . rand(1, 100000), $item);
1367
+                    $newOutput[$key][] = $file->getId();
1368
+                }
1369
+            }
1370
+        }
1371
+        return $newOutput;
1372
+    }
1373
+
1374
+    /**
1375
+     * @param Task $task
1376
+     * @return array<array-key, list<numeric|string|File>|numeric|string|File>
1377
+     * @throws GenericFileException
1378
+     * @throws LockedException
1379
+     * @throws NotPermittedException
1380
+     * @throws ValidationException|UnauthorizedException
1381
+     */
1382
+    public function prepareInputData(Task $task): array {
1383
+        $taskTypes = $this->getAvailableTaskTypes();
1384
+        $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape'];
1385
+        $optionalInputShape = $taskTypes[$task->getTaskTypeId()]['optionalInputShape'];
1386
+        $input = $task->getInput();
1387
+        $input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape);
1388
+        $input = $this->fillInputFileData($task->getUserId(), $input, $inputShape, $optionalInputShape);
1389
+        return $input;
1390
+    }
1391
+
1392
+    public function lockTask(Task $task): bool {
1393
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1394
+        if ($this->taskMapper->lockTask($taskEntity) === 0) {
1395
+            return false;
1396
+        }
1397
+        $task->setStatus(Task::STATUS_RUNNING);
1398
+        return true;
1399
+    }
1400
+
1401
+    /**
1402
+     * @throws \JsonException
1403
+     * @throws Exception
1404
+     */
1405
+    public function setTaskStatus(Task $task, int $status): void {
1406
+        $currentTaskStatus = $task->getStatus();
1407
+        if ($currentTaskStatus === Task::STATUS_SCHEDULED && $status === Task::STATUS_RUNNING) {
1408
+            $task->setStartedAt(time());
1409
+        } elseif ($currentTaskStatus === Task::STATUS_RUNNING && ($status === Task::STATUS_FAILED || $status === Task::STATUS_CANCELLED)) {
1410
+            $task->setEndedAt(time());
1411
+        } elseif ($currentTaskStatus === Task::STATUS_UNKNOWN && $status === Task::STATUS_SCHEDULED) {
1412
+            $task->setScheduledAt(time());
1413
+        }
1414
+        $task->setStatus($status);
1415
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1416
+        $this->taskMapper->update($taskEntity);
1417
+    }
1418
+
1419
+    /**
1420
+     * Validate input, fill input default values, set completionExpectedAt, set scheduledAt
1421
+     *
1422
+     * @param Task $task
1423
+     * @return void
1424
+     * @throws UnauthorizedException
1425
+     * @throws ValidationException
1426
+     * @throws \OCP\TaskProcessing\Exception\Exception
1427
+     */
1428
+    private function prepareTask(Task $task): void {
1429
+        $taskTypes = $this->getAvailableTaskTypes();
1430
+        $taskType = $taskTypes[$task->getTaskTypeId()];
1431
+        $inputShape = $taskType['inputShape'];
1432
+        $inputShapeDefaults = $taskType['inputShapeDefaults'];
1433
+        $inputShapeEnumValues = $taskType['inputShapeEnumValues'];
1434
+        $optionalInputShape = $taskType['optionalInputShape'];
1435
+        $optionalInputShapeEnumValues = $taskType['optionalInputShapeEnumValues'];
1436
+        $optionalInputShapeDefaults = $taskType['optionalInputShapeDefaults'];
1437
+        // validate input
1438
+        $this->validateInput($inputShape, $inputShapeDefaults, $inputShapeEnumValues, $task->getInput());
1439
+        $this->validateInput($optionalInputShape, $optionalInputShapeDefaults, $optionalInputShapeEnumValues, $task->getInput(), true);
1440
+        // authenticate access to mentioned files
1441
+        $ids = [];
1442
+        foreach ($inputShape + $optionalInputShape as $key => $descriptor) {
1443
+            if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1444
+                /** @var list<int>|int $inputSlot */
1445
+                $inputSlot = $task->getInput()[$key];
1446
+                if (is_array($inputSlot)) {
1447
+                    $ids += $inputSlot;
1448
+                } else {
1449
+                    $ids[] = $inputSlot;
1450
+                }
1451
+            }
1452
+        }
1453
+        foreach ($ids as $fileId) {
1454
+            $this->validateFileId($fileId);
1455
+            $this->validateUserAccessToFile($fileId, $task->getUserId());
1456
+        }
1457
+        // remove superfluous keys and set input
1458
+        $input = $this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape);
1459
+        $inputWithDefaults = $this->fillInputDefaults($input, $inputShapeDefaults, $optionalInputShapeDefaults);
1460
+        $task->setInput($inputWithDefaults);
1461
+        $task->setScheduledAt(time());
1462
+        $provider = $this->getPreferredProvider($task->getTaskTypeId());
1463
+        // calculate expected completion time
1464
+        $completionExpectedAt = new \DateTime('now');
1465
+        $completionExpectedAt->add(new \DateInterval('PT' . $provider->getExpectedRuntime() . 'S'));
1466
+        $task->setCompletionExpectedAt($completionExpectedAt);
1467
+    }
1468
+
1469
+    /**
1470
+     * Store the task in the DB and set its ID in the \OCP\TaskProcessing\Task input param
1471
+     *
1472
+     * @param Task $task
1473
+     * @return void
1474
+     * @throws Exception
1475
+     * @throws \JsonException
1476
+     */
1477
+    private function storeTask(Task $task): void {
1478
+        // create a db entity and insert into db table
1479
+        $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
1480
+        $this->taskMapper->insert($taskEntity);
1481
+        // make sure the scheduler knows the id
1482
+        $task->setId($taskEntity->getId());
1483
+    }
1484
+
1485
+    /**
1486
+     * @param array $output
1487
+     * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
1488
+     * @return array
1489
+     * @throws NotPermittedException
1490
+     */
1491
+    private function validateOutputFileIds(array $output, ...$specs): array {
1492
+        $newOutput = [];
1493
+        $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
1494
+        foreach ($spec as $key => $descriptor) {
1495
+            $type = $descriptor->getShapeType();
1496
+            if (!isset($output[$key])) {
1497
+                continue;
1498
+            }
1499
+            if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
1500
+                $newOutput[$key] = $output[$key];
1501
+                continue;
1502
+            }
1503
+            if (EShapeType::getScalarType($type) === $type) {
1504
+                // Is scalar file ID
1505
+                $newOutput[$key] = $this->validateFileId($output[$key]);
1506
+            } else {
1507
+                // Is list of file IDs
1508
+                $newOutput = [];
1509
+                foreach ($output[$key] as $item) {
1510
+                    $newOutput[$key][] = $this->validateFileId($item);
1511
+                }
1512
+            }
1513
+        }
1514
+        return $newOutput;
1515
+    }
1516
+
1517
+    /**
1518
+     * @param mixed $id
1519
+     * @return File
1520
+     * @throws ValidationException
1521
+     */
1522
+    private function validateFileId(mixed $id): File {
1523
+        $node = $this->rootFolder->getFirstNodeById($id);
1524
+        if ($node === null) {
1525
+            $node = $this->rootFolder->getFirstNodeByIdInPath($id, '/' . $this->rootFolder->getAppDataDirectoryName() . '/');
1526
+            if ($node === null) {
1527
+                throw new ValidationException('Could not find file ' . $id);
1528
+            } elseif (!$node instanceof File) {
1529
+                throw new ValidationException('File with id "' . $id . '" is not a file');
1530
+            }
1531
+        } elseif (!$node instanceof File) {
1532
+            throw new ValidationException('File with id "' . $id . '" is not a file');
1533
+        }
1534
+        return $node;
1535
+    }
1536
+
1537
+    /**
1538
+     * @param mixed $fileId
1539
+     * @param string|null $userId
1540
+     * @return void
1541
+     * @throws UnauthorizedException
1542
+     */
1543
+    private function validateUserAccessToFile(mixed $fileId, ?string $userId): void {
1544
+        if ($userId === null) {
1545
+            throw new UnauthorizedException('User does not have access to file ' . $fileId);
1546
+        }
1547
+        $mounts = $this->userMountCache->getMountsForFileId($fileId);
1548
+        $userIds = array_map(fn ($mount) => $mount->getUser()->getUID(), $mounts);
1549
+        if (!in_array($userId, $userIds)) {
1550
+            throw new UnauthorizedException('User ' . $userId . ' does not have access to file ' . $fileId);
1551
+        }
1552
+    }
1553
+
1554
+    /**
1555
+     * @param Task $task
1556
+     * @return list<int>
1557
+     * @throws NotFoundException
1558
+     */
1559
+    public function extractFileIdsFromTask(Task $task): array {
1560
+        $ids = [];
1561
+        $taskTypes = $this->getAvailableTaskTypes();
1562
+        if (!isset($taskTypes[$task->getTaskTypeId()])) {
1563
+            throw new NotFoundException('Could not find task type');
1564
+        }
1565
+        $taskType = $taskTypes[$task->getTaskTypeId()];
1566
+        foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) {
1567
+            if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1568
+                /** @var int|list<int> $inputSlot */
1569
+                $inputSlot = $task->getInput()[$key];
1570
+                if (is_array($inputSlot)) {
1571
+                    $ids = array_merge($inputSlot, $ids);
1572
+                } else {
1573
+                    $ids[] = $inputSlot;
1574
+                }
1575
+            }
1576
+        }
1577
+        if ($task->getOutput() !== null) {
1578
+            foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) {
1579
+                if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
1580
+                    /** @var int|list<int> $outputSlot */
1581
+                    $outputSlot = $task->getOutput()[$key];
1582
+                    if (is_array($outputSlot)) {
1583
+                        $ids = array_merge($outputSlot, $ids);
1584
+                    } else {
1585
+                        $ids[] = $outputSlot;
1586
+                    }
1587
+                }
1588
+            }
1589
+        }
1590
+        return $ids;
1591
+    }
1592
+
1593
+    /**
1594
+     * @param ISimpleFolder $folder
1595
+     * @param int $ageInSeconds
1596
+     * @return \Generator
1597
+     */
1598
+    public function clearFilesOlderThan(ISimpleFolder $folder, int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
1599
+        foreach ($folder->getDirectoryListing() as $file) {
1600
+            if ($file->getMTime() < time() - $ageInSeconds) {
1601
+                try {
1602
+                    $fileName = $file->getName();
1603
+                    $file->delete();
1604
+                    yield $fileName;
1605
+                } catch (NotPermittedException $e) {
1606
+                    $this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
1607
+                }
1608
+            }
1609
+        }
1610
+    }
1611
+
1612
+    /**
1613
+     * @param int $ageInSeconds
1614
+     * @return \Generator
1615
+     * @throws Exception
1616
+     * @throws InvalidPathException
1617
+     * @throws NotFoundException
1618
+     * @throws \JsonException
1619
+     * @throws \OCP\Files\NotFoundException
1620
+     */
1621
+    public function cleanupTaskProcessingTaskFiles(int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
1622
+        $taskIdsToCleanup = [];
1623
+        foreach ($this->taskMapper->getTasksToCleanup($ageInSeconds) as $task) {
1624
+            $taskIdsToCleanup[] = $task->getId();
1625
+            $ocpTask = $task->toPublicTask();
1626
+            $fileIds = $this->extractFileIdsFromTask($ocpTask);
1627
+            foreach ($fileIds as $fileId) {
1628
+                // only look for output files stored in appData/TaskProcessing/
1629
+                $file = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/core/TaskProcessing/');
1630
+                if ($file instanceof File) {
1631
+                    try {
1632
+                        $fileId = $file->getId();
1633
+                        $fileName = $file->getName();
1634
+                        $file->delete();
1635
+                        yield ['task_id' => $task->getId(), 'file_id' => $fileId, 'file_name' => $fileName];
1636
+                    } catch (NotPermittedException $e) {
1637
+                        $this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
1638
+                    }
1639
+                }
1640
+            }
1641
+        }
1642
+        return $taskIdsToCleanup;
1643
+    }
1644
+
1645
+    /**
1646
+     * Make a request to the task's webhookUri if necessary
1647
+     *
1648
+     * @param Task $task
1649
+     */
1650
+    private function runWebhook(Task $task): void {
1651
+        $uri = $task->getWebhookUri();
1652
+        $method = $task->getWebhookMethod();
1653
+
1654
+        if (!$uri || !$method) {
1655
+            return;
1656
+        }
1657
+
1658
+        if (in_array($method, ['HTTP:GET', 'HTTP:POST', 'HTTP:PUT', 'HTTP:DELETE'], true)) {
1659
+            $client = $this->clientService->newClient();
1660
+            $httpMethod = preg_replace('/^HTTP:/', '', $method);
1661
+            $options = [
1662
+                'timeout' => 30,
1663
+                'body' => json_encode([
1664
+                    'task' => $task->jsonSerialize(),
1665
+                ]),
1666
+                'headers' => ['Content-Type' => 'application/json'],
1667
+            ];
1668
+            try {
1669
+                $client->request($httpMethod, $uri, $options);
1670
+            } catch (ClientException|ServerException $e) {
1671
+                $this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Request failed', ['exception' => $e]);
1672
+            } catch (\Exception|\Throwable $e) {
1673
+                $this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Unknown error', ['exception' => $e]);
1674
+            }
1675
+        } elseif (str_starts_with($method, 'AppAPI:') && str_starts_with($uri, '/')) {
1676
+            $parsedMethod = explode(':', $method, 4);
1677
+            if (count($parsedMethod) < 3) {
1678
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Invalid method: ' . $method);
1679
+            }
1680
+            [, $exAppId, $httpMethod] = $parsedMethod;
1681
+            if (!$this->appManager->isEnabledForAnyone('app_api')) {
1682
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. AppAPI is disabled or not installed.');
1683
+                return;
1684
+            }
1685
+            try {
1686
+                $appApiFunctions = \OCP\Server::get(\OCA\AppAPI\PublicFunctions::class);
1687
+            } catch (ContainerExceptionInterface|NotFoundExceptionInterface) {
1688
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Could not get AppAPI public functions.');
1689
+                return;
1690
+            }
1691
+            $exApp = $appApiFunctions->getExApp($exAppId);
1692
+            if ($exApp === null) {
1693
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is missing.');
1694
+                return;
1695
+            } elseif (!$exApp['enabled']) {
1696
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is disabled.');
1697
+                return;
1698
+            }
1699
+            $requestParams = [
1700
+                'task' => $task->jsonSerialize(),
1701
+            ];
1702
+            $requestOptions = [
1703
+                'timeout' => 30,
1704
+            ];
1705
+            $response = $appApiFunctions->exAppRequest($exAppId, $uri, $task->getUserId(), $httpMethod, $requestParams, $requestOptions);
1706
+            if (is_array($response) && isset($response['error'])) {
1707
+                $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Error during request to ExApp(' . $exAppId . '): ', $response['error']);
1708
+            }
1709
+        }
1710
+    }
1711 1711
 }
Please login to merge, or discard this patch.
public/TaskProcessing/TaskTypes/ImageToTextOpticalCharacterRecognition.php 1 patch
Indentation   +57 added lines, -57 removed lines patch added patch discarded remove patch
@@ -20,69 +20,69 @@
 block discarded – undo
20 20
  * @since 33.0.0
21 21
  */
22 22
 class ImageToTextOpticalCharacterRecognition implements ITaskType {
23
-	/**
24
-	 * @since 33.0.0
25
-	 */
26
-	public const ID = 'core:image2text:ocr';
23
+    /**
24
+     * @since 33.0.0
25
+     */
26
+    public const ID = 'core:image2text:ocr';
27 27
 
28
-	private IL10N $l;
28
+    private IL10N $l;
29 29
 
30
-	/**
31
-	 * @since 33.0.0
32
-	 */
33
-	public function __construct(
34
-		IFactory $l10nFactory,
35
-	) {
36
-		$this->l = $l10nFactory->get('lib');
37
-	}
30
+    /**
31
+     * @since 33.0.0
32
+     */
33
+    public function __construct(
34
+        IFactory $l10nFactory,
35
+    ) {
36
+        $this->l = $l10nFactory->get('lib');
37
+    }
38 38
 
39 39
 
40
-	/**
41
-	 * @since 33.0.0
42
-	 */
43
-	public function getName(): string {
44
-		return $this->l->t('Optical character recognition');
45
-	}
40
+    /**
41
+     * @since 33.0.0
42
+     */
43
+    public function getName(): string {
44
+        return $this->l->t('Optical character recognition');
45
+    }
46 46
 
47
-	/**
48
-	 * @since 33.0.0
49
-	 */
50
-	public function getDescription(): string {
51
-		return $this->l->t('Extract text from files like images or PDFs');
52
-	}
47
+    /**
48
+     * @since 33.0.0
49
+     */
50
+    public function getDescription(): string {
51
+        return $this->l->t('Extract text from files like images or PDFs');
52
+    }
53 53
 
54
-	/**
55
-	 * @since 33.0.0
56
-	 */
57
-	public function getId(): string {
58
-		return self::ID;
59
-	}
54
+    /**
55
+     * @since 33.0.0
56
+     */
57
+    public function getId(): string {
58
+        return self::ID;
59
+    }
60 60
 
61
-	/**
62
-	 * @return ShapeDescriptor[]
63
-	 * @since 33.0.0
64
-	 */
65
-	public function getInputShape(): array {
66
-		return [
67
-			'input' => new ShapeDescriptor(
68
-				$this->l->t('Input files'),
69
-				$this->l->t('The files to extract text from'),
70
-				EShapeType::ListOfFiles
71
-			),
72
-		];
73
-	}
61
+    /**
62
+     * @return ShapeDescriptor[]
63
+     * @since 33.0.0
64
+     */
65
+    public function getInputShape(): array {
66
+        return [
67
+            'input' => new ShapeDescriptor(
68
+                $this->l->t('Input files'),
69
+                $this->l->t('The files to extract text from'),
70
+                EShapeType::ListOfFiles
71
+            ),
72
+        ];
73
+    }
74 74
 
75
-	/**
76
-	 * @return ShapeDescriptor[]
77
-	 * @since 33.0.0
78
-	 */
79
-	public function getOutputShape(): array {
80
-		return [
81
-			'output' => new ShapeDescriptor(
82
-				$this->l->t('Output texts'),
83
-				$this->l->t('The texts that were extracted from the files'),
84
-				EShapeType::ListOfTexts
85
-			),
86
-		];
87
-	}
75
+    /**
76
+     * @return ShapeDescriptor[]
77
+     * @since 33.0.0
78
+     */
79
+    public function getOutputShape(): array {
80
+        return [
81
+            'output' => new ShapeDescriptor(
82
+                $this->l->t('Output texts'),
83
+                $this->l->t('The texts that were extracted from the files'),
84
+                EShapeType::ListOfTexts
85
+            ),
86
+        ];
87
+    }
88 88
 }
Please login to merge, or discard this patch.