feat: workflow foundation (#23621)

* feat: plugins

* feat: table definition

* feat: type and migration

* feat: add repositories

* feat: validate manifest with class-validator and load manifest info to database

* feat: workflow/plugin controller/service layer

* feat: implement workflow logic

* feat: make trigger static

* feat: dynamical instantiate plugin instances

* fix: access control and helper script

* feat: it works

* chore: simplify

* refactor: refactor and use queue for workflow execution

* refactor: remove unsused property in plugin-schema

* build wasm in prod

* feat: plugin loader in transaction

* fix: docker build arm64

* generated files

* shell check

* fix tests

* fix: waiting for migration to finish before loading plugin

* remove context reassignment

* feat: use mise to manage extism tools (#23760)

* pr feedback

* refactor: create workflow now including create filters and actions

* feat: workflow medium tests

* fix: broken medium test

* feat: medium tests

* chore: unify workflow job

* sign user id with jwt

* chore: query plugin with filters and action

* chore: read manifest in repository

* chore: load manifest from server configs

* merge main

* feat: endpoint documentation

* pr feedback

* load plugin from absolute path

* refactor:handle trigger

* throw error and return early

* pr feedback

* unify plugin services

* fix: plugins code

* clean up

* remove triggerConfig

* clean up

* displayName and methodName

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: bo0tzz <git@bo0tzz.me>
This commit is contained in:
Alex
2025-11-14 14:05:05 -06:00
committed by GitHub
parent d784d431d0
commit 4dcc049465
89 changed files with 7264 additions and 14 deletions

View File

@@ -53,6 +53,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -125,6 +127,12 @@ export class ImmichDatabase {
UserMetadataAuditTable,
UserTable,
VersionHistoryTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
WorkflowTable,
WorkflowFilterTable,
WorkflowActionTable,
];
functions = [
@@ -231,4 +239,12 @@ export interface DB {
user_metadata_audit: UserMetadataAuditTable;
version_history: VersionHistoryTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
workflow: WorkflowTable;
workflow_filter: WorkflowFilterTable;
workflow_action: WorkflowActionTable;
}

View File

@@ -0,0 +1,113 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "plugin" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"author" character varying NOT NULL,
"version" character varying NOT NULL,
"wasmPath" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "plugin_name_uq" UNIQUE ("name"),
CONSTRAINT "plugin_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db);
await sql`CREATE TABLE "plugin_filter" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"methodName" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"supportedContexts" character varying[] NOT NULL,
"schema" jsonb,
CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"),
CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute(
db,
);
await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db);
await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db);
await sql`CREATE TABLE "plugin_action" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"methodName" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"supportedContexts" character varying[] NOT NULL,
"schema" jsonb,
CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"),
CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute(
db,
);
await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db);
await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db);
await sql`CREATE TABLE "workflow" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"ownerId" uuid NOT NULL,
"triggerType" character varying NOT NULL,
"name" character varying,
"description" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"enabled" boolean NOT NULL DEFAULT true,
CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db);
await sql`CREATE TABLE "workflow_filter" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workflowId" uuid NOT NULL,
"filterId" uuid NOT NULL,
"filterConfig" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db);
await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute(
db,
);
await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db);
await sql`CREATE TABLE "workflow_action" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workflowId" uuid NOT NULL,
"actionId" uuid NOT NULL,
"actionConfig" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db);
await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute(
db,
);
await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "workflow";`.execute(db);
await sql`DROP TABLE "workflow_filter";`.execute(db);
await sql`DROP TABLE "workflow_action";`.execute(db);
await sql`DROP TABLE "plugin";`.execute(db);
await sql`DROP TABLE "plugin_filter";`.execute(db);
await sql`DROP TABLE "plugin_action";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db);
}

View File

@@ -0,0 +1,95 @@
import { PluginContext } from 'src/enum';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
import type { JSONSchema } from 'src/types/plugin-schema.types';
@Table('plugin')
export class PluginTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ index: true, unique: true })
name!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column()
author!: string;
@Column()
version!: string;
@Column()
wasmPath!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_filter')
export class PluginFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_action')
export class PluginActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}

View File

@@ -0,0 +1,78 @@
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from 'src/sql-tools';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
@Table('workflow')
export class WorkflowTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column()
triggerType!: PluginTriggerType;
@Column({ nullable: true })
name!: string | null;
@Column()
description!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['filterId'] })
@Table('workflow_filter')
export class WorkflowFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
filterId!: string;
@Column({ type: 'jsonb', nullable: true })
filterConfig!: FilterConfig | null;
@Column({ type: 'integer' })
order!: number;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['actionId'] })
@Table('workflow_action')
export class WorkflowActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
actionId!: string;
@Column({ type: 'jsonb', nullable: true })
actionConfig!: ActionConfig | null;
@Column({ type: 'integer' })
order!: number;
}