feat: workflow logging (#29878)

* feat: workflow logging

* chore: update sql and openapi patch

* chore: include id in workflow log entries

* chore(server): update sql and lint

* chore(mobile): remove openapi files

* chore(web): lint

* chore: clean up workflow logging

* chore(server): add runId to workflow logs

* chore(server): store workflow results as enum
This commit is contained in:
Ben Beckford 2026-08-13 19:18:24 -07:00 committed by GitHub
parent af33a78d18
commit 447cc40a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 689 additions and 22 deletions

View file

@ -4,6 +4,8 @@ import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
WorkflowCreateDto,
WorkflowGetLogsDto,
WorkflowLogEntryDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowShareResponseDto,
@ -113,4 +115,19 @@ export class WorkflowController {
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Get(':id/logs')
@Authenticated({ permission: Permission.WorkflowLogs })
@Endpoint({
summary: 'Retrieve workflow logs',
description: 'Retrieve logs of a workflows runs by ID',
history: HistoryBuilder.v3(),
})
getWorkflowLogs(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: WorkflowGetLogsDto,
): Promise<WorkflowLogEntryDto[]> {
return this.service.getLogs(auth, id, dto);
}
}

View file

@ -1,6 +1,7 @@
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import { WorkflowResultSchema, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
const WorkflowTriggerResponseSchema = z
@ -17,6 +18,7 @@ const WorkflowSearchSchema = z
name: z.string().optional().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
logging: z.boolean().optional().describe('Workflow logs run results'),
})
.meta({ id: 'WorkflowSearchDto' });
@ -42,6 +44,7 @@ const WorkflowCreateSchema = z
name: z.string().nullable().optional().describe('Workflow name'),
description: z.string().nullable().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
logging: z.boolean().optional().describe('Workflow logs run results'),
steps: z.array(WorkflowStepSchema).optional(),
})
.meta({ id: 'WorkflowCreateDto' });
@ -52,6 +55,7 @@ const WorkflowUpdateSchema = z
name: z.string().nullable().optional().describe('Workflow name'),
description: z.string().nullable().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
logging: z.boolean().optional().describe('Workflow logs run results'),
steps: z.array(WorkflowStepSchema).optional(),
})
.meta({ id: 'WorkflowUpdateDto' });
@ -65,6 +69,7 @@ const WorkflowResponseSchema = z
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Update date'),
enabled: z.boolean().describe('Workflow enabled'),
logging: z.boolean().describe('Workflow logs run results'),
steps: z.array(WorkflowStepSchema).describe('Workflow steps'),
})
.meta({ id: 'WorkflowResponseDto' });
@ -78,12 +83,36 @@ const WorkflowShareResponseSchema = z
})
.meta({ id: 'WorkflowShareResponseDto' });
const WorkflowLogEntrySchema = z
.object({
id: z.uuidv4().describe('Workflow log entry ID'),
at: isoDatetimeToDate.describe('Workflow run date/time'),
result: WorkflowResultSchema.describe('Workflow run result'),
triggerDataId: z.uuid().optional().describe('Workflow trigger data ID'),
lastStep: z
.object({
method: z.string().describe('Method of the step'),
index: z.int().positive().describe('Index of the step in the workflow'),
})
.optional()
.describe('Last step ran, if the workflow ended early'),
})
.meta({ id: 'WorkflowLogEntryDto' });
const WorkflowGetLogsSchema = z.object({
result: WorkflowResultSchema.optional().describe('Filter by run result'),
before: isoDatetimeToDate.optional().describe('Filter by runs before a date/time'),
limit: z.coerce.number().int().positive().default(50).describe('Maximum number of logs'),
});
export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {}
export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {}
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {}
export class WorkflowLogEntryDto extends createZodDto(WorkflowLogEntrySchema) {}
export class WorkflowGetLogsDto extends createZodDto(WorkflowGetLogsSchema) {}
type Workflow = {
id: string;
@ -93,6 +122,7 @@ type Workflow = {
name: string | null;
description: string | null;
enabled: boolean;
logging: boolean;
};
type WorkflowStep = {
@ -107,6 +137,7 @@ export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): Wor
id: workflow.id,
enabled: workflow.enabled,
trigger: workflow.trigger,
logging: workflow.logging,
name: workflow.name,
description: workflow.description,
createdAt: workflow.createdAt.toISOString(),

View file

@ -298,6 +298,7 @@ export enum Permission {
WorkflowRead = 'workflow.read',
WorkflowUpdate = 'workflow.update',
WorkflowDelete = 'workflow.delete',
WorkflowLogs = 'workflow.logs',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
@ -1235,6 +1236,17 @@ export enum CalendarHeatmapType {
Taken = 'Taken',
}
export enum WorkflowResult {
Completed = 'completed',
Halted = 'halted',
Error = 'error',
}
export const WorkflowResultSchema = z
.enum(WorkflowResult)
.describe('Workflow run result')
.meta({ id: 'WorkflowResult' });
export enum SearchOrderField {
FileCreatedAt = 'fileCreatedAt',
LocalDateTime = 'localDateTime',

View file

@ -9,6 +9,7 @@ select
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
"workflow"."logging",
(
select
coalesce(json_agg(agg), '[]')
@ -43,6 +44,7 @@ select
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
"workflow"."logging",
(
select
coalesce(json_agg(agg), '[]')
@ -73,6 +75,7 @@ select
"workflow"."id",
"workflow"."name",
"workflow"."trigger",
"workflow"."logging",
(
select
coalesce(json_agg(agg), '[]')
@ -100,6 +103,39 @@ where
"id" = $2
and "enabled" = $3
-- WorkflowRepository.getLogs
select
"workflow_log"."id",
"workflow_log"."createdAt",
"workflow_log"."result",
"workflow_log"."workflowId",
"workflow_log"."workflowStepId",
"workflow_log"."triggerDataId",
(
select
to_json(obj)
from
(
select
"plugin_method"."pluginId",
"plugin_method"."name" as "methodName",
"workflow_step"."order"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
where
"workflow_step"."id" = "workflow_log"."workflowStepId"
) as obj
) as "step"
from
"workflow_log"
where
"workflow_log"."workflowId" = $1
order by
"workflow_log"."createdAt" desc
limit
$2
-- WorkflowRepository.delete
delete from "workflow"
where

View file

@ -4,8 +4,9 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { WorkflowGetLogsDto, WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { DB } from 'src/schema';
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
import { withTags } from 'src/utils/database';
@ -27,6 +28,7 @@ export class WorkflowRepository {
'workflow.enabled',
'workflow.createdAt',
'workflow.updatedAt',
'workflow.logging',
])
.select((eb) => [
jsonArrayFrom(
@ -66,7 +68,7 @@ export class WorkflowRepository {
getForWorkflowRun(id: string) {
return this.db
.selectFrom('workflow')
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
.select(['workflow.id', 'workflow.name', 'workflow.trigger', 'workflow.logging'])
.select((eb) => [
jsonArrayFrom(
eb
@ -99,6 +101,9 @@ export class WorkflowRepository {
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
return this.db.transaction().execute(async (tx) => {
if (dto.logging === false) {
await tx.deleteFrom('workflow_log').where('workflowId', '=', id).execute();
}
if (Object.values(dto).some((prop) => prop !== undefined)) {
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
}
@ -106,6 +111,39 @@ export class WorkflowRepository {
});
}
@GenerateSql({ params: [DummyValue.UUID, { result: undefined }] })
getLogs(id: string, dto: WorkflowGetLogsDto) {
return this.db
.selectFrom('workflow_log')
.select([
'workflow_log.id',
'workflow_log.createdAt',
'workflow_log.result',
'workflow_log.workflowId',
'workflow_log.workflowStepId',
'workflow_log.triggerDataId',
])
.where('workflow_log.workflowId', '=', id)
.select((eb) => [
jsonObjectFrom(
eb
.selectFrom('workflow_step')
.whereRef('workflow_step.id', '=', 'workflow_log.workflowStepId')
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
.select(['plugin_method.pluginId', 'plugin_method.name as methodName', 'workflow_step.order']),
).as('step'),
])
.$if(dto.result !== undefined, (qb) => qb.where('workflow_log.result', '=', dto.result!))
.$if(dto.before !== undefined, (qb) => qb.where('workflow_log.createdAt', '<', dto.before!))
.orderBy('workflow_log.createdAt', 'desc')
.limit(dto.limit)
.execute();
}
log(dto: Insertable<WorkflowLogTable>) {
return this.db.insertInto('workflow_log').values(dto).execute();
}
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
}

View file

@ -87,6 +87,7 @@ import {
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@ -279,4 +280,5 @@ export interface DB {
workflow: WorkflowTable;
workflow_step: WorkflowStepTable;
workflow_log: WorkflowLogTable;
}

View file

@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "logging" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`CREATE TABLE "workflow_log" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"workflowId" uuid NOT NULL,
"result" character varying NOT NULL,
"workflowStepId" uuid,
"triggerDataId" uuid,
"runId" uuid NOT NULL,
CONSTRAINT "workflow_log_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_log_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "workflow_step" ("id") ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT "workflow_log_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_log_workflowId_idx" ON "workflow_log" ("workflowId");`.execute(db);
await sql`CREATE INDEX "workflow_log_workflowStepId_idx" ON "workflow_log" ("workflowStepId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "logging";`.execute(db);
await sql`DROP TABLE "workflow_log";`.execute(db);
}

View file

@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from '@immich/sql-tools';
import { WorkflowResult } from 'src/enum';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@Table('workflow_log')
export class WorkflowLogTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@ForeignKeyColumn(() => WorkflowTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', index: true })
workflowId!: string;
@Column()
result!: WorkflowResult;
@ForeignKeyColumn(() => WorkflowStepTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
workflowStepId!: string | null;
@Column({ type: 'uuid', nullable: true })
triggerDataId!: string | null;
@Column({ type: 'uuid' })
runId!: string;
}

View file

@ -41,4 +41,7 @@ export class WorkflowTable {
@Column({ type: 'boolean', default: true })
enabled!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
logging!: Generated<boolean>;
}

View file

@ -22,6 +22,7 @@ import {
JobName,
JobStatus,
QueueName,
WorkflowResult,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
@ -38,7 +39,7 @@ const dummy = () => {
};
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T>; entityId?: string }>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
@ -345,6 +346,7 @@ export class WorkflowExecutionService extends BaseService {
return {
data: { asset } as any,
authUserId: asset.ownerId,
entityId: asset.id,
};
},
write: async (auth, changes) => {
@ -412,11 +414,13 @@ export class WorkflowExecutionService extends BaseService {
return;
}
try {
const { read, write } = handler;
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
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<typeof type> = {
trigger: workflow.trigger,
type,
@ -467,14 +471,45 @@ export class WorkflowExecutionService extends BaseService {
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;
}
}
if (workflow.logging) {
await this.workflowRepository.log({
workflowId,
result: WorkflowResult.Halted,
workflowStepId: step.id,
triggerDataId: readResult.entityId,
runId,
});
}
this.logger.debug(`Workflow ${workflowId} executed successfully`);
} catch (error) {
this.logger.error(`Error executing workflow ${workflowId}:`, error);
return JobStatus.Failed;
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`);
}
}

View file

@ -5,6 +5,8 @@ import {
mapWorkflow,
mapWorkflowShare,
WorkflowCreateDto,
WorkflowGetLogsDto,
WorkflowLogEntryDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowShareResponseDto,
@ -83,6 +85,23 @@ export class WorkflowService extends BaseService {
await this.workflowRepository.delete(id);
}
async getLogs(auth: AuthDto, id: string, dto: WorkflowGetLogsDto): Promise<WorkflowLogEntryDto[]> {
await this.requireAccess({ auth, permission: Permission.WorkflowLogs, ids: [id] });
const logs = await this.workflowRepository.getLogs(id, dto);
return logs.map((entry) => ({
id: entry.id,
at: entry.createdAt,
result: entry.result,
triggerDataId: entry.triggerDataId ?? undefined,
lastStep: entry.step
? {
index: entry.step.order,
method: `${entry.step.pluginId}#${entry.step.methodName}`,
}
: undefined,
}));
}
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
const methods = await this.pluginRepository.getForValidation();
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];

View file

@ -324,7 +324,8 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
case Permission.WorkflowRead:
case Permission.WorkflowUpdate:
case Permission.WorkflowDelete: {
case Permission.WorkflowDelete:
case Permission.WorkflowLogs: {
return access.workflow.checkOwnerAccess(auth.user.id, ids);
}