import { CurrentPlugin } from '@extism/extism'; import { AlbumAssetV1, WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse, WorkflowTrigger, } from '@immich/plugin-sdk'; import { HttpException, UnauthorizedException } from '@nestjs/common'; import { join } from 'node:path'; import { DummyValue, OnEvent, OnJob } from 'src/decorators'; import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; import { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, ImmichEnvironment, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey, WorkflowResult, WorkflowScanType, WorkflowType, } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; import { withImpliedItems } from 'src/utils/workflow'; const dummy = () => { throw new Error( `Calling host functions is not allowed without setting methods[].hostFunctions=true in the plugin manifest`, ); }; type ExecuteOptions = { read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData; entityId?: string }>; write: (auth: AuthDto, changes: WorkflowChanges) => Promise; }; type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger }; type HostContext = { allowedHosts: string[]; }; export class WorkflowExecutionService extends BaseService { private jwtSecret!: string; private scanning = false; @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] }) async onPluginSync() { await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => { // TODO avoid importing plugins in each worker // Can this use system metadata similar to geocoding? const { environment, resourcePaths, plugins } = this.configRepository.getEnv(); await this.importFolder(resourcePaths.corePlugin, { force: environment === ImmichEnvironment.Development }); if (plugins.external.allow && plugins.external.installFolder) { await this.importFolders(plugins.external.installFolder); } }); } @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] }) async onPluginLoad() { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, ctx, args) => albumService.addAssets(authDto, ...args), ); const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, ctx, args) => albumService.addAssetsToAlbums(authDto, ...args), ); const httpRequest = this.wrap< [ url: string, options?: { method?: string; headers?: Record; body?: string; }, ] >(async (authDto, context, args) => { const hostname = new URL(args[0]).hostname; for (const pattern of context.allowedHosts) { const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*')); if (regex.test(hostname)) { // eslint-disable-next-line unicorn/no-invalid-argument-count const res = await fetch(...args); return { ok: res.ok, status: res.status, body: await res.text(), }; } } throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => tagService.bulkTagAssets(authDto, ...args), ); const functions = { searchAlbums, createAlbum, addAssetsToAlbum, addAssetsToAlbums, httpRequest, bulkTagAssets, }; const stubs: typeof functions = { searchAlbums: dummy, createAlbum: dummy, addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); for (const { id, name, version, wasmBytes, methods } of plugins) { const isMethod = methods.some(({ hostFunctions }) => !hostFunctions); if (isMethod) { const label = `${name}@${version}`; const key = this.getPluginKey({ id, hostFunctions: false }); try { await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: false, functions: stubs }); this.logger.log(`Loaded plugin: ${label}`); } catch (error) { this.logger.error(`Unable to load plugin ${label} (${id})`, error); } } const isMethodWithFunction = methods.some(({ hostFunctions }) => hostFunctions); if (isMethodWithFunction) { const label = `${name}@${version}/worker`; const key = this.getPluginKey({ id, hostFunctions: true }); try { await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions }); this.logger.log(`Loaded plugin with host functions: ${label}`); } catch (error) { this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error); } } } } private getPluginKey({ id, hostFunctions }: { id: string; hostFunctions: boolean }) { return id + (hostFunctions ? '/worker' : ''); } private wrap(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise) { return async (plugin: CurrentPlugin, offset: bigint) => { try { const handle = plugin.read(offset); if (!handle) { return plugin.store( JSON.stringify({ success: false, status: 400, message: 'Called host function without input' }), ); } const { authToken, args } = handle.json() as { authToken: string; args: T }; if (!authToken) { throw new Error('authToken is required'); } const context = plugin.hostContext(); const authDto = this.validate(authToken); const response = await fn(authDto, context, args); return plugin.store(JSON.stringify({ success: true, response })); } catch (error: Error | any) { if (error instanceof HttpException) { this.logger.error(`Plugin host exception: ${error}`); return plugin.store( JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }), ); } this.logger.error(`Plugin host exception: ${error}`, error?.stack); return plugin.store( JSON.stringify({ success: false, status: 500, message: `Internal server error: ${error}`, }), ); } }; } private async importFolders(installFolder: string): Promise { try { const entries = await this.storageRepository.readdirWithTypes(installFolder); for (const entry of entries) { if (!entry.isDirectory()) { continue; } await this.importFolder(join(installFolder, entry.name)); } } catch (error) { this.logger.error(`Failed to import plugins folder ${installFolder}:`, error); } } private async importFolder(folder: string, options?: { force?: boolean }) { try { const manifestPath = join(folder, 'manifest.json'); const bytes = await this.storageRepository.readFile(manifestPath); const contents = bytes.toString('utf8'); const sha256hash = this.cryptoRepository.hashSha256(contents) as Buffer; if (!options?.force) { const match = await this.pluginRepository.getByHash(sha256hash); if (match) { this.logger.log(`Plugin up to date (name=${match.name}@${match.version}, hash=${sha256hash.toString('hex')}`); return; } } const dto = JSON.parse(contents); const result = PluginManifestDto.schema.safeParse(dto); if (!result.success) { const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n'); this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${issues}`); return; } const manifest = result.data; const existing = await this.pluginRepository.getByName(manifest.name); const wasmPath = `${folder}/${manifest.wasmPath}`; const wasmBytes = await this.storageRepository.readFile(wasmPath); const plugin = await this.pluginRepository.upsert( { // NOTE: new properties here need to be added to the on conflict clause in the repository enabled: true, name: manifest.name, title: manifest.title, description: manifest.description, author: manifest.author, version: manifest.version, templates: manifest.templates, wasmBytes, sha256hash, }, manifest.methods, ); if (existing) { this.logger.log( `Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `, ); } else { this.logger.log( `Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`, ); } return manifest; } catch { this.logger.warn(`Failed to import plugin from ${folder}:`); } } private validate(authToken: string): AuthDto { try { const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret); if (!jwt.userId) { throw new UnauthorizedException('Invalid token: missing userId'); } return { user: { id: jwt.userId, }, } as AuthDto; } catch (error) { this.logger.error('Token validation failed:', error); throw new UnauthorizedException('Invalid token'); } } private sign(userId: string) { return this.cryptoRepository.signJwt({ userId }, this.jwtSecret); } @OnEvent({ name: 'AssetCreate' }) onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate }); } @OnEvent({ name: 'AssetMetadataExtracted' }) onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) { // prevent loops // TODO loop detection in job service directly if (source === 'sidecar-write') { return; } return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } @OnEvent({ name: 'AlbumAssetsAdded' }) onAlbumAssetsAdded() { return this.jobRepository.queue({ name: JobName.WorkflowScan, data: { type: WorkflowScanType.AlbumAsset } }); } @OnEvent({ name: 'AssetTag' }) onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); } private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( items.map((workflow) => ({ name: JobName.WorkflowAssetTrigger, data: { workflowId: workflow.id, assetId, trigger }, })), ); } @OnJob({ name: JobName.WorkflowScan, queue: QueueName.Workflow }) private async scan({ type }: JobOf) { if (this.scanning) { return JobStatus.Skipped; } this.scanning = true; if (type !== WorkflowScanType.AlbumAsset) { return; } let checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.WorkflowCheckpoint); const now = await this.syncCheckpointRepository.getNow(); if (!checkpoint) { checkpoint = { albumAssetUuid: now.nowId }; await this.systemMetadataRepository.set(SystemMetadataKey.WorkflowCheckpoint, checkpoint); } const workflows = new Map(); while (checkpoint.albumAssetUuid < now.nowId) { const albumAssets = await this.workflowRepository.getForAlbumAssetV1(checkpoint.albumAssetUuid); if (albumAssets.length === 0) { break; } const jobs = new Map(); for (const albumAsset of albumAssets) { const userId = albumAsset.asset?.ownerId; if (!workflows.has(userId)) { workflows.set( userId, await this.workflowRepository.search({ userId, trigger: WorkflowTrigger.AlbumAssetAdded }), ); } for (const workflow of workflows.get(userId)) { if (!jobs.has(workflow.id)) { jobs.set(workflow.id, []); } jobs.get(workflow.id)!.push({ asset: albumAsset.asset as any, album: { id: albumAsset.albumId } }); } } const queues = await this.workflowRepository.addToQueue( jobs .entries() .map(([workflowId, data]) => ({ workflowId, data })) .toArray(), ); await this.jobRepository.queueAll(queues.map(({ id }) => ({ name: JobName.WorkflowRun, data: { queueId: id } }))); checkpoint!.albumAssetUuid = albumAssets[0].updateId; await this.systemMetadataRepository.set(SystemMetadataKey.WorkflowCheckpoint, checkpoint); } this.scanning = false; return JobStatus.Success; } private writeAssetV1(assetId: string) { const assetService = BaseService.create(AssetService, this); return async (auth: AuthDto, changes: WorkflowChanges) => { const asset = changes.asset; if (!asset) { return; } await assetService.update(auth, assetId, { isFavorite: asset.isFavorite, visibility: asset.visibility, dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined, // TODO allow setting to null longitude: asset.exifInfo?.longitude ?? undefined, // TODO allow setting to null latitude: asset.exifInfo?.latitude ?? undefined, // TODO allow setting to null description: asset.exifInfo?.description ?? undefined, rating: asset.exifInfo?.rating, // TODO add to update dto // make: asset.exifInfo?.make, // model: asset.exifInfo?.model, // city: asset.exifInfo?.city, // state: asset.exifInfo?.state, // country: asset.exifInfo?.country, // lensModel: asset.exifInfo?.lensModel, // fNumber: asset.exifInfo?.fNumber, // fps: asset.exifInfo?.fps, // iso: asset.exifInfo?.iso, }); }; } @OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow }) handleAssetTrigger({ workflowId, assetId }: JobOf) { return this.execute(workflowId, (type) => { switch (type) { case WorkflowType.AssetV1: { return { read: async () => { const asset = await this.workflowRepository.getForAssetV1(assetId); return { data: { asset } as any, authUserId: asset.ownerId, entityId: asset.id, }; }, write: this.writeAssetV1(assetId), } satisfies ExecuteOptions; } default: { return; } } }); } @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) async runQueue({ queueId }: JobOf) { const queue = await this.workflowRepository.getQueue(queueId); for (const item of queue.data) { await this.execute(queue.workflowId, (type) => { switch (type) { case WorkflowType.AssetV1: case WorkflowType.AlbumAssetV1: { return { read: async () => { const workflow = await this.workflowRepository.getForWorkflowRun(queue.workflowId); return { data: item as any, authUserId: workflow!.ownerId, }; }, write: async (auth, changes) => { const workflow = await this.workflowRepository.getForWorkflowRun(queue.workflowId); if ((item as AlbumAssetV1).asset.ownerId === workflow?.ownerId) { await this.writeAssetV1((item as AlbumAssetV1).asset.id)(auth, changes); } }, } satisfies ExecuteOptions; } default: { return; } } }); } } private async execute( workflowId: string, getHandler: (type: T) => ExecuteOptions | undefined, ) { const workflow = await this.workflowRepository.getForWorkflowRun(workflowId); if (!workflow) { return; } // TODO infer from steps let type: T | undefined; for (const targetType of Object.values(WorkflowType)) { const implied = withImpliedItems(targetType); const isMissing = workflow.steps.some((step) => step.types.every((type) => !implied.includes(type))); if (!isMissing) { type = targetType as unknown as T; break; } } if (!type) { throw new Error('Unable to infer workflow event type from steps'); } const handler = getHandler(type); if (!handler) { this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); return; } const { read, write } = handler; const readResult = await read(type); let data = readResult.data; const runId = crypto.randomUUID(); for (const step of workflow.steps) { try { const payload: WorkflowEventPayload = { trigger: workflow.trigger, type, config: step.config ?? {}, workflow: { id: workflowId, authToken: this.sign(readResult.authUserId), stepId: step.id, }, data, }; const context: HostContext = { allowedHosts: step.allowedHosts, }; if (step.methodName.startsWith('noop')) { continue; } const result = await this.pluginRepository.callMethod>( { pluginKey: this.getPluginKey({ id: step.pluginId, hostFunctions: step.hostFunctions }), methodName: step.methodName, }, payload, context, ); if (result?.changes) { await write( { user: { id: readResult.authUserId, }, session: { id: DummyValue.UUID, hasElevatedPermission: true, }, } as AuthDto, result.changes, ); ({ data } = await read(type)); } if (result?.config) { await this.workflowRepository.updateStep(step.id, { config: result.config }); } const shouldContinue = result?.workflow?.continue ?? true; if (!shouldContinue) { if (workflow.logging) { await this.workflowRepository.log({ workflowId, result: WorkflowResult.Halted, workflowStepId: step.id, triggerDataId: readResult.entityId, runId, }); } this.logger.debug(`Workflow ${workflowId} run ${runId} stopped on step ${step.id}`); return; } } catch (error) { this.logger.error(`Error executing workflow ${workflowId} run ${runId}:`, error); if (workflow.logging) { await this.workflowRepository.log({ workflowId, result: WorkflowResult.Error, workflowStepId: step.id, triggerDataId: readResult.entityId, runId, }); } return JobStatus.Failed; } } if (workflow.logging) { await this.workflowRepository.log({ workflowId, result: WorkflowResult.Completed, triggerDataId: readResult.entityId, runId, }); } this.logger.debug(`Workflow ${workflowId} run ${runId} executed successfully`); } }