From eec0167a207f32ae1e46613abfd662aadbae8c20 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 4 Mar 2025 14:21:28 +0100 Subject: [PATCH 01/31] feat: add action success events to db --- .../migration.sql | 10 ++++++++++ core/prisma/schema/schema.dbml | 2 ++ core/prisma/schema/schema.prisma | 2 ++ packages/db/src/public/Event.ts | 2 ++ 4 files changed, 16 insertions(+) create mode 100644 core/prisma/migrations/20250304132049_add_action_success_events/migration.sql diff --git a/core/prisma/migrations/20250304132049_add_action_success_events/migration.sql b/core/prisma/migrations/20250304132049_add_action_success_events/migration.sql new file mode 100644 index 000000000..fa7125100 --- /dev/null +++ b/core/prisma/migrations/20250304132049_add_action_success_events/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Event" ADD VALUE 'actionSucceeded'; +ALTER TYPE "Event" ADD VALUE 'actionFailed'; diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index a649027fd..d1a8ebb25 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -509,6 +509,8 @@ Enum Event { pubEnteredStage pubLeftStage pubInStageForDuration + actionSucceeded + actionFailed } Enum FormAccessType { diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 55db04e39..bfda6e0d1 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -464,6 +464,8 @@ enum Event { pubEnteredStage pubLeftStage pubInStageForDuration + actionSucceeded + actionFailed } enum FormAccessType { diff --git a/packages/db/src/public/Event.ts b/packages/db/src/public/Event.ts index 17473ee3d..46605ae97 100644 --- a/packages/db/src/public/Event.ts +++ b/packages/db/src/public/Event.ts @@ -8,6 +8,8 @@ export enum Event { pubEnteredStage = "pubEnteredStage", pubLeftStage = "pubLeftStage", pubInStageForDuration = "pubInStageForDuration", + actionSucceeded = "actionSucceeded", + actionFailed = "actionFailed", } /** Zod schema for Event */ From 4ce4eb84700b817706d02f06fc9c9d876a2da8bb Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 5 Mar 2025 18:24:00 +0100 Subject: [PATCH 02/31] feat: setup basic chain actions --- core/actions/_lib/rules.ts | 26 +- core/actions/_lib/zodTypes.ts | 21 ++ core/actions/api/index.ts | 25 +- core/actions/types.ts | 17 +- .../[communitySlug]/stages/manage/actions.ts | 17 +- .../panel/actionsTab/StagePanelRule.tsx | 43 ++- .../actionsTab/StagePanelRuleCreator.tsx | 290 +++++++++++++----- .../panel/actionsTab/StagePanelRules.tsx | 1 + core/app/components/pubs/PubEditor/helpers.ts | 8 + core/lib/db/queries.ts | 30 +- core/lib/server/rules.ts | 176 ++++++++++- .../migration.sql | 13 + core/prisma/schema/schema.dbml | 5 + core/prisma/schema/schema.prisma | 12 +- packages/db/src/public/PublicSchema.ts | 44 +-- packages/db/src/public/Rules.ts | 9 + packages/db/src/table-names.ts | 8 + packages/ui/package.json | 2 + .../ActionInstanceSelector.tsx | 49 +++ .../ActionInstanceSelectorInput.tsx | 8 + .../ActionInstancesContext.tsx | 48 +++ packages/ui/src/actionInstances/index.tsx | 2 + packages/ui/src/auto-form/config.ts | 3 + 23 files changed, 725 insertions(+), 132 deletions(-) create mode 100644 core/prisma/migrations/20250305135055_add_action_ref_to_rul/migration.sql create mode 100644 packages/ui/src/actionInstances/ActionInstanceSelector.tsx create mode 100644 packages/ui/src/actionInstances/ActionInstanceSelectorInput.tsx create mode 100644 packages/ui/src/actionInstances/ActionInstancesContext.tsx create mode 100644 packages/ui/src/actionInstances/index.tsx diff --git a/core/actions/_lib/rules.ts b/core/actions/_lib/rules.ts index 09077ac50..5247a0e04 100644 --- a/core/actions/_lib/rules.ts +++ b/core/actions/_lib/rules.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ActionInstances } from "db/public"; import { Event } from "db/public"; import { defineRule } from "~/actions/types"; @@ -37,7 +38,30 @@ export const pubEnteredStage = defineRule({ }); export type PubEnteredStage = typeof pubEnteredStage; -export type Rules = PubInStageForDuration | PubLeftStage | PubEnteredStage; +export const actionSucceeded = defineRule({ + event: Event.actionSucceeded, + display: { + base: "a specific action succeeds", + withConfig: (actionInstance: ActionInstances) => `${actionInstance.name} succeeds`, + }, +}); +export type ActionSucceeded = typeof actionSucceeded; + +export const actionFailed = defineRule({ + event: Event.actionFailed, + display: { + base: "a specific action fails", + withConfig: (actionInstance) => `${actionInstance.name} fails`, + }, +}); +export type ActionFailed = typeof actionFailed; + +export type Rules = + | PubInStageForDuration + | PubLeftStage + | PubEnteredStage + | ActionSucceeded + | ActionFailed; export type RuleForEvent = Extract; diff --git a/core/actions/_lib/zodTypes.ts b/core/actions/_lib/zodTypes.ts index 7b9351aab..25f58d381 100644 --- a/core/actions/_lib/zodTypes.ts +++ b/core/actions/_lib/zodTypes.ts @@ -32,5 +32,26 @@ class StringWithTokens extends z.ZodString { } } +const actionInstanceShape = { + name: z.string(), + description: z.string(), + icon: z.string(), + action: z.string(), + actionInstanceId: z.string().uuid(), +}; + +export type ActionInstanceConfig = z.infer>; + +class ActionInstance extends z.ZodObject { + static create = () => + new ActionInstance({ + typeName: "ActionInstance" as z.ZodFirstPartyTypeKind.ZodObject, + shape: () => actionInstanceShape, + catchall: z.never(), + unknownKeys: "strip", + }); +} + export const markdown = Markdown.create; export const stringWithTokens = StringWithTokens.create; +export const actionInstance = ActionInstance.create; diff --git a/core/actions/api/index.ts b/core/actions/api/index.ts index 7fc3600ff..520fe18a9 100644 --- a/core/actions/api/index.ts +++ b/core/actions/api/index.ts @@ -2,9 +2,16 @@ import type * as z from "zod"; -import type { Event } from "db/public"; +import type { ActionInstances, Event } from "db/public"; -import { pubEnteredStage, pubInStageForDuration, pubLeftStage } from "../_lib/rules"; +import type { ReferentialRuleEvent } from "../types"; +import { + actionFailed, + actionSucceeded, + pubEnteredStage, + pubInStageForDuration, + pubLeftStage, +} from "../_lib/rules"; import * as datacite from "../datacite/action"; import * as email from "../email/action"; import * as googleDriveImport from "../googleDriveImport/action"; @@ -13,6 +20,7 @@ import * as log from "../log/action"; import * as move from "../move/action"; import * as pdf from "../pdf/action"; import * as pushToV6 from "../pushToV6/action"; +import { isReferentialRuleEvent, referentialRuleEvents } from "../types"; export const actions = { [log.action.name]: log.action, @@ -41,22 +49,33 @@ export const rules = { [pubInStageForDuration.event]: pubInStageForDuration, [pubEnteredStage.event]: pubEnteredStage, [pubLeftStage.event]: pubLeftStage, + [actionSucceeded.event]: actionSucceeded, + [actionFailed.event]: actionFailed, } as const; export const getRuleByName = (name: T) => { return rules[name]; }; +export const isReferentialRule = ( + rule: (typeof rules)[keyof typeof rules] +): rule is Extract => + referentialRuleEvents.includes(rule.event as any); + export const humanReadableEvent = ( event: T, config?: (typeof rules)[T]["additionalConfig"] extends undefined ? never - : z.infer> + : z.infer>, + watchedAction?: ActionInstances | null ) => { const rule = getRuleByName(event); if (config && rule.additionalConfig) { return rule.display.withConfig(config); } + if (watchedAction && isReferentialRule(rule)) { + return rule.display.withConfig(watchedAction); + } return rule.display.base; }; diff --git a/core/actions/types.ts b/core/actions/types.ts index cbc2758a4..117baad0d 100644 --- a/core/actions/types.ts +++ b/core/actions/types.ts @@ -3,16 +3,17 @@ import type * as z from "zod"; import type { ProcessedPub } from "contracts"; import type { + ActionInstances, Action as ActionName, ActionRunsId, CommunitiesId, - Event, PubsId, StagesId, } from "db/public"; import type { LastModifiedBy } from "db/types"; import type { Dependency, FieldConfig, FieldConfigItem } from "ui/auto-form"; import type * as Icons from "ui/icon"; +import { Event } from "db/public"; import type { CorePubField } from "./corePubFields"; import type { ClientExceptionOptions } from "~/lib/serverActions"; @@ -176,6 +177,12 @@ export const defineRun = ( export type Run = ReturnType; +export const referentialRuleEvents = [Event.actionSucceeded, Event.actionFailed] as const; +export type ReferentialRuleEvent = (typeof referentialRuleEvents)[number]; + +export const isReferentialRuleEvent = (event: Event): event is ReferentialRuleEvent => + referentialRuleEvents.includes(event as any); + export type EventRuleOptionsBase< E extends Event, AC extends Record | undefined = undefined, @@ -195,7 +202,13 @@ export type EventRuleOptionsBase< /** * The display name for this event when used in a rule */ - [K in "withConfig" as AC extends Record ? K : never]: (options: AC) => string; + [K in "withConfig" as AC extends Record + ? K + : E extends ReferentialRuleEvent + ? K + : never]: ( + options: AC extends Record ? AC : ActionInstances + ) => string; }; }; diff --git a/core/app/c/[communitySlug]/stages/manage/actions.ts b/core/app/c/[communitySlug]/stages/manage/actions.ts index 577ebd3ab..a8eda541f 100644 --- a/core/app/c/[communitySlug]/stages/manage/actions.ts +++ b/core/app/c/[communitySlug]/stages/manage/actions.ts @@ -33,7 +33,7 @@ import { revalidateTagsForCommunity } from "~/lib/server/cache/revalidate"; import { findCommunityBySlug } from "~/lib/server/community"; import { defineServerAction } from "~/lib/server/defineServerAction"; import { insertStageMember } from "~/lib/server/member"; -import { createRule, removeRule } from "~/lib/server/rules"; +import { createRule, createRuleWithCycleCheck, removeRule, RuleError } from "~/lib/server/rules"; import { createMoveConstraint as createMoveConstraintDb, createStage as createStageDb, @@ -390,22 +390,23 @@ export const addRule = defineServerAction(async function addRule({ } try { - await createRule({ + await createRuleWithCycleCheck({ actionInstanceId: data.actionInstanceId as ActionInstancesId, event: data.event, config: "additionalConfiguration" in data ? data.additionalConfiguration : null, - }).executeTakeFirstOrThrow(); + watchedActionId: + "watchedActionInstanceId" in data ? data.watchedActionInstanceId : undefined, + }); } catch (error) { logger.error(error); - if (error.message?.includes("unique constraint")) { + if (error instanceof RuleError) { return { - title: "Rule already exists", - error: `A rule for '${humanReadableEvent(data.event)}' and this action already exists. Please add another action - of the same type to this stage in order to have the same action trigger - multiple times for '${humanReadableEvent(data.event)}'.`, + title: "Error creating rule", + error: error.message, cause: error, }; } + console.log(error); return { error: "Failed to add rule", diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRule.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRule.tsx index bddaeab3e..65cbd18dc 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRule.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRule.tsx @@ -2,9 +2,10 @@ import { useCallback } from "react"; -import type { Action, CommunitiesId, Event, RulesId, StagesId } from "db/public"; +import type { Action, ActionInstances, CommunitiesId, Event, RulesId, StagesId } from "db/public"; import { Button } from "ui/button"; import { Trash } from "ui/icon"; +import { cn } from "utils"; import type { RuleForEvent } from "~/actions/_lib/rules"; import type { RuleConfig } from "~/actions/types"; @@ -18,15 +19,15 @@ type Props = { rule: { id: RulesId; event: Event; - instanceName: string; - action: Action; + actionInstance: ActionInstances; + watchedActionInstance?: ActionInstances | null; config?: RuleConfig> | null; }; }; -const actionIcon = (actionName: Action) => { - const action = getActionByName(actionName); - return ; +const ActionIcon = (props: { actionName: Action; className?: string }) => { + const action = getActionByName(props.actionName); + return ; }; export const StagePanelRule = (props: Props) => { @@ -40,15 +41,33 @@ export const StagePanelRule = (props: Props) => {
- {actionIcon(rule.action)} + If{" "} - {rule.instanceName} - {" "} - will run when{" "} - - {humanReadableEvent(rule.event, rule.config ?? undefined)} + {rule.watchedActionInstance ? ( + <> + + {humanReadableEvent( + rule.event, + rule.config ?? undefined, + rule.watchedActionInstance + )} + + ) : ( + humanReadableEvent(rule.event, rule.config ?? undefined) + )} + , run{" "} + + + {rule.actionInstance.name} + {" "}
diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx index d56df722c..6ace1d3e5 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx @@ -1,5 +1,7 @@ "use client"; +import type { ControllerRenderProps, FieldValue, UseFormReturn } from "react-hook-form"; + import { useCallback, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -12,7 +14,8 @@ import type { CommunitiesId, StagesId, } from "db/public"; -import { Event } from "db/public"; +import { actionInstancesIdSchema, Event } from "db/public"; +import { ActionInstanceProvider } from "ui/actionInstances"; import { AutoFormObject } from "ui/auto-form"; import { Button } from "ui/button"; import { @@ -27,9 +30,10 @@ import { import { Form, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; -import type { Rules } from "~/actions/_lib/rules"; +import type { RuleConfig, RuleForEvent, Rules } from "~/actions/_lib/rules"; +import type { ReferentialRuleEvent } from "~/actions/types"; import { actions, getRuleByName, humanReadableEvent, rules } from "~/actions/api"; -import { useServerAction } from "~/lib/serverActions"; +import { isClientException, useServerAction } from "~/lib/serverActions"; import { addRule } from "../../../actions"; type Props = { @@ -39,36 +43,132 @@ type Props = { rules: { id: string; event: Event; - instanceName: string; - action: Action; - actionInstanceId: ActionInstancesId; + actionInstance: ActionInstances; + watchedAction?: ActionInstances; + config?: RuleConfig> | null; }[]; }; -const schema = z.discriminatedUnion("event", [ - z.object({ - event: z.literal(Event.pubEnteredStage), - actionInstanceId: z.string().uuid(), - }), - z.object({ - event: z.literal(Event.pubLeftStage), - actionInstanceId: z.string().uuid(), - }), - ...Object.values(rules) - .filter( - (rule): rule is Exclude => - rule.event !== Event.pubEnteredStage && rule.event !== Event.pubLeftStage - ) - .map((rule) => - z.object({ - event: z.literal(rule.event), - actionInstanceId: z.string().uuid(), - additionalConfiguration: rule.additionalConfig - ? rule.additionalConfig - : z.null().optional(), - }) - ), -]); +// Action selector component to be reused +const ActionSelector = ({ + fieldProps, + actionInstances, + label, + placeholder, + disabledActionId, +}: { + fieldProps: Omit, "name">; + actionInstances: ActionInstances[]; + label: string; + placeholder: string; + disabledActionId?: string; +}) => { + return ( + + {label} + + + + ); +}; + +const schema = z + .discriminatedUnion("event", [ + z.object({ + event: z.literal(Event.pubEnteredStage), + actionInstanceId: actionInstancesIdSchema, + }), + z.object({ + event: z.literal(Event.pubLeftStage), + actionInstanceId: actionInstancesIdSchema, + }), + z.object({ + event: z.literal(Event.actionSucceeded), + actionInstanceId: actionInstancesIdSchema, + watchedActionInstanceId: actionInstancesIdSchema, + }), + z.object({ + event: z.literal(Event.actionFailed), + actionInstanceId: actionInstancesIdSchema, + watchedActionInstanceId: actionInstancesIdSchema, + }), + ...Object.values(rules) + .filter( + ( + rule + ): rule is Exclude< + Rules, + { + event: + | Event.pubEnteredStage + | Event.pubLeftStage + | Event.actionSucceeded + | Event.actionFailed; + } + > => + ![ + Event.pubEnteredStage, + Event.pubLeftStage, + Event.actionSucceeded, + Event.actionFailed, + ].includes(rule.event) + ) + .map((rule) => + z.object({ + event: z.literal(rule.event), + actionInstanceId: actionInstancesIdSchema, + additionalConfiguration: rule.additionalConfig + ? rule.additionalConfig + : z.null().optional(), + }) + ), + ]) + .superRefine((data, ctx) => { + if (data.event !== Event.actionSucceeded && data.event !== Event.actionFailed) { + return; + } + + if (data.watchedActionInstanceId === data.actionInstanceId) { + ctx.addIssue({ + path: ["watchedActionInstanceId"], + code: z.ZodIssueCode.custom, + message: "Rules may not trigger actions in a loop", + }); + } + }); export type CreateRuleSchema = z.infer; @@ -77,10 +177,12 @@ export const StagePanelRuleCreator = (props: Props) => { const [isOpen, setIsOpen] = useState(false); const onSubmit = useCallback( async (data: CreateRuleSchema) => { - setIsOpen(false); - runAddRule({ stageId: props.stageId, data }); + const result = await runAddRule({ stageId: props.stageId, data }); + if (!isClientException(result)) { + setIsOpen(false); + } }, - [props.communityId] + [props.stageId, runAddRule] ); const onOpenChange = useCallback((open: boolean) => { @@ -89,15 +191,41 @@ export const StagePanelRuleCreator = (props: Props) => { const form = useForm>({ resolver: zodResolver(schema), + defaultValues: { + actionInstanceId: undefined, + event: undefined, + }, }); const event = form.watch("event"); + const selectedActionInstanceId = form.watch("actionInstanceId"); + const watchedActionInstanceId = form.watch("watchedActionInstanceId"); + + // For action chaining events, filter out self-references + const isActionChainingEvent = event === Event.actionSucceeded || event === Event.actionFailed; + + // Get disallowed events based on current configuration + const getDisallowedEvents = useCallback(() => { + if (!selectedActionInstanceId) return []; - const selectedAction = form.watch("actionInstanceId"); + return props.rules + .filter((rule) => { + // For regular events, disallow if same action+event already exists + if (rule.event !== Event.actionSucceeded && rule.event !== Event.actionFailed) { + return rule.actionInstance.id === selectedActionInstanceId; + } - const disallowedEvents = props.rules - .filter((rule) => rule.actionInstanceId === selectedAction) - .map((rule) => rule.event); + // For action chaining events, allow multiple rules with different watched actions + return ( + rule.actionInstance.id === selectedActionInstanceId && + rule.event === event && + rule.watchedAction?.id === watchedActionInstanceId + ); + }) + .map((rule) => rule.event); + }, [props.rules, selectedActionInstanceId, event, form]); + + const disallowedEvents = getDisallowedEvents(); const allowedEvents = Object.values(Event).filter((event) => !disallowedEvents.includes(event)); const rule = getRuleByName(event); @@ -125,36 +253,12 @@ export const StagePanelRuleCreator = (props: Props) => { control={form.control} name="actionInstanceId" render={({ field }) => ( - - - - - + )} /> { name="event" render={({ field }) => ( - + when... {allowedEvents.length > 0 ? ( <> + + + + + {actionInstances.map((instance) => { + const action = actions[instance.action]; + + return ( + +
+ + {instance.name} +
+
+ ); + })} +
+ + +
+ )} + /> + ); +}; diff --git a/packages/ui/src/actionInstances/ActionInstanceSelectorInput.tsx b/packages/ui/src/actionInstances/ActionInstanceSelectorInput.tsx new file mode 100644 index 000000000..a83cbf556 --- /dev/null +++ b/packages/ui/src/actionInstances/ActionInstanceSelectorInput.tsx @@ -0,0 +1,8 @@ +import React from "react"; + +import type { AutoFormInputComponentProps } from "../auto-form"; +import { ActionInstanceSelector } from "./ActionInstanceSelector"; + +export const ActionInstanceSelectorInput = (props: AutoFormInputComponentProps) => { + return ; +}; diff --git a/packages/ui/src/actionInstances/ActionInstancesContext.tsx b/packages/ui/src/actionInstances/ActionInstancesContext.tsx new file mode 100644 index 000000000..4d664176a --- /dev/null +++ b/packages/ui/src/actionInstances/ActionInstancesContext.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { JSX } from "react"; + +import React, { createContext, useContext } from "react"; + +import type { Action, ActionInstances } from "db/public"; + +type ActionType = { + name: Action; + config: { + schema: Record; + }; + description: string; + params: { + schema: Record; + }; + icon: (props: any) => React.ReactNode | JSX.Element; + superAdminOnly?: boolean; + experimental?: boolean; + tokens?: Record; +}; + +export type ActionInstanceContext = { + actions: Record; + actionInstances: ActionInstances[]; +}; + +type Props = { + children: React.ReactNode; + actionInstances: ActionInstances[]; + actions: Record; +}; + +const ActionInstanceContext = createContext({ + actions: {}, + actionInstances: [], +}); + +export function ActionInstanceProvider({ children, actionInstances, actions }: Props) { + return ( + + {children} + + ); +} + +export const useActionInstanceContext = () => useContext(ActionInstanceContext); diff --git a/packages/ui/src/actionInstances/index.tsx b/packages/ui/src/actionInstances/index.tsx new file mode 100644 index 000000000..364bd3106 --- /dev/null +++ b/packages/ui/src/actionInstances/index.tsx @@ -0,0 +1,2 @@ +export * from "./ActionInstancesContext"; +export * from "./ActionInstanceSelector"; diff --git a/packages/ui/src/auto-form/config.ts b/packages/ui/src/auto-form/config.ts index 613c0f4f3..bc76308ea 100644 --- a/packages/ui/src/auto-form/config.ts +++ b/packages/ui/src/auto-form/config.ts @@ -1,3 +1,4 @@ +import { ActionInstanceSelectorInput } from "../actionInstances/ActionInstanceSelectorInput"; import { InputWithTokens, MarkdownEditor } from "../editors"; import AutoFormCheckbox from "./fields/checkbox"; import AutoFormDate from "./fields/date"; @@ -21,6 +22,7 @@ export const INPUT_COMPONENTS = { fallback: AutoFormInput, markdown: MarkdownEditor, stringWithTokens: InputWithTokens, + actionInstance: ActionInstanceSelectorInput, }; /** @@ -37,4 +39,5 @@ export const DEFAULT_ZOD_HANDLERS: { ZodNumber: "number", Markdown: "markdown", StringWithTokens: "stringWithTokens", + ActionInstance: "actionInstance", }; From d4554374795b2db17e7731a5a62aca4ac8816018 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Mar 2025 15:41:35 +0100 Subject: [PATCH 03/31] fix: remove unique constraint --- core/prisma/schema/schema.prisma | 1 - 1 file changed, 1 deletion(-) diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 2fa793a25..16c5188ad 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -464,7 +464,6 @@ model Rule { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - @@unique([actionInstanceId, event]) @@map(name: "rules") } From ec99078bae399bc070af16be24c4d7be5e699009 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Mar 2025 15:46:01 +0100 Subject: [PATCH 04/31] fix: fix types for actioninstancescontext --- packages/ui/src/actionInstances/ActionInstancesContext.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/actionInstances/ActionInstancesContext.tsx b/packages/ui/src/actionInstances/ActionInstancesContext.tsx index 4d664176a..224dc7c5e 100644 --- a/packages/ui/src/actionInstances/ActionInstancesContext.tsx +++ b/packages/ui/src/actionInstances/ActionInstancesContext.tsx @@ -22,15 +22,13 @@ type ActionType = { }; export type ActionInstanceContext = { - actions: Record; + actions: Record; actionInstances: ActionInstances[]; }; type Props = { children: React.ReactNode; - actionInstances: ActionInstances[]; - actions: Record; -}; +} & ActionInstanceContext; const ActionInstanceContext = createContext({ actions: {}, From 3ce58ce54cdb5e563f4074cd0767bb81723d8c16 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Mar 2025 15:47:47 +0100 Subject: [PATCH 05/31] fix: ignore error --- core/actions/_lib/zodTypes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/actions/_lib/zodTypes.ts b/core/actions/_lib/zodTypes.ts index 25f58d381..22e66d419 100644 --- a/core/actions/_lib/zodTypes.ts +++ b/core/actions/_lib/zodTypes.ts @@ -42,6 +42,7 @@ const actionInstanceShape = { export type ActionInstanceConfig = z.infer>; +// @ts-expect-error FIXME: '{ name: z.ZodString; description: z.ZodString; icon: z.ZodString; action: z.ZodString; actionInstanceId: z.ZodString; }' is assignable to the constraint of type 'T_1', but 'T_1' could be instantiated with a different subtype of constraint 'ZodRawShape'.ts(2417) class ActionInstance extends z.ZodObject { static create = () => new ActionInstance({ From e2ba8e091fc0283215f833a7d702fa73800b4765 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 6 Mar 2025 16:03:07 +0100 Subject: [PATCH 06/31] fix: fix type errors --- core/lib/server/rules.ts | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/core/lib/server/rules.ts b/core/lib/server/rules.ts index d2a72c139..62cba069c 100644 --- a/core/lib/server/rules.ts +++ b/core/lib/server/rules.ts @@ -5,6 +5,7 @@ import { Event } from "db/public"; import { expect } from "utils"; import type { ReferentialRuleEvent } from "~/actions/types"; +import { isReferentialRuleEvent } from "~/actions/types"; import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { autoRevalidate } from "./cache/autoRevalidate"; @@ -24,8 +25,8 @@ async function wouldCreateCycle( .withRecursive("action_path", (cte) => cte .selectFrom("action_instances") - .where("id", "=", watchedActionId) .select(["id", sql`array[id]`.as("path")]) + .where("id", "=", watchedActionId) .unionAll((qb) => qb .selectFrom("action_path") @@ -35,15 +36,18 @@ async function wouldCreateCycle( "action_instances.id", "rules.actionInstanceId" ) - .where((eb) => - eb.not(eb("action_instances.id", "=", eb.fn.any("action_path.path"))) - ) .select([ "action_instances.id", sql< ActionInstancesId[] >`action_path.path || array[action_instances.id]`.as("path"), ]) + .where((eb) => + // @ts-expect-error FIXME: i straight up don't know what it's yelling about + // might be related to https://github.com/RobinBlomberg/kysely-codegen/issues/184 + eb.not(eb("action_instances.id", "=", eb.fn.any("action_path.path"))) + ) + .$narrowType<{ id: ActionInstancesId }>() ) ) .selectFrom("action_path") @@ -56,9 +60,6 @@ async function wouldCreateCycle( // .where("id", "=", toBeRunActionId) .executeTakeFirst(); - console.log("==============="); - console.log(result); - console.log("==============="); if (!result) { return { hasCycle: false, @@ -81,12 +82,8 @@ async function wouldCreateCycle( return actionInstance; }); - console.log("==============="); - console.log(filledInPath); - console.log("==============="); - return { - hasCycle: !!result?.id, + hasCycle: true, path: filledInPath, }; } @@ -171,12 +168,17 @@ export async function createRuleWithCycleCheck(data: { }).executeTakeFirstOrThrow(); } catch (e) { if (isUniqueConstraintError(e)) { - console.error(e); - throw new ReferentialRuleAlreadyExistsError( - data.event, - data.actionInstanceId, - data.watchedActionId - ); + if (isReferentialRuleEvent(data.event)) { + if (data.watchedActionId) { + throw new ReferentialRuleAlreadyExistsError( + data.event, + data.actionInstanceId, + data.watchedActionId + ); + } + } else { + throw new RegularRuleAlreadyExistsError(data.event, data.actionInstanceId); + } } throw e; } From d4bff9866942e4d6af05690819a8d5b76a507fd3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 12:30:16 +0100 Subject: [PATCH 07/31] feat: progress --- core/actions/_lib/rules.ts | 9 +- core/actions/_lib/runActionInstance.ts | 53 ++++-- core/actions/_lib/scheduleActionInstance.ts | 176 ++++++++---------- core/actions/api/index.ts | 8 +- core/actions/api/serverAction.ts | 1 + core/actions/types.ts | 20 +- .../internal/[...ts-rest]/route.ts | 19 +- .../actions/getActionRunsTableColumns.tsx | 12 +- .../[communitySlug]/activity/actions/page.tsx | 9 + .../actionsTab/StagePanelRuleCreator.tsx | 2 +- core/lib/db/queries.ts | 33 +++- core/lib/server/errors.ts | 2 + core/lib/server/jobs.ts | 30 ++- core/lib/server/rules.ts | 8 +- .../migration.sql | 5 + core/prisma/schema/schema.dbml | 5 + core/prisma/schema/schema.prisma | 7 + jobs/package.json | 1 + jobs/src/jobs/emitEvent.ts | 42 +++-- packages/contracts/src/resources/internal.ts | 15 +- packages/db/src/public/ActionRuns.ts | 9 + packages/db/src/public/PublicSchema.ts | 44 ++--- packages/db/src/table-names.ts | 8 + pnpm-lock.yaml | 3 + 24 files changed, 339 insertions(+), 182 deletions(-) create mode 100644 core/prisma/migrations/20250306165526_log_triggering_action_run/migration.sql diff --git a/core/actions/_lib/rules.ts b/core/actions/_lib/rules.ts index 5247a0e04..942d19d18 100644 --- a/core/actions/_lib/rules.ts +++ b/core/actions/_lib/rules.ts @@ -63,7 +63,14 @@ export type Rules = | ActionSucceeded | ActionFailed; -export type RuleForEvent = Extract; +export type SchedulableEvent = + | Event.pubInStageForDuration + | Event.actionFailed + | Event.actionSucceeded; + +export type RuleForEvent = E extends E ? Extract : never; + +export type SchedulableRule = RuleForEvent; export type RuleConfig = Rule extends Rule ? NonNullable["_input"] diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index b4a6ea0b9..0fe5045c2 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -22,8 +22,10 @@ import { getPubsWithRelatedValuesAndChildren } from "~/lib/server"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; import { isClientException } from "~/lib/serverActions"; import { getActionByName } from "../api"; +import { isScheduableRuleEvent } from "../types"; import { getActionRunByName } from "./getRuns"; import { resolveWithPubfields } from "./resolvePubfields"; +import { scheduleActionInstances } from "./scheduleActionInstance"; export type ActionInstanceRunResult = ClientException | ClientExceptionOptions | ActionSuccess; @@ -32,6 +34,8 @@ export type RunActionInstanceArgs = { communityId: CommunitiesId; actionInstanceId: ActionInstancesId; actionInstanceArgs?: Record; + stack: ActionRunsId[]; + scheduledActionRunId?: ActionRunsId; } & ({ event: Event } | { userId: UsersId }); const _runActionInstance = async ( @@ -203,10 +207,27 @@ const _runActionInstance = async ( actionRunId: args.actionRunId, }); + await scheduleActionInstances({ + pubId: args.pubId, + stageId: actionInstance.stageId, + event: Event.actionSucceeded, + stack: [...args.stack, args.actionRunId], + watchedActionInstanceId: actionInstance.id, + }); + return result; } catch (error) { captureException(error); logger.error(error); + + await scheduleActionInstances({ + pubId: args.pubId, + stageId: actionInstance.stageId, + event: Event.actionFailed, + stack: [...args.stack, args.actionRunId], + watchedActionInstanceId: actionInstance.id, + }); + return { title: "Failed to run action", error: error.message, @@ -217,28 +238,19 @@ const _runActionInstance = async ( export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { const isActionUserInitiated = "userId" in args; + console.log("+++++++++++"); + console.log(args); + console.log("+++++++++++"); + // we need to first create the action run, // in case the action modifies the pub and needs to pass the lastModifiedBy field // which in this case would be `action-run:` + const actionRuns = await autoRevalidate( trx - .with( - "existingScheduledActionRun", - (db) => - db - .selectFrom("action_runs") - .selectAll() - .where("actionInstanceId", "=", args.actionInstanceId) - .where("pubId", "=", args.pubId) - .where("status", "=", ActionRunStatus.scheduled) - // this should be guaranteed to be unique, as only one actionInstance should be scheduled per pub - ) .insertInto("action_runs") .values((eb) => ({ - id: - isActionUserInitiated || args.event !== Event.pubInStageForDuration - ? undefined - : eb.selectFrom("existingScheduledActionRun").select("id"), + id: args.scheduledActionRunId, actionInstanceId: args.actionInstanceId, pubId: args.pubId, userId: isActionUserInitiated ? args.userId : null, @@ -252,6 +264,7 @@ export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { .where("action_instances.id", "=", args.actionInstanceId), params: args, event: isActionUserInitiated ? undefined : args.event, + triggeringActionRunId: args.stack.at(-1), })) .returningAll() // conflict should only happen if a scheduled action is excecuted @@ -296,9 +309,15 @@ export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { const status = isClientException(result) ? ActionRunStatus.failure : ActionRunStatus.success; + console.log("---------------"); + console.log(args.scheduledActionRunId ?? actionRun.id); + console.log("---------------"); // update the action run with the result await autoRevalidate( - trx.updateTable("action_runs").set({ status, result }).where("id", "=", actionRun.id) + trx + .updateTable("action_runs") + .set({ status, result }) + .where("id", "=", args.scheduledActionRunId ?? actionRun.id) ).executeTakeFirstOrThrow( () => new Error( @@ -314,6 +333,7 @@ export const runInstancesForEvent = async ( stageId: StagesId, event: Event, communityId: CommunitiesId, + stack: ActionRunsId[], trx = db ) => { const instances = await trx @@ -335,6 +355,7 @@ export const runInstancesForEvent = async ( communityId, actionInstanceId: instance.actionInstanceId, event, + stack, }, trx ), diff --git a/core/actions/_lib/scheduleActionInstance.ts b/core/actions/_lib/scheduleActionInstance.ts index 8086121e1..d51539b88 100644 --- a/core/actions/_lib/scheduleActionInstance.ts +++ b/core/actions/_lib/scheduleActionInstance.ts @@ -1,130 +1,116 @@ // "use server"; -import { jsonArrayFrom } from "kysely/helpers/postgres"; - -import type { ActionInstancesId, PubsId, Rules, StagesId } from "db/public"; +import type { ActionInstancesId, ActionRunsId, PubsId, StagesId } from "db/public"; import { ActionRunStatus, Event } from "db/public"; import { logger } from "logger"; -import type { RuleConfig } from "./rules"; +import type { SchedulableRule } from "./rules"; +import type { GetEventRuleOptions } from "~/lib/db/queries"; import { db } from "~/kysely/database"; import { addDuration } from "~/lib/dates"; -import { autoCache } from "~/lib/server/cache/autoCache"; +import { getStageRules } from "~/lib/db/queries"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug"; import { getJobsClient, getScheduledActionJobKey } from "~/lib/server/jobs"; -export const scheduleActionInstances = async ({ - pubId, - stageId, -}: { - pubId: PubsId; - stageId: StagesId; -}) => { - if (!pubId || !stageId) { +export const scheduleActionInstances = async ( + options: { + pubId: PubsId; + stageId: StagesId; + stack: ActionRunsId[]; + } & GetEventRuleOptions +) => { + if (!options.pubId || !options.stageId) { throw new Error("pubId and stageId are required"); } - const instances = await autoCache( - db - .selectFrom("action_instances") - .where("action_instances.stageId", "=", stageId) - .select((eb) => [ - "id", - "name", - "config", - "stageId", - jsonArrayFrom( - eb - .selectFrom("rules") - .select([ - "rules.id as id", - "rules.event as event", - "rules.config as config", - "actionInstanceId", - ]) - .where("rules.actionInstanceId", "=", eb.ref("action_instances.id")) - .where("rules.event", "=", Event.pubInStageForDuration) - ).as("rules"), - ]) - ).execute(); - - if (!instances.length) { - logger.debug({ - msg: `No action instances found for stage ${stageId}. Most likely this is because a Pub is moved into a stage without action instances.`, - pubId, - stageId, - instances, - }); - return; - } - - const validRules = instances.flatMap((instance) => - instance.rules - .filter((rule): rule is Rules & { config: RuleConfig } => - Boolean( - typeof rule.config === "object" && - rule.config && - "duration" in rule.config && - rule.config.duration && - "interval" in rule.config && - rule.config.interval - ) - ) - .map((rule) => ({ - ...rule, - actionName: instance.name, - actionInstanceConfig: instance.config, - })) - ); + const [rules, jobsClient] = await Promise.all([ + getStageRules(options.stageId, options).execute(), + getJobsClient(), + ]); - if (!validRules.length) { + if (!rules.length) { logger.debug({ - msg: "No action instances connected to a pubInStageForDuration rule found for pub", - pubId, - stageId, - instances, + msg: `No action instances found for stage ${options.stageId}. Most likely this is because a Pub is moved into a stage without action instances.`, + pubId: options.pubId, + stageId: options.stageId, + rules, }); return; } - const jobsClient = await getJobsClient(); + const validRules = rules + + .filter( + (rule): rule is typeof rule & SchedulableRule => + rule.event === Event.actionFailed || + rule.event === Event.actionSucceeded || + (rule.event === Event.pubInStageForDuration && + Boolean( + typeof rule.config === "object" && + rule.config && + "duration" in rule.config && + rule.config.duration && + "interval" in rule.config && + rule.config.interval + )) + ) + .map((rule) => ({ + ...rule, + duration: rule.config?.duration || 0, + interval: rule.config?.interval || "minute", + })); + + // if (!validRules.length) { + // logger.debug({ + // msg: "No action instances connected to a pubInStageForDuration rule found for pub", + // pubId, + // stageId, + // instances, + // }); + // return; + // } const results = await Promise.all( validRules.flatMap(async (rule) => { - const job = await jobsClient.scheduleAction({ - actionInstanceId: rule.actionInstanceId, - duration: rule.config.duration, - interval: rule.config.interval, - stageId: stageId, - pubId, - community: { - slug: await getCommunitySlug(), - }, - }); - const runAt = addDuration({ - duration: rule.config.duration, - interval: rule.config.interval, + duration: rule.duration, + interval: rule.interval, }).toISOString(); - if (job.id) { - await autoRevalidate( - db.insertInto("action_runs").values({ - actionInstanceId: rule.actionInstanceId, - pubId: pubId, + const scheduledActionRun = await autoRevalidate( + db + .insertInto("action_runs") + .values({ + actionInstanceId: rule.actionInstance.id, + pubId: options.pubId, status: ActionRunStatus.scheduled, - config: rule.actionInstanceConfig, + config: rule.actionInstance.config, result: { scheduled: `Action scheduled for ${runAt}` }, - event: Event.pubInStageForDuration, + event: rule.event, + triggeringActionRunId: options.stack.at(-1), }) - ).execute(); - } + .returning("id") + ).executeTakeFirstOrThrow(); + + const job = await jobsClient.scheduleAction({ + actionInstanceId: rule.actionInstance.id, + duration: rule.duration, + interval: rule.interval, + stageId: options.stageId, + pubId: options.pubId, + community: { + slug: await getCommunitySlug(), + }, + stack: options.stack, + scheduledActionRunId: scheduledActionRun.id, + event: rule.event, + }); return { result: job, - actionInstanceId: rule.actionInstanceId, - actionInstanceName: rule.actionName, + actionInstanceId: rule.actionInstance.id, + actionInstanceName: rule.actionInstance.name, runAt, }; }) diff --git a/core/actions/api/index.ts b/core/actions/api/index.ts index 520fe18a9..82a3dcb1f 100644 --- a/core/actions/api/index.ts +++ b/core/actions/api/index.ts @@ -4,7 +4,7 @@ import type * as z from "zod"; import type { ActionInstances, Event } from "db/public"; -import type { ReferentialRuleEvent } from "../types"; +import type { SequentialRuleEvent } from "../types"; import { actionFailed, actionSucceeded, @@ -20,7 +20,7 @@ import * as log from "../log/action"; import * as move from "../move/action"; import * as pdf from "../pdf/action"; import * as pushToV6 from "../pushToV6/action"; -import { isReferentialRuleEvent, referentialRuleEvents } from "../types"; +import { isSequentialRuleEvent, sequentialRuleEvents } from "../types"; export const actions = { [log.action.name]: log.action, @@ -59,8 +59,8 @@ export const getRuleByName = (name: T) => { export const isReferentialRule = ( rule: (typeof rules)[keyof typeof rules] -): rule is Extract => - referentialRuleEvents.includes(rule.event as any); +): rule is Extract => + sequentialRuleEvents.includes(rule.event as any); export const humanReadableEvent = ( event: T, diff --git a/core/actions/api/serverAction.ts b/core/actions/api/serverAction.ts index e83319788..82052c39c 100644 --- a/core/actions/api/serverAction.ts +++ b/core/actions/api/serverAction.ts @@ -21,6 +21,7 @@ export const runActionInstance = defineServerAction(async function runActionInst const result = await runActionInstanceInner({ ...args, userId: user.id as UsersId, + stack: args.stack ?? [], }); return result; diff --git a/core/actions/types.ts b/core/actions/types.ts index 117baad0d..5620cf3a1 100644 --- a/core/actions/types.ts +++ b/core/actions/types.ts @@ -177,11 +177,21 @@ export const defineRun = ( export type Run = ReturnType; -export const referentialRuleEvents = [Event.actionSucceeded, Event.actionFailed] as const; -export type ReferentialRuleEvent = (typeof referentialRuleEvents)[number]; +export const sequentialRuleEvents = [Event.actionSucceeded, Event.actionFailed] as const; +export type SequentialRuleEvent = (typeof sequentialRuleEvents)[number]; -export const isReferentialRuleEvent = (event: Event): event is ReferentialRuleEvent => - referentialRuleEvents.includes(event as any); +export const isSequentialRuleEvent = (event: Event): event is SequentialRuleEvent => + sequentialRuleEvents.includes(event as any); + +export const scheduableRuleEvents = [ + Event.pubInStageForDuration, + Event.actionFailed, + Event.actionSucceeded, +] as const; +export type ScheduableRuleEvent = (typeof scheduableRuleEvents)[number]; + +export const isScheduableRuleEvent = (event: Event): event is ScheduableRuleEvent => + scheduableRuleEvents.includes(event as any); export type EventRuleOptionsBase< E extends Event, @@ -204,7 +214,7 @@ export type EventRuleOptionsBase< */ [K in "withConfig" as AC extends Record ? K - : E extends ReferentialRuleEvent + : E extends SequentialRuleEvent ? K : never]: ( options: AC extends Record ? AC : ActionInstances diff --git a/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts index 3379c6b5d..c1ddfbf93 100644 --- a/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts @@ -1,7 +1,8 @@ import { createNextHandler } from "@ts-rest/serverless/next"; -import type { ActionInstancesId, CommunitiesId, Event, PubsId, StagesId } from "db/public"; +import type { ActionInstancesId, CommunitiesId, PubsId, StagesId } from "db/public"; import { api } from "contracts"; +import { Event } from "db/public"; import { runInstancesForEvent } from "~/actions/_lib/runActionInstance"; import { scheduleActionInstances } from "~/actions/_lib/scheduleActionInstance"; @@ -20,8 +21,11 @@ const handler = createNextHandler( api.internal, { triggerAction: async ({ headers, params, body }) => { + console.log("---------------"); + console.log(body); + console.log("---------------"); checkAuthentication(headers.authorization); - const { pubId, event } = body; + const { pubId, event, stack, scheduledActionRunId } = body; const { actionInstanceId } = params; const community = await findCommunityBySlug(); @@ -30,10 +34,12 @@ const handler = createNextHandler( } const actionRunResults = await runActionInstance({ - pubId: pubId as PubsId, - event: event as Event, + pubId: pubId, + event: event, actionInstanceId: actionInstanceId as ActionInstancesId, communityId: community.id as CommunitiesId, + stack: stack ?? [], + scheduledActionRunId: scheduledActionRunId, }); return { @@ -56,7 +62,8 @@ const handler = createNextHandler( pubId as PubsId, stageId as StagesId, event as Event, - community.id as CommunitiesId + community.id as CommunitiesId, + [] ); return { @@ -76,6 +83,8 @@ const handler = createNextHandler( const actionScheduleResults = await scheduleActionInstances({ pubId: pubId as PubsId, stageId: stageId as StagesId, + stack: [], + event: Event.pubInStageForDuration, }); return { diff --git a/core/app/c/[communitySlug]/activity/actions/getActionRunsTableColumns.tsx b/core/app/c/[communitySlug]/activity/actions/getActionRunsTableColumns.tsx index d6a58c5dd..10f98024d 100644 --- a/core/app/c/[communitySlug]/activity/actions/getActionRunsTableColumns.tsx +++ b/core/app/c/[communitySlug]/activity/actions/getActionRunsTableColumns.tsx @@ -5,6 +5,7 @@ import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; import type { PubsId } from "db/public"; +import { Event } from "db/public"; import { Badge } from "ui/badge"; import { DataTableColumnHeader } from "ui/data-table"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "ui/hover-card"; @@ -16,6 +17,7 @@ export type ActionRun = { id: string; createdAt: Date; actionInstance: { name: string; action: string } | null; + triggeringActionInstance: { name: string; action: string } | null; stage: { id: string; name: string } | null; pub: PubTitleProps & { id: PubsId }; result: unknown; @@ -53,11 +55,15 @@ export const getActionRunsTableColumns = (communitySlug: string) => return `${user.firstName} ${user.lastName}`; } switch (getValue()) { - case "pubEnteredStage": + case Event.actionFailed: + return `Rule (${row.original.triggeringActionInstance?.name} failed)`; + case Event.actionSucceeded: + return `Rule (${row.original.triggeringActionInstance?.name} succeeded)`; + case Event.pubEnteredStage: return "Rule (Pub entered stage)"; - case "pubLeftStage": + case Event.pubLeftStage: return "Rule (Pub exited stage)"; - case "pubInStageForDuration": + case Event.pubInStageForDuration: return "Rule (Pub in stage for duration)"; } }, diff --git a/core/app/c/[communitySlug]/activity/actions/page.tsx b/core/app/c/[communitySlug]/activity/actions/page.tsx index 2c6a99548..8522f5618 100644 --- a/core/app/c/[communitySlug]/activity/actions/page.tsx +++ b/core/app/c/[communitySlug]/activity/actions/page.tsx @@ -65,6 +65,14 @@ export default async function Page(props: { .whereRef("action_instances.id", "=", "action_runs.actionInstanceId") .select(["action_instances.name", "action_instances.action"]) ).as("actionInstance"), + "action_runs.triggeringActionRunId", + jsonObjectFrom( + eb + .selectFrom("action_runs as ar") + .innerJoin("action_instances", "ar.actionInstanceId", "action_instances.id") + .whereRef("ar.id", "=", "action_runs.triggeringActionRunId") + .select(["action_instances.name", "action_instances.action"]) + ).as("triggeringActionInstance"), jsonObjectFrom( eb .selectFrom("stages") @@ -87,6 +95,7 @@ export default async function Page(props: { ]) .orderBy("action_runs.createdAt", "desc") ).execute()) as ActionRun[]; + console.log(actionRuns); return ( <> diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx index 6ace1d3e5..56e4d8c66 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx @@ -31,7 +31,7 @@ import { Form, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; import type { RuleConfig, RuleForEvent, Rules } from "~/actions/_lib/rules"; -import type { ReferentialRuleEvent } from "~/actions/types"; +import type { SequentialRuleEvent } from "~/actions/types"; import { actions, getRuleByName, humanReadableEvent, rules } from "~/actions/api"; import { isClientException, useServerAction } from "~/lib/serverActions"; import { addRule } from "../../../actions"; diff --git a/core/lib/db/queries.ts b/core/lib/db/queries.ts index fb57b6916..42b5d6dad 100644 --- a/core/lib/db/queries.ts +++ b/core/lib/db/queries.ts @@ -1,7 +1,8 @@ import { cache } from "react"; import { jsonObjectFrom } from "kysely/helpers/postgres"; -import type { StagesId, UsersId } from "db/public"; +import type { ActionInstancesId, StagesId, UsersId } from "db/public"; +import { Event } from "db/public"; import type { RuleConfig } from "~/actions/types"; import { db } from "~/kysely/database"; @@ -59,7 +60,16 @@ export const getStageMembers = cache((stageId: StagesId) => { ); }); -export const getStageRules = cache((stageId: StagesId) => { +export type GetEventRuleOptions = + | { + event: Event.pubInStageForDuration; + watchedActionInstanceId?: never; + } + | { + event: Event.actionFailed | Event.actionSucceeded; + watchedActionInstanceId: ActionInstancesId; + }; +export const getStageRules = cache((stageId: StagesId, options?: GetEventRuleOptions) => { return autoCache( db .selectFrom("rules") @@ -86,6 +96,25 @@ export const getStageRules = cache((stageId: StagesId) => { // .where("action_instances.stageId", "=", stageId) ).as("watchedActionInstance"), ]) + .$if(!!options?.event, (eb) => { + const where = eb.where("rules.event", "=", options!.event); + + if (options!.event === Event.pubInStageForDuration) { + return where; + } + + return where.where("rules.watchedActionId", "=", options!.watchedActionInstanceId); + }) .$narrowType<{ config: RuleConfig | null }>() ); }); + +// export const getReferentialRules = cache( +// (stageId: StagesId, event: Event, watchedActionId: ActionInstancesId) => { +// return autoCache( +// getStageRules(stageId) +// .qb.where("rules.watchedActionId", "=", watchedActionId) +// .where("rules.event", "=", event) +// ); +// } +// ); diff --git a/core/lib/server/errors.ts b/core/lib/server/errors.ts index 1ad48ec9e..da2da2c4f 100644 --- a/core/lib/server/errors.ts +++ b/core/lib/server/errors.ts @@ -2,6 +2,7 @@ import type { ErrorHttpStatusCode } from "@ts-rest/core"; import type { TsRestRequest } from "@ts-rest/serverless"; import { NextResponse } from "next/server"; +import { reactErrorHandler } from "@sentry/nextjs"; import { RequestValidationError, TsRestHttpError, TsRestResponse } from "@ts-rest/serverless"; import pg from "pg"; @@ -83,6 +84,7 @@ export const handleDatabaseErrors = (error: pg.DatabaseError, req: TsRestRequest }; export const tsRestHandleErrors = (error: unknown, req: TsRestRequest): TsRestResponse => { + logger.error(error); if (error instanceof RequestValidationError) { return TsRestResponse.fromJson( { diff --git a/core/lib/server/jobs.ts b/core/lib/server/jobs.ts index 1d676743f..e5fe7542a 100644 --- a/core/lib/server/jobs.ts +++ b/core/lib/server/jobs.ts @@ -9,7 +9,7 @@ import { env } from "../env/env.mjs"; import "date-fns"; -import type { ActionInstancesId, PubsId, StagesId } from "db/public"; +import type { ActionInstancesId, ActionRunsId, PubsId, StagesId } from "db/public"; import { Event } from "db/public"; import type { Interval } from "~/actions/_lib/rules"; @@ -36,6 +36,9 @@ export type JobsClient = { community: { slug: string; }; + event: Event; + stack: ActionRunsId[]; + scheduledActionRunId: ActionRunsId; }): Promise; }; @@ -56,24 +59,37 @@ export const makeJobsClient = async (): Promise => { job: { key: jobKey }, }); }, - async scheduleAction({ actionInstanceId, stageId, duration, interval, pubId, community }) { + async scheduleAction({ + actionInstanceId, + stageId, + duration, + interval, + pubId, + community, + event, + stack, + scheduledActionRunId, + }) { const runAt = addDuration({ duration, interval }); const jobKey = getScheduledActionJobKey({ stageId, actionInstanceId, pubId }); logger.info({ - msg: `Scheduling action with key: ${actionInstanceId} to run at ${runAt}`, + msg: `Scheduling action with key: ${actionInstanceId} to run at ${runAt}. Cause: ${event}${stack?.length ? `, triggered by: ${stack.join(" -> ")}` : ""}`, actionInstanceId, stageId, duration, interval, runAt, pubId, + stack, + event, + scheduledActionRunId, }); try { const job = await workerUtils.addJob( "emitEvent", { - event: Event.pubInStageForDuration, + event, duration, interval, runAt, @@ -81,6 +97,8 @@ export const makeJobsClient = async (): Promise => { stageId, pubId, community, + stack, + scheduledActionRunId, }, { runAt, @@ -106,7 +124,9 @@ export const makeJobsClient = async (): Promise => { duration, interval, pubId, - err, + err: err.message, + stack, + event, }); return { error: err, diff --git a/core/lib/server/rules.ts b/core/lib/server/rules.ts index 62cba069c..a19feab15 100644 --- a/core/lib/server/rules.ts +++ b/core/lib/server/rules.ts @@ -4,8 +4,8 @@ import type { ActionInstances, ActionInstancesId, NewRules, RulesId } from "db/p import { Event } from "db/public"; import { expect } from "utils"; -import type { ReferentialRuleEvent } from "~/actions/types"; -import { isReferentialRuleEvent } from "~/actions/types"; +import type { SequentialRuleEvent } from "~/actions/types"; +import { isSequentialRuleEvent } from "~/actions/types"; import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { autoRevalidate } from "./cache/autoRevalidate"; @@ -115,7 +115,7 @@ export class RuleAlreadyExistsError extends RuleError { export class ReferentialRuleAlreadyExistsError extends RuleAlreadyExistsError { constructor( - public event: ReferentialRuleEvent, + public event: SequentialRuleEvent, public actionInstanceId: ActionInstancesId, public watchedActionId: ActionInstancesId ) { @@ -168,7 +168,7 @@ export async function createRuleWithCycleCheck(data: { }).executeTakeFirstOrThrow(); } catch (e) { if (isUniqueConstraintError(e)) { - if (isReferentialRuleEvent(data.event)) { + if (isSequentialRuleEvent(data.event)) { if (data.watchedActionId) { throw new ReferentialRuleAlreadyExistsError( data.event, diff --git a/core/prisma/migrations/20250306165526_log_triggering_action_run/migration.sql b/core/prisma/migrations/20250306165526_log_triggering_action_run/migration.sql new file mode 100644 index 000000000..c677f8e7c --- /dev/null +++ b/core/prisma/migrations/20250306165526_log_triggering_action_run/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "action_runs" ADD COLUMN "triggeringActionRunId" TEXT; + +-- AddForeignKey +ALTER TABLE "action_runs" ADD CONSTRAINT "action_runs_triggeringActionRunId_fkey" FOREIGN KEY ("triggeringActionRunId") REFERENCES "action_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index cc1d5f3cd..dc4986900 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -341,6 +341,9 @@ Table action_runs { createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] PubValueHistory pub_values_history [not null] + triggeringActionRunId String + triggeringActionRun action_runs + sequentialActionRuns action_runs [not null] } Table rules { @@ -688,6 +691,8 @@ Ref: action_runs.pubId > pubs.id Ref: action_runs.userId > users.id [delete: Set Null] +Ref: action_runs.triggeringActionRunId - action_runs.id [delete: Set Null] + Ref: rules.actionInstanceId > action_instances.id [delete: Cascade] Ref: rules.watchedActionId > action_instances.id [delete: Cascade] diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 16c5188ad..1e6a5bfc0 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -443,6 +443,13 @@ model ActionRun { updatedAt DateTime @default(now()) @updatedAt PubValueHistory PubValueHistory[] + // action run that triggered this action run + triggeringActionRunId String? + triggeringActionRun ActionRun? @relation("triggering_action_run", fields: [triggeringActionRunId], references: [id], onDelete: SetNull) + + // action runs that were triggered by this action run + sequentialActionRuns ActionRun[] @relation("triggering_action_run") + @@map(name: "action_runs") } diff --git a/jobs/package.json b/jobs/package.json index e451fb394..6674c39ba 100644 --- a/jobs/package.json +++ b/jobs/package.json @@ -14,6 +14,7 @@ "src" ], "dependencies": { + "db": "workspace:*", "zod": "catalog:", "react": "catalog:react19", "@honeycombio/opentelemetry-node": "catalog:", diff --git a/jobs/src/jobs/emitEvent.ts b/jobs/src/jobs/emitEvent.ts index 0e657f6a6..706170079 100644 --- a/jobs/src/jobs/emitEvent.ts +++ b/jobs/src/jobs/emitEvent.ts @@ -1,18 +1,14 @@ +import type { ActionRunsId, PubsId, StagesId } from "db/public"; import type { logger } from "logger"; +import { Event } from "db/public"; import type { InternalClient } from "../clients"; import { defineJob } from "../defineJob"; -enum Event { - pubEnteredStage = "pubEnteredStage", - pubLeftStage = "pubLeftStage", - pubInStageForDuration = "pubInStageForDuration", -} - // TODO: Use kanel generated types for these type PubInStagesRow = { - pubId: string; - stageId: string; + pubId: PubsId; + stageId: StagesId; }; type DBTriggerEventPayload = { @@ -26,16 +22,19 @@ type DBTriggerEventPayload = { }; type ScheduledEventPayload = { - event: Event.pubInStageForDuration; + event: Event; duration: number; interval: "minute" | "hour" | "day" | "week" | "month" | "year"; runAt: Date; - stageId: string; - pubId: string; + stageId: StagesId; + pubId: PubsId; actionInstanceId: string; community: { slug: string; }; + triggeringActionRunId?: string; + stack?: ActionRunsId[]; + scheduledActionRunId: ActionRunsId; }; type EmitEventPayload = DBTriggerEventPayload | ScheduledEventPayload; @@ -137,8 +136,20 @@ const triggerAction = async ( payload: ScheduledEventPayload, logger: Logger ) => { - const { stageId, event, pubId, actionInstanceId, community, ...context } = payload; + const { + stageId, + stack, + event, + pubId, + actionInstanceId, + scheduledActionRunId, + community, + ...context + } = payload; + console.log("+++++++++++"); + console.log(payload); + console.log("+++++++++++"); try { const { status, body } = await client.triggerAction({ params: { @@ -148,10 +159,15 @@ const triggerAction = async ( body: { pubId, event, + scheduledActionRunId, + stack, }, }); + console.log("---------------"); + console.log(status, body); + console.log("---------------"); - if (status > 400) { + if (status >= 400) { logger.error({ msg: `API error triggering action`, body, ...context }); return; } diff --git a/packages/contracts/src/resources/internal.ts b/packages/contracts/src/resources/internal.ts index aa89d3ea6..7c75c05a2 100644 --- a/packages/contracts/src/resources/internal.ts +++ b/packages/contracts/src/resources/internal.ts @@ -1,6 +1,8 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; +import { actionInstancesIdSchema, actionRunsIdSchema, eventSchema, pubsIdSchema } from "db/public"; + const contract = initContract(); export const internalApi = contract.router( @@ -37,12 +39,13 @@ export const internalApi = contract.router( actionInstanceId: z.string(), }), body: z.object({ - pubId: z.string(), - event: z.enum(["pubLeftStage", "pubEnteredStage", "pubInStageForDuration"]), + pubId: pubsIdSchema, + event: eventSchema, + stack: z.array(actionRunsIdSchema).optional(), + scheduledActionRunId: actionRunsIdSchema.optional(), }), responses: { 200: z.object({ - // actionInstanceName: z.string(), actionInstanceId: z.string(), result: z.any(), }), @@ -58,9 +61,9 @@ export const internalApi = contract.router( stageId: z.string(), }), body: z.object({ - event: z.enum(["pubLeftStage", "pubEnteredStage", "pubInStageForDuration"]), - pubId: z.string(), - actionInstanceId: z.string().optional(), + pubId: pubsIdSchema, + event: eventSchema, + watchedActionInstanceId: actionInstancesIdSchema.optional(), }), responses: { 200: z.array( diff --git a/packages/db/src/public/ActionRuns.ts b/packages/db/src/public/ActionRuns.ts index ce381f8bf..b41655028 100644 --- a/packages/db/src/public/ActionRuns.ts +++ b/packages/db/src/public/ActionRuns.ts @@ -46,6 +46,12 @@ export interface ActionRunsTable { updatedAt: ColumnType; result: ColumnType; + + triggeringActionRunId: ColumnType< + ActionRunsId | null, + ActionRunsId | null, + ActionRunsId | null + >; } export type ActionRuns = Selectable; @@ -68,6 +74,7 @@ export const actionRunsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), result: z.unknown(), + triggeringActionRunId: actionRunsIdSchema.nullable(), }); export const actionRunsInitializerSchema = z.object({ @@ -82,6 +89,7 @@ export const actionRunsInitializerSchema = z.object({ createdAt: z.date().optional(), updatedAt: z.date().optional(), result: z.unknown(), + triggeringActionRunId: actionRunsIdSchema.optional().nullable(), }); export const actionRunsMutatorSchema = z.object({ @@ -96,4 +104,5 @@ export const actionRunsMutatorSchema = z.object({ createdAt: z.date().optional(), updatedAt: z.date().optional(), result: z.unknown().optional(), + triggeringActionRunId: actionRunsIdSchema.optional().nullable(), }); diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index 9be2ccf72..62eb7d16d 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -33,6 +33,28 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { + _prisma_migrations: PrismaMigrationsTable; + + users: UsersTable; + + pubs: PubsTable; + + pub_types: PubTypesTable; + + stages: StagesTable; + + member_groups: MemberGroupsTable; + + communities: CommunitiesTable; + + move_constraint: MoveConstraintTable; + + pub_fields: PubFieldsTable; + + pub_values: PubValuesTable; + + _PubFieldToPubType: PubFieldToPubTypeTable; + _MemberGroupToUser: MemberGroupToUserTable; auth_tokens: AuthTokensTable; @@ -70,26 +92,4 @@ export interface PublicSchema { membership_capabilities: MembershipCapabilitiesTable; pub_values_history: PubValuesHistoryTable; - - _prisma_migrations: PrismaMigrationsTable; - - users: UsersTable; - - pubs: PubsTable; - - pub_types: PubTypesTable; - - stages: StagesTable; - - member_groups: MemberGroupsTable; - - communities: CommunitiesTable; - - move_constraint: MoveConstraintTable; - - pub_fields: PubFieldsTable; - - pub_values: PubValuesTable; - - _PubFieldToPubType: PubFieldToPubTypeTable; } diff --git a/packages/db/src/table-names.ts b/packages/db/src/table-names.ts index 1f06e0b85..7c2dfdd26 100644 --- a/packages/db/src/table-names.ts +++ b/packages/db/src/table-names.ts @@ -427,6 +427,14 @@ export const databaseTables = [ isAutoIncrementing: false, hasDefaultValue: false, }, + { + name: "triggeringActionRunId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, ], }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7996c1ee..87f7ef76a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -650,6 +650,9 @@ importers: contracts: specifier: workspace:* version: link:../packages/contracts + db: + specifier: workspace:* + version: link:../packages/db graphile-worker: specifier: ^0.16.5 version: 0.16.6(typescript@5.7.2) From 0cd8be8b1ab1d13f2ee3f97f42f2d89e3489fb3e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 12:31:41 +0100 Subject: [PATCH 08/31] chore: remove logs --- core/actions/_lib/runActionInstance.ts | 7 ------- .../v0/c/[communitySlug]/internal/[...ts-rest]/route.ts | 3 --- core/app/c/[communitySlug]/activity/actions/page.tsx | 1 - core/app/c/[communitySlug]/stages/manage/actions.ts | 1 - .../manage/components/panel/actionsTab/StagePanelRules.tsx | 2 +- 5 files changed, 1 insertion(+), 13 deletions(-) diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index 0fe5045c2..6ca40d7b4 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -238,10 +238,6 @@ const _runActionInstance = async ( export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { const isActionUserInitiated = "userId" in args; - console.log("+++++++++++"); - console.log(args); - console.log("+++++++++++"); - // we need to first create the action run, // in case the action modifies the pub and needs to pass the lastModifiedBy field // which in this case would be `action-run:` @@ -309,9 +305,6 @@ export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { const status = isClientException(result) ? ActionRunStatus.failure : ActionRunStatus.success; - console.log("---------------"); - console.log(args.scheduledActionRunId ?? actionRun.id); - console.log("---------------"); // update the action run with the result await autoRevalidate( trx diff --git a/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts index c1ddfbf93..b44e6a062 100644 --- a/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/internal/[...ts-rest]/route.ts @@ -21,9 +21,6 @@ const handler = createNextHandler( api.internal, { triggerAction: async ({ headers, params, body }) => { - console.log("---------------"); - console.log(body); - console.log("---------------"); checkAuthentication(headers.authorization); const { pubId, event, stack, scheduledActionRunId } = body; diff --git a/core/app/c/[communitySlug]/activity/actions/page.tsx b/core/app/c/[communitySlug]/activity/actions/page.tsx index 8522f5618..563068096 100644 --- a/core/app/c/[communitySlug]/activity/actions/page.tsx +++ b/core/app/c/[communitySlug]/activity/actions/page.tsx @@ -95,7 +95,6 @@ export default async function Page(props: { ]) .orderBy("action_runs.createdAt", "desc") ).execute()) as ActionRun[]; - console.log(actionRuns); return ( <> diff --git a/core/app/c/[communitySlug]/stages/manage/actions.ts b/core/app/c/[communitySlug]/stages/manage/actions.ts index a8eda541f..128a582aa 100644 --- a/core/app/c/[communitySlug]/stages/manage/actions.ts +++ b/core/app/c/[communitySlug]/stages/manage/actions.ts @@ -406,7 +406,6 @@ export const addRule = defineServerAction(async function addRule({ cause: error, }; } - console.log(error); return { error: "Failed to add rule", diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRules.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRules.tsx index 555bb7d11..796cc0950 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRules.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRules.tsx @@ -19,7 +19,7 @@ const StagePanelRulesInner = async (props: PropsInner) => { getStageActions(props.stageId).execute(), getStageRules(props.stageId).execute(), ]); - console.log(rules); + if (!stage) { return ; } From cb16088c0d43212a7796ce9d9aa6562d483acf81 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 12:32:52 +0100 Subject: [PATCH 09/31] chore: fix type errors --- core/actions/_lib/runActionInstance.db.test.ts | 2 ++ core/app/components/ActionUI/ActionRunForm.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/core/actions/_lib/runActionInstance.db.test.ts b/core/actions/_lib/runActionInstance.db.test.ts index 3d00083ea..3763dcf2e 100644 --- a/core/actions/_lib/runActionInstance.db.test.ts +++ b/core/actions/_lib/runActionInstance.db.test.ts @@ -83,6 +83,7 @@ describe("runActionInstance", () => { pubId: pubs[0].id, event: Event.pubEnteredStage, communityId: community.id, + stack: [], }); expect(result).toEqual({ @@ -133,6 +134,7 @@ describe("runActionInstance", () => { docUrl: fakeDocURL, }, communityId: community.id, + stack: [], }); expect(result).toEqual({ diff --git a/core/app/components/ActionUI/ActionRunForm.tsx b/core/app/components/ActionUI/ActionRunForm.tsx index be0cb805f..e9a286f68 100644 --- a/core/app/components/ActionUI/ActionRunForm.tsx +++ b/core/app/components/ActionUI/ActionRunForm.tsx @@ -48,6 +48,7 @@ export const ActionRunForm = ({ pubId, actionInstanceArgs: values, communityId: community.id as CommunitiesId, + stack: [], }); if ("success" in result) { From a5b4552c832e1f8dab7872745b48c6f73885488a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 13:59:34 +0100 Subject: [PATCH 10/31] fix: make work --- core/lib/server/rules.ts | 170 +++++++++++++++++++++++---------------- 1 file changed, 99 insertions(+), 71 deletions(-) diff --git a/core/lib/server/rules.ts b/core/lib/server/rules.ts index a19feab15..d1c2e1afd 100644 --- a/core/lib/server/rules.ts +++ b/core/lib/server/rules.ts @@ -1,3 +1,5 @@ +import type { ZodError } from "zod"; + import { sql } from "kysely"; import type { ActionInstances, ActionInstancesId, NewRules, RulesId } from "db/public"; @@ -5,11 +7,73 @@ import { Event } from "db/public"; import { expect } from "utils"; import type { SequentialRuleEvent } from "~/actions/types"; +import { rules } from "~/actions/api"; import { isSequentialRuleEvent } from "~/actions/types"; import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { autoRevalidate } from "./cache/autoRevalidate"; +export class RuleError extends Error { + constructor(message: string) { + super(message); + this.name = "RuleError"; + } +} + +export class RuleConfigError extends RuleError { + constructor( + public event: Event, + public config: Record, + public error: ZodError + ) { + super(`Invalid config for ${event}: ${JSON.stringify(config)}. ${error.message}`); + this.name = "RuleConfigError"; + } +} + +export class RuleCycleError extends RuleError { + constructor(public path: ActionInstances[]) { + super(`Creating this rule would create a cycle: ${path.map((p) => p.name).join(" -> ")}`); + this.name = "RuleCycleError"; + } +} + +export class RuleAlreadyExistsError extends RuleError { + constructor( + message: string, + public event: Event, + public actionInstanceId: ActionInstancesId, + public watchedActionId?: ActionInstancesId + ) { + super(message); + this.name = "RuleAlreadyExistsError"; + } +} + +export class SequentialRuleAlreadyExistsError extends RuleAlreadyExistsError { + constructor( + public event: SequentialRuleEvent, + public actionInstanceId: ActionInstancesId, + public watchedActionId: ActionInstancesId + ) { + super( + ` ${event} rule for ${watchedActionId} running ${actionInstanceId} already exists`, + event, + actionInstanceId, + watchedActionId + ); + } +} + +export class RegularRuleAlreadyExistsError extends RuleAlreadyExistsError { + constructor( + public event: Event, + public actionInstanceId: ActionInstancesId + ) { + super(` ${event} rule for ${actionInstanceId} already exists`, event, actionInstanceId); + } +} + export const createRule = (props: NewRules) => autoRevalidate(db.insertInto("rules").values(props)); export const removeRule = (ruleId: RulesId) => @@ -20,14 +84,13 @@ async function wouldCreateCycle( toBeRunActionId: ActionInstancesId, watchedActionId: ActionInstancesId ): Promise<{ hasCycle: true; path: ActionInstances[] } | { hasCycle: false; path?: never }> { - // This uses Kysely's withRecursive for the CTE - const result = await db + const forwardResult = await db .withRecursive("action_path", (cte) => cte .selectFrom("action_instances") .select(["id", sql`array[id]`.as("path")]) - .where("id", "=", watchedActionId) - .unionAll((qb) => + .where("id", "=", toBeRunActionId) + .union((qb) => qb .selectFrom("action_path") .innerJoin("rules", "rules.watchedActionId", "action_path.id") @@ -43,38 +106,39 @@ async function wouldCreateCycle( >`action_path.path || array[action_instances.id]`.as("path"), ]) .where((eb) => - // @ts-expect-error FIXME: i straight up don't know what it's yelling about - // might be related to https://github.com/RobinBlomberg/kysely-codegen/issues/184 - eb.not(eb("action_instances.id", "=", eb.fn.any("action_path.path"))) + eb.not( + eb( + "action_instances.id", + "=", + eb.fn.any(sql`action_path.path`) + ) + ) ) - .$narrowType<{ id: ActionInstancesId }>() ) ) .selectFrom("action_path") .select(["id", "path"]) - .where((eb) => { - // Check if the last entry in the path is the action we're about to run - // This would indicate a cycle - return eb("id", "=", toBeRunActionId); - }) - // .where("id", "=", toBeRunActionId) - .executeTakeFirst(); - - if (!result) { + .where("id", "=", watchedActionId) + .execute(); + + if (forwardResult.length === 0) { return { hasCycle: false, - path: undefined, }; } - // find all the action instances in the path + // if we found a path from toBeRunActionId to watchedActionId, we have a cycle + // the complete cycle is: watchedActionId -> toBeRunActionId (the new rule) -> path -> watchedActionId + const cyclePath = [watchedActionId, toBeRunActionId, ...forwardResult[0].path.slice(1)]; + + // get the action instances for the path const actionInstances = await db .selectFrom("action_instances") .selectAll() - .where("id", "in", result.path) + .where("id", "in", cyclePath) .execute(); - const filledInPath = result.path.map((id) => { + const filledInPath = cyclePath.map((id) => { const actionInstance = expect( actionInstances.find((ai) => ai.id === id), `Action instance ${id} not found` @@ -87,55 +151,6 @@ async function wouldCreateCycle( path: filledInPath, }; } -export class RuleError extends Error { - constructor(message: string) { - super(message); - this.name = "RuleError"; - } -} - -export class RuleCycleError extends RuleError { - constructor(public path: ActionInstances[]) { - super(`Creating this rule would create a cycle: ${path.map((p) => p.name).join(" -> ")}`); - this.name = "RuleCycleError"; - } -} - -export class RuleAlreadyExistsError extends RuleError { - constructor( - message: string, - public event: Event, - public actionInstanceId: ActionInstancesId, - public watchedActionId?: ActionInstancesId - ) { - super(message); - this.name = "RuleAlreadyExistsError"; - } -} - -export class ReferentialRuleAlreadyExistsError extends RuleAlreadyExistsError { - constructor( - public event: SequentialRuleEvent, - public actionInstanceId: ActionInstancesId, - public watchedActionId: ActionInstancesId - ) { - super( - ` ${event} rule for ${watchedActionId} running ${actionInstanceId} already exists`, - event, - actionInstanceId, - watchedActionId - ); - } -} - -export class RegularRuleAlreadyExistsError extends RuleAlreadyExistsError { - constructor( - public event: Event, - public actionInstanceId: ActionInstancesId - ) { - super(` ${event} rule for ${actionInstanceId} already exists`, event, actionInstanceId); - } -} // Function to actually create the rule after checking for cycles export async function createRuleWithCycleCheck(data: { @@ -144,6 +159,18 @@ export async function createRuleWithCycleCheck(data: { watchedActionId?: ActionInstancesId; config?: Record | null; }) { + // check the config + + const config = rules[data.event].additionalConfig; + + if (config) { + try { + config.parse(data.config); + } catch (e) { + throw new RuleConfigError(data.event, data.config ?? {}, e); + } + } + // Only check for cycles if this is an action event with a watched action if ( (data.event === Event.actionSucceeded || data.event === Event.actionFailed) && @@ -166,11 +193,12 @@ export async function createRuleWithCycleCheck(data: { watchedActionId: data.watchedActionId, config: data.config ? JSON.stringify(data.config) : null, }).executeTakeFirstOrThrow(); + return createdRule; } catch (e) { if (isUniqueConstraintError(e)) { if (isSequentialRuleEvent(data.event)) { if (data.watchedActionId) { - throw new ReferentialRuleAlreadyExistsError( + throw new SequentialRuleAlreadyExistsError( data.event, data.actionInstanceId, data.watchedActionId From 29b50482b16095eec83d6ea3b63400ada236006b Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 13:59:48 +0100 Subject: [PATCH 11/31] dev: allow rules to be seeded --- core/prisma/seed/seedCommunity.ts | 117 +++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 25 deletions(-) diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index e8c8619cf..1abb5b9ee 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -11,10 +11,12 @@ import mudder from "mudder"; import type { ProcessedPub } from "contracts"; import type { + ActionInstances, ActionInstancesId, ApiAccessTokensId, Communities, CommunitiesId, + Event, FormAccessType, FormElements, Forms, @@ -23,6 +25,7 @@ import type { PubFields, PubsId, PubTypes, + Rules, Stages, StagesId, Users, @@ -83,29 +86,41 @@ export type UsersInitializer = Record< } >; -export type ActionInstanceInitializer = { - [K in ActionName]: { - /** - * @default randomUUID - */ - id?: ActionInstancesId; - action: K; - name?: string; - config: (typeof actions)[K]["config"]["schema"]["_input"]; - }; -}[keyof typeof actions]; +export type ActionInstanceInitializer = Record< + string, + { + [K in ActionName]: { + /** + * @default randomUUID + */ + id?: ActionInstancesId; + action: K; + name?: string; + config: (typeof actions)[K]["config"]["schema"]["_input"]; + }; + }[keyof typeof actions] +>; /** * Map of stagename to list of permissions */ -export type StagesInitializer = Record< +export type StagesInitializer< + U extends UsersInitializer, + A extends ActionInstanceInitializer = ActionInstanceInitializer, +> = Record< string, { id?: StagesId; members?: { [M in keyof U]?: MemberRole; }; - actions?: ActionInstanceInitializer[]; + actions?: A; + rules?: { + event: Event; + actionInstance: keyof A; + watchedAction?: keyof A; + config?: Record | null; + }[]; } >; @@ -441,10 +456,22 @@ type UsersBySlug = { [K in keyof U]: U[K] & Users; }; -type StagesWithPermissionsByName = { +type StagesWithPermissionsAndActionsAndRulesByName = { [K in keyof S]: Omit & { name: K } & { permissions: StagePermissions; - }; + } & ("actions" extends keyof S[K] + ? { + actions: { + [KK in keyof S[K]["actions"]]: S[K]["actions"][KK] & ActionInstances; + }; + } & ("rules" extends keyof S[K] + ? { + rules: { + [KK in keyof S[K]["rules"]]: S[K]["rules"][KK] & Rules; + }; + } + : {}) + : {}); }; type FormsByName> = { @@ -947,7 +974,7 @@ export async function seedCommunity< ), }, ]) - ) as StagesWithPermissionsByName; + ); const stageConnectionsList = props.stageConnections ? await db @@ -1102,14 +1129,15 @@ export async function seedCommunity< ) as FormsByName; // actions last because they can reference form and pub id's - const possibleActions = consolidatedStages.flatMap( - (stage, idx) => - stage.actions?.map((action) => ({ - stageId: stage.id, - action: action.action, - name: action.name, - config: JSON.stringify(action.config), - })) ?? [] + const possibleActions = consolidatedStages.flatMap((stage, idx) => + stage.actions + ? Object.entries(stage.actions).map(([actionName, action]) => ({ + stageId: stage.id, + action: action.action, + name: actionName, + config: JSON.stringify(action.config), + })) + : [] ); const createdActions = possibleActions.length @@ -1118,6 +1146,45 @@ export async function seedCommunity< logger.info(`${createdCommunity.name}: Successfully created ${createdActions.length} actions`); + // as StagesWithPermissionsByName; + + const possibleRules = consolidatedStages.flatMap( + (stage, idx) => + stage.rules?.map((rule) => ({ + event: rule.event, + actionInstanceId: expect( + createdActions.find((action) => action.name === rule.actionInstance)?.id + ), + watchedActionId: createdActions.find((action) => action.name === rule.watchedAction) + ?.id, + config: rule.config ? JSON.stringify(rule.config) : null, + })) ?? [] + ); + + const createdRules = possibleRules.length + ? await trx.insertInto("rules").values(possibleRules).returningAll().execute() + : []; + + const fullStages = Object.fromEntries( + consolidatedStages.map((stage) => { + const actionsForStage = createdActions.filter((action) => action.stageId === stage.id); + return [ + stage.name, + { + ...stage, + actions: Object.fromEntries( + actionsForStage.map((action) => [action.name, action]) + ), + rules: createdRules.filter((rule) => + actionsForStage.some((action) => action.id === rule.actionInstanceId) + ), + }, + ]; + }) + ) as StagesWithPermissionsAndActionsAndRulesByName; + + logger.info(`${createdCommunity.name}: Successfully created ${createdRules.length} rules`); + let apiToken: string | undefined = undefined; if (options?.withApiToken) { @@ -1163,7 +1230,7 @@ export async function seedCommunity< pubTypes: pubTypesWithPubFieldsByName, users: usersBySlug, members: createdMembers, - stages: stagesWithPermissionsByName, + stages: fullStages, stageConnections: stageConnectionsList, pubs: createdPubs, actions: createdActions, From 4dadc0d38e02f633f10501128ff1cbfc49c61d46 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 13:59:59 +0100 Subject: [PATCH 12/31] dev: add tests --- core/lib/server/rules.db.test.ts | 190 +++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 core/lib/server/rules.db.test.ts diff --git a/core/lib/server/rules.db.test.ts b/core/lib/server/rules.db.test.ts new file mode 100644 index 000000000..a5871fad2 --- /dev/null +++ b/core/lib/server/rules.db.test.ts @@ -0,0 +1,190 @@ +import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; + +import { Action, CoreSchemaType, Event, MemberRole } from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { mockServerCode } from "~/lib/__tests__/utils"; +import { createSeed } from "~/prisma/seed/createSeed"; + +const { createForEachMockedTransaction } = await mockServerCode(); + +const { getTrx } = createForEachMockedTransaction(); + +const seed = createSeed({ + community: { + name: "test", + slug: "test-server-pub", + }, + users: { + admin: { + role: MemberRole.admin, + }, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Description: { schemaName: CoreSchemaType.String }, + "Some relation": { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + "Basic Pub": { + Title: { isTitle: true }, + "Some relation": { isTitle: false }, + }, + }, + stages: { + "Stage 1": { + actions: { + "1": { + action: Action.log, + name: "1", + config: {}, + }, + "2": { + action: Action.log, + name: "2", + config: {}, + }, + "3": { + action: Action.log, + name: "3", + config: {}, + }, + }, + rules: [ + { + event: Event.actionSucceeded, + actionInstance: "1", + watchedAction: "2", + }, + { + event: Event.actionFailed, + actionInstance: "2", + watchedAction: "3", + }, + { + event: Event.pubInStageForDuration, + actionInstance: "3", + config: { + duration: 1000, + interval: "s", + }, + }, + { + event: Event.pubLeftStage, + actionInstance: "3", + }, + ], + }, + }, + pubs: [ + { + pubType: "Basic Pub", + values: { + Title: "Some title", + }, + stage: "Stage 1", + }, + ], +}); + +let community: CommunitySeedOutput; + +beforeAll(async () => { + const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); + community = await seedCommunity(seed); +}); + +describe("rules.db", () => { + it("should create a rule", async () => { + const { createRuleWithCycleCheck } = await import("./rules"); + const rule = await createRuleWithCycleCheck({ + event: Event.pubEnteredStage, + actionInstanceId: community.stages["Stage 1"].actions["1"].id, + config: {}, + }); + + expect(rule).toBeDefined(); + }); + + it("should throw a RegularRuleAlreadyExistsError if a regular rule already exists", async () => { + const { createRuleWithCycleCheck, RegularRuleAlreadyExistsError } = await import("./rules"); + await expect( + createRuleWithCycleCheck({ + event: Event.pubLeftStage, + actionInstanceId: community.stages["Stage 1"].actions["3"].id, + config: {}, + }) + ).rejects.toThrow(RegularRuleAlreadyExistsError); + }); + + it("should throw a SequentialRuleAlreadyExistsError if a sequential rule already exists", async () => { + const { createRuleWithCycleCheck, SequentialRuleAlreadyExistsError } = await import( + "./rules" + ); + await expect( + createRuleWithCycleCheck({ + event: Event.actionSucceeded, + actionInstanceId: community.stages["Stage 1"].actions["1"].id, + watchedActionId: community.stages["Stage 1"].actions["2"].id, + config: {}, + }) + ).rejects.toThrow(SequentialRuleAlreadyExistsError); + }); + + it("should throw a RuleConfigError if the config is invalid", async () => { + const { createRuleWithCycleCheck, RuleConfigError } = await import("./rules"); + await expect( + createRuleWithCycleCheck({ + event: Event.pubInStageForDuration, + actionInstanceId: community.stages["Stage 1"].actions["1"].id, + config: {}, + }) + ).rejects.toThrowError(RuleConfigError); + }); + + describe("cycle detection", () => { + it("should throw a RuleCycleError if the rule is a cycle", async () => { + const { createRuleWithCycleCheck, RuleCycleError } = await import("./rules"); + await expect( + createRuleWithCycleCheck({ + event: Event.actionSucceeded, + actionInstanceId: community.stages["Stage 1"].actions["3"].id, + watchedActionId: community.stages["Stage 1"].actions["1"].id, + config: {}, + }) + ).rejects.toThrow(RuleCycleError); + + // should also happen for ActionFailed + await expect( + createRuleWithCycleCheck({ + event: Event.actionFailed, + actionInstanceId: community.stages["Stage 1"].actions["3"].id, + watchedActionId: community.stages["Stage 1"].actions["1"].id, + config: {}, + }) + ).rejects.toThrow(RuleCycleError); + + // just to check that if we have 2->1, 1->2 will create a cycle + await expect( + createRuleWithCycleCheck({ + event: Event.actionSucceeded, + actionInstanceId: community.stages["Stage 1"].actions["2"].id, + watchedActionId: community.stages["Stage 1"].actions["1"].id, + config: {}, + }) + ).rejects.toThrow(RuleCycleError); + }); + it("should not throw an error if the rule is not a cycle", async () => { + // 3 -> 1 is fine, bc we only have 3 -> 2 and 2 -> 1 thus far + const { createRuleWithCycleCheck } = await import("./rules"); + await expect( + createRuleWithCycleCheck({ + event: Event.actionSucceeded, + actionInstanceId: community.stages["Stage 1"].actions["1"].id, + watchedActionId: community.stages["Stage 1"].actions["3"].id, + config: {}, + }) + ).resolves.not.toThrow(); + }); + }); +}); From 91774c5c38d4e8410fa4c3b85a46081e1413cc80 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 14:34:48 +0100 Subject: [PATCH 13/31] fix: update seed scripts --- core/prisma/exampleCommunitySeeds/arcadiaJournal.ts | 6 +++--- core/prisma/exampleCommunitySeeds/croccroc.ts | 11 +++++++---- core/prisma/seed/seedCommunity.ts | 8 ++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/core/prisma/exampleCommunitySeeds/arcadiaJournal.ts b/core/prisma/exampleCommunitySeeds/arcadiaJournal.ts index 506f01ab1..dc31034c9 100644 --- a/core/prisma/exampleCommunitySeeds/arcadiaJournal.ts +++ b/core/prisma/exampleCommunitySeeds/arcadiaJournal.ts @@ -200,8 +200,8 @@ export async function seedArcadiaJournal(communityId?: CommunitiesId) { stages: { Submitted: { members: { new: MemberRole.contributor }, - actions: [ - { + actions: { + "Send Review email": { action: Action.email, config: { subject: "HELLO :recipientName REVIEW OUR STUFF PLEASE", @@ -210,7 +210,7 @@ export async function seedArcadiaJournal(communityId?: CommunitiesId) { }, name: "Send Review email", }, - ], + }, }, "Ask Author for Consent": { members: { new: MemberRole.contributor }, diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts index f3deb5cf1..81049305d 100644 --- a/core/prisma/exampleCommunitySeeds/croccroc.ts +++ b/core/prisma/exampleCommunitySeeds/croccroc.ts @@ -140,17 +140,20 @@ export async function seedCroccroc(communityId?: CommunitiesId) { stages: { Submitted: { members: { new: MemberRole.contributor }, - actions: [ - { + actions: { + "Log Review": { + action: Action.log, + config: {}, + }, + "Send Review email": { action: Action.email, config: { subject: "HELLO :recipientName REVIEW OUR STUFF PLEASE", recipient: memberId, body: `You are invited to fill in a form.\n\n\n\n:link{form="review"}\n\nCurrent time: :value{field='croccroc:published-at'}`, }, - name: "Send Review email", }, - ], + }, }, "Ask Author for Consent": { members: { new: MemberRole.contributor }, diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index 1abb5b9ee..521eeb8d2 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -456,7 +456,11 @@ type UsersBySlug = { [K in keyof U]: U[K] & Users; }; -type StagesWithPermissionsAndActionsAndRulesByName = { +type StagesWithPermissionsAndActionsAndRulesByName< + U extends UsersInitializer, + S extends StagesInitializer, + StagePermissions, +> = { [K in keyof S]: Omit & { name: K } & { permissions: StagePermissions; } & ("actions" extends keyof S[K] @@ -1181,7 +1185,7 @@ export async function seedCommunity< }, ]; }) - ) as StagesWithPermissionsAndActionsAndRulesByName; + ) as unknown as StagesWithPermissionsAndActionsAndRulesByName; logger.info(`${createdCommunity.name}: Successfully created ${createdRules.length} rules`); From af205db2d7401343d7991a82427d72355870f2ab Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 14:51:26 +0100 Subject: [PATCH 14/31] fix: correctly fail actions if they return clientException, and check stack depth --- core/actions/_lib/runActionInstance.ts | 46 ++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index 6ca40d7b4..df6af8cdd 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -20,14 +20,16 @@ import { hydratePubValues } from "~/lib/fields/utils"; import { createLastModifiedBy } from "~/lib/lastModifiedBy"; import { getPubsWithRelatedValuesAndChildren } from "~/lib/server"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; -import { isClientException } from "~/lib/serverActions"; +import { isClientException, isClientExceptionOptions } from "~/lib/serverActions"; import { getActionByName } from "../api"; import { isScheduableRuleEvent } from "../types"; import { getActionRunByName } from "./getRuns"; import { resolveWithPubfields } from "./resolvePubfields"; import { scheduleActionInstances } from "./scheduleActionInstance"; -export type ActionInstanceRunResult = ClientException | ClientExceptionOptions | ActionSuccess; +export type ActionInstanceRunResult = (ClientException | ClientExceptionOptions | ActionSuccess) & { + stack: ActionRunsId[]; +}; export type RunActionInstanceArgs = { pubId: PubsId; @@ -44,6 +46,8 @@ const _runActionInstance = async ( ): Promise => { const isActionUserInitiated = "userId" in args; + const stack = [...args.stack, args.actionRunId]; + const pubPromise = getPubsWithRelatedValuesAndChildren( { pubId: args.pubId, @@ -87,6 +91,7 @@ const _runActionInstance = async ( return { error: "Pub not found", cause: pubResult.reason, + stack, }; } @@ -95,6 +100,7 @@ const _runActionInstance = async ( return { error: "Action instance not found", cause: actionInstanceResult.reason, + stack, }; } @@ -111,12 +117,14 @@ const _runActionInstance = async ( return { error: `Pub ${args.pubId} is not in stage ${actionInstance.stageId}, even though the action instance is. This most likely happened because the pub was moved before the time the action was scheduled to run.`, + stack, }; } if (!actionInstance.action) { return { error: "Action not found", + stack, }; } @@ -126,6 +134,7 @@ const _runActionInstance = async ( if (!actionRun || !action) { return { error: "Action not found", + stack, }; } @@ -135,6 +144,7 @@ const _runActionInstance = async ( const err = { error: "Invalid config", cause: parsedConfig.error, + stack, }; if (args.actionInstanceArgs) { // Check if the args passed can substitute for missing or invalid config @@ -154,6 +164,7 @@ const _runActionInstance = async ( title: "Invalid pub config", cause: parsedArgs.error, error: "The action was run with invalid parameters", + stack, }; } @@ -207,15 +218,26 @@ const _runActionInstance = async ( actionRunId: args.actionRunId, }); + if (isClientExceptionOptions(result)) { + await scheduleActionInstances({ + pubId: args.pubId, + stageId: actionInstance.stageId, + event: Event.actionFailed, + stack, + watchedActionInstanceId: actionInstance.id, + }); + return { ...result, stack }; + } + await scheduleActionInstances({ pubId: args.pubId, stageId: actionInstance.stageId, event: Event.actionSucceeded, - stack: [...args.stack, args.actionRunId], + stack, watchedActionInstanceId: actionInstance.id, }); - return result; + return { ...result, stack }; } catch (error) { captureException(error); logger.error(error); @@ -224,18 +246,27 @@ const _runActionInstance = async ( pubId: args.pubId, stageId: actionInstance.stageId, event: Event.actionFailed, - stack: [...args.stack, args.actionRunId], + stack, watchedActionInstanceId: actionInstance.id, }); return { title: "Failed to run action", error: error.message, + stack, }; } }; +const MAX_STACK_DEPTH = 10 as const; + export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { + if (args.stack.length > MAX_STACK_DEPTH) { + throw new Error( + `Action instance stack depth of ${args.stack.length} exceeds the maximum allowed depth of ${MAX_STACK_DEPTH}` + ); + } + const isActionUserInitiated = "userId" in args; // we need to first create the action run, @@ -278,6 +309,7 @@ export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { title: "Action run failed", error: `Multiple scheduled action runs found for pub ${args.pubId} and action instance ${args.actionInstanceId}. This should never happen.`, cause: `Multiple scheduled action runs found for pub ${args.pubId} and action instance ${args.actionInstanceId}. This should never happen.`, + stack: args.stack, }; await autoRevalidate( @@ -303,7 +335,9 @@ export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { const result = await _runActionInstance({ ...args, actionRunId: actionRun.id }); - const status = isClientException(result) ? ActionRunStatus.failure : ActionRunStatus.success; + const status = isClientExceptionOptions(result) + ? ActionRunStatus.failure + : ActionRunStatus.success; // update the action run with the result await autoRevalidate( From eadb6c2ddd9f2c3c43158332bd6b5ec5662ec92e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 15:13:36 +0100 Subject: [PATCH 15/31] feat: add max stack depth protection at rule run- and creation time --- core/actions/_lib/runActionInstance.ts | 6 +- core/lib/server/rules.db.test.ts | 22 +++- core/lib/server/rules.ts | 161 +++++++++++++++++++------ 3 files changed, 150 insertions(+), 39 deletions(-) diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index df6af8cdd..5bbccd8a2 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -20,9 +20,9 @@ import { hydratePubValues } from "~/lib/fields/utils"; import { createLastModifiedBy } from "~/lib/lastModifiedBy"; import { getPubsWithRelatedValuesAndChildren } from "~/lib/server"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; -import { isClientException, isClientExceptionOptions } from "~/lib/serverActions"; +import { MAX_STACK_DEPTH } from "~/lib/server/rules"; +import { isClientExceptionOptions } from "~/lib/serverActions"; import { getActionByName } from "../api"; -import { isScheduableRuleEvent } from "../types"; import { getActionRunByName } from "./getRuns"; import { resolveWithPubfields } from "./resolvePubfields"; import { scheduleActionInstances } from "./scheduleActionInstance"; @@ -258,8 +258,6 @@ const _runActionInstance = async ( } }; -const MAX_STACK_DEPTH = 10 as const; - export async function runActionInstance(args: RunActionInstanceArgs, trx = db) { if (args.stack.length > MAX_STACK_DEPTH) { throw new Error( diff --git a/core/lib/server/rules.db.test.ts b/core/lib/server/rules.db.test.ts index a5871fad2..7b5c66bbf 100644 --- a/core/lib/server/rules.db.test.ts +++ b/core/lib/server/rules.db.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; +import { beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest"; import { Action, CoreSchemaType, Event, MemberRole } from "db/public"; @@ -49,6 +49,11 @@ const seed = createSeed({ name: "3", config: {}, }, + "4": { + action: Action.log, + name: "4", + config: {}, + }, }, rules: [ { @@ -186,5 +191,20 @@ describe("rules.db", () => { }) ).resolves.not.toThrow(); }); + + it("should throw a RuleMaxDepthError if the rule would exceed the maximum stack depth", async () => { + const { createRuleWithCycleCheck, RuleMaxDepthError } = await import("./rules"); + await expect( + createRuleWithCycleCheck( + { + event: Event.actionSucceeded, + actionInstanceId: community.stages["Stage 1"].actions["3"].id, + watchedActionId: community.stages["Stage 1"].actions["4"].id, + config: {}, + }, + 3 + ) + ).rejects.toThrow(RuleMaxDepthError); + }); }); }); diff --git a/core/lib/server/rules.ts b/core/lib/server/rules.ts index d1c2e1afd..24875f285 100644 --- a/core/lib/server/rules.ts +++ b/core/lib/server/rules.ts @@ -38,6 +38,15 @@ export class RuleCycleError extends RuleError { } } +export class RuleMaxDepthError extends RuleError { + constructor(public path: ActionInstances[]) { + super( + `Creating this rule would exceed the maximum stack depth (${MAX_STACK_DEPTH}): ${path.map((p) => p.name).join(" -> ")}` + ); + this.name = "RuleMaxDepthError"; + } +} + export class RuleAlreadyExistsError extends RuleError { constructor( message: string, @@ -79,16 +88,42 @@ export const createRule = (props: NewRules) => autoRevalidate(db.insertInto("rul export const removeRule = (ruleId: RulesId) => autoRevalidate(db.deleteFrom("rules").where("id", "=", ruleId)); -// Function to check if adding a rule would create a cycle +/** + * The maximum number of action instances that can be in a sequence in a single stage. + * TODO: make this trackable across stages + */ +export const MAX_STACK_DEPTH = 10; + +/** + * Checks if adding a rule would create a cycle, or else adding it would create + * a sequence exceeding the MAXIMUM_STACK_DEPTH + * + * This is a recursive function that checks if there is a path from the watched action to the toBeRun action. + * If there is, it returns the path and a flag indicating that a cycle would be created. + * Otherwise, it returns false. + * + */ async function wouldCreateCycle( toBeRunActionId: ActionInstancesId, - watchedActionId: ActionInstancesId -): Promise<{ hasCycle: true; path: ActionInstances[] } | { hasCycle: false; path?: never }> { - const forwardResult = await db + watchedActionId: ActionInstancesId, + maxStackDepth = MAX_STACK_DEPTH +): Promise< + | { hasCycle: true; exceedsMaxDepth: false; path: ActionInstances[] } + | { hasCycle: false; exceedsMaxDepth: true; path: ActionInstances[] } + | { hasCycle: false; exceedsMaxDepth: false; path?: never } +> { + // Check if there's a path from toBeRunActionId back to watchedActionId (cycle) + // or if any path would exceed MAX_STACK_DEPTH + const result = await db .withRecursive("action_path", (cte) => cte .selectFrom("action_instances") - .select(["id", sql`array[id]`.as("path")]) + .select([ + "id", + sql`array[id]`.as("path"), + sql`1`.as("depth"), + sql`false`.as("isCycle"), + ]) .where("id", "=", toBeRunActionId) .union((qb) => qb @@ -104,41 +139,81 @@ async function wouldCreateCycle( sql< ActionInstancesId[] >`action_path.path || array[action_instances.id]`.as("path"), + sql`action_path.depth + 1`.as("depth"), + sql`action_instances.id = any(action_path.path) OR action_instances.id = ${watchedActionId}`.as( + "isCycle" + ), ]) .where((eb) => - eb.not( - eb( - "action_instances.id", - "=", - eb.fn.any(sql`action_path.path`) - ) - ) + // Continue recursion if: + // 1. We haven't found a cycle yet + // 2. We haven't exceeded MAX_STACK_DEPTH + eb.and([ + eb("action_path.isCycle", "=", false), + eb("action_path.depth", "<=", maxStackDepth), + ]) ) ) ) .selectFrom("action_path") - .select(["id", "path"]) - .where("id", "=", watchedActionId) + .select(["id", "path", "depth", "isCycle"]) + .where((eb) => + // Find either: + // 1. A path that creates a cycle (id = watchedActionId or id already in path) + // 2. A path that would exceed MAX_STACK_DEPTH when adding the new rule + eb.or([eb("isCycle", "=", true), eb("depth", ">=", maxStackDepth)]) + ) + .orderBy(["isCycle desc", "depth desc"]) + .limit(1) .execute(); - if (forwardResult.length === 0) { + if (result.length === 0) { + // No issues found return { hasCycle: false, + exceedsMaxDepth: false, }; } - // if we found a path from toBeRunActionId to watchedActionId, we have a cycle - // the complete cycle is: watchedActionId -> toBeRunActionId (the new rule) -> path -> watchedActionId - const cyclePath = [watchedActionId, toBeRunActionId, ...forwardResult[0].path.slice(1)]; + const pathResult = result[0]; + + // Construct the full path + let fullPath: ActionInstancesId[]; - // get the action instances for the path + if (pathResult.isCycle) { + // For cycles, include the watchedActionId at the beginning to show the complete cycle + if (pathResult.id === watchedActionId) { + // Direct cycle: watchedActionId -> toBeRunActionId -> watchedActionId + fullPath = [watchedActionId, toBeRunActionId, watchedActionId]; + } else { + // Indirect cycle: watchedActionId -> toBeRunActionId -> path -> (some node in path) + // Find where the cycle occurs + const cycleIndex = pathResult.path.findIndex((id) => id === pathResult.id); + if (cycleIndex !== -1) { + // Include only the part of the path that forms the cycle + fullPath = [ + watchedActionId, + toBeRunActionId, + ...pathResult.path.slice(0, cycleIndex + 1), + ]; + } else { + // Fallback - shouldn't happen but just in case + fullPath = [watchedActionId, toBeRunActionId, ...pathResult.path]; + } + } + } else { + // For MAX_STACK_DEPTH issues, show the full path that would be created + fullPath = [watchedActionId, toBeRunActionId, ...pathResult.path]; + } + + // Get the action instances for the path const actionInstances = await db .selectFrom("action_instances") .selectAll() - .where("id", "in", cyclePath) + .where("id", "in", fullPath) .execute(); - const filledInPath = cyclePath.map((id) => { + const filledInPath = fullPath.map((id) => { const actionInstance = expect( actionInstances.find((ai) => ai.id === id), `Action instance ${id} not found` @@ -147,20 +222,33 @@ async function wouldCreateCycle( }); return { - hasCycle: true, + hasCycle: pathResult.isCycle, + exceedsMaxDepth: !pathResult.isCycle && pathResult.depth >= maxStackDepth, path: filledInPath, - }; + } as + | { + hasCycle: true; + exceedsMaxDepth: false; + path: ActionInstances[]; + } + | { + hasCycle: false; + exceedsMaxDepth: true; + path: ActionInstances[]; + }; } // Function to actually create the rule after checking for cycles -export async function createRuleWithCycleCheck(data: { - event: Event; - actionInstanceId: ActionInstancesId; - watchedActionId?: ActionInstancesId; - config?: Record | null; -}) { +export async function createRuleWithCycleCheck( + data: { + event: Event; + actionInstanceId: ActionInstancesId; + watchedActionId?: ActionInstancesId; + config?: Record | null; + }, + maxStackDepth = MAX_STACK_DEPTH +) { // check the config - const config = rules[data.event].additionalConfig; if (config) { @@ -176,13 +264,18 @@ export async function createRuleWithCycleCheck(data: { (data.event === Event.actionSucceeded || data.event === Event.actionFailed) && data.watchedActionId ) { - const { hasCycle, path } = await wouldCreateCycle( + const result = await wouldCreateCycle( data.actionInstanceId, - data.watchedActionId + data.watchedActionId, + maxStackDepth ); - if (hasCycle) { - throw new RuleCycleError(path); + if (result.hasCycle) { + throw new RuleCycleError(result.path); + } + + if ("exceedsMaxDepth" in result && result.exceedsMaxDepth) { + throw new RuleMaxDepthError(result.path); } } From a34cef8aecf195f33c381569deeedc3b364e27f9 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 15:18:16 +0100 Subject: [PATCH 16/31] fix: fix type errors --- core/actions/_lib/runActionInstance.db.test.ts | 12 ++++++------ core/actions/api/serverAction.ts | 1 + core/lib/server/pub-trigger.db.test.ts | 6 +++--- core/prisma/seed/seedCommunity.db.test.ts | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/core/actions/_lib/runActionInstance.db.test.ts b/core/actions/_lib/runActionInstance.db.test.ts index 3763dcf2e..9bd3c4dfa 100644 --- a/core/actions/_lib/runActionInstance.db.test.ts +++ b/core/actions/_lib/runActionInstance.db.test.ts @@ -29,29 +29,29 @@ const pubTriggerTestSeed = async () => { }, stages: { Submission: { - actions: [ - { + actions: { + "1": { action: Action.log, config: { debounce: 1, }, }, - { + "2": { action: Action.email, config: { - recipient: "all@pubpub.org", + recipientEmail: "all@pubpub.org", body: "Hello", subject: "Test", }, }, - { + "3": { action: Action.googleDriveImport, config: { docUrl: "https://docs.google.com/document/d/1234567890", outputField: `${slugName}:title`, }, }, - ], + }, }, }, pubs: [ diff --git a/core/actions/api/serverAction.ts b/core/actions/api/serverAction.ts index 82052c39c..dc223d414 100644 --- a/core/actions/api/serverAction.ts +++ b/core/actions/api/serverAction.ts @@ -15,6 +15,7 @@ export const runActionInstance = defineServerAction(async function runActionInst if (!user) { return { error: "Not logged in", + stack: [], }; } diff --git a/core/lib/server/pub-trigger.db.test.ts b/core/lib/server/pub-trigger.db.test.ts index 665b70376..5422b84a8 100644 --- a/core/lib/server/pub-trigger.db.test.ts +++ b/core/lib/server/pub-trigger.db.test.ts @@ -780,15 +780,15 @@ describe("pub_values_history trigger", () => { }, stages: { "Stage 1": { - actions: [ - { + actions: { + "1": { action: Action.log, name: "Log Action", config: { debounce: 1000, }, }, - ], + }, }, }, }); diff --git a/core/prisma/seed/seedCommunity.db.test.ts b/core/prisma/seed/seedCommunity.db.test.ts index 2232ab57c..c7c6aa3bb 100644 --- a/core/prisma/seed/seedCommunity.db.test.ts +++ b/core/prisma/seed/seedCommunity.db.test.ts @@ -60,16 +60,16 @@ describe("seedCommunity", () => { stages: { "Stage 1": { members: { hih: MemberRole.contributor }, - actions: [ - { + actions: { + "1": { action: Action.email, config: { body: "hello nerd", subject: "hello nerd", - recipient: testUserId, + recipientEmail: "all@pubpub.org", }, }, - ], + }, }, "Stage 2": {}, }, From a3516d62723a03056e67fe8f84166c0d8aef80bc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 16:48:45 +0100 Subject: [PATCH 17/31] fix: fix test --- core/actions/_lib/runActionInstance.db.test.ts | 4 ++-- core/prisma/seed/seedCommunity.db.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/actions/_lib/runActionInstance.db.test.ts b/core/actions/_lib/runActionInstance.db.test.ts index 9bd3c4dfa..cefca3280 100644 --- a/core/actions/_lib/runActionInstance.db.test.ts +++ b/core/actions/_lib/runActionInstance.db.test.ts @@ -86,7 +86,7 @@ describe("runActionInstance", () => { stack: [], }); - expect(result).toEqual({ + expect(result).toMatchObject({ success: true, report: "Logged out some data, check your console.", data: {}, @@ -102,7 +102,7 @@ describe("runActionInstance", () => { expect(actionRuns).toHaveLength(1); expect(actionRuns[0].status).toEqual(ActionRunStatus.success); - expect(actionRuns[0].result, "Action run should be successfully created").toEqual({ + expect(actionRuns[0].result, "Action run should be successfully created").toMatchObject({ success: true, report: "Logged out some data, check your console.", data: {}, diff --git a/core/prisma/seed/seedCommunity.db.test.ts b/core/prisma/seed/seedCommunity.db.test.ts index c7c6aa3bb..9b0567922 100644 --- a/core/prisma/seed/seedCommunity.db.test.ts +++ b/core/prisma/seed/seedCommunity.db.test.ts @@ -175,7 +175,7 @@ describe("seedCommunity", () => { body: "hello nerd", subject: "hello nerd", }, - name: "", + name: "1", }, ]); From e173c150deb3908d3f3b3b675cc0c7e4de160fae Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 17:08:02 +0100 Subject: [PATCH 18/31] dev: add chained rules to croccroc seed --- core/prisma/exampleCommunitySeeds/croccroc.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts index 81049305d..0e14b499d 100644 --- a/core/prisma/exampleCommunitySeeds/croccroc.ts +++ b/core/prisma/exampleCommunitySeeds/croccroc.ts @@ -3,6 +3,7 @@ import { Action, CoreSchemaType, ElementType, + Event, InputComponent, MemberRole, StructuralFormElement, @@ -32,6 +33,7 @@ export async function seedCroccroc(communityId?: CommunitiesId) { File: { schemaName: CoreSchemaType.FileUpload }, Confidence: { schemaName: CoreSchemaType.Vector3 }, "Published At": { schemaName: CoreSchemaType.DateTime }, + "File Upload": { schemaName: CoreSchemaType.FileUpload }, }, pubTypes: { Submission: { @@ -44,6 +46,7 @@ export async function seedCroccroc(communityId?: CommunitiesId) { File: { isTitle: false }, Confidence: { isTitle: false }, "Published At": { isTitle: false }, + "File Upload": { isTitle: false }, }, Evaluation: { Title: { isTitle: true }, @@ -95,6 +98,15 @@ export async function seedCroccroc(communityId?: CommunitiesId) { ], stage: "Submitted", }, + { + pubType: "Submission", + values: { + Title: "Rule Test", + Content: "Rule Test Content", + "Published At": new Date(), + }, + stage: "Rule Test", + }, ], forms: { Review: { @@ -165,6 +177,105 @@ export async function seedCroccroc(communityId?: CommunitiesId) { "In Production": {}, Published: {}, Shelved: {}, + "Rule Test": { + actions: { + "Log 1": { + action: Action.log, + config: {}, + }, + "Log 2": { + action: Action.log, + config: {}, + }, + "Log 3": { + action: Action.log, + config: {}, + }, + "Log 4": { + action: Action.log, + config: {}, + }, + "Log 5": { + action: Action.log, + config: {}, + }, + "Log 6": { + action: Action.log, + config: {}, + }, + "Log 7": { + action: Action.log, + config: {}, + }, + "Log 8": { + action: Action.log, + config: {}, + }, + "Log 9": { + action: Action.log, + config: {}, + }, + + "Email 1": { + action: Action.email, + config: { + body: "test", + subject: "Hello", + }, + }, + "Log X": { + action: Action.log, + config: {}, + }, + }, + rules: [ + { + actionInstance: "Log 1", + event: Event.actionSucceeded, + watchedAction: "Log 2", + }, + { + actionInstance: "Log 2", + event: Event.actionSucceeded, + watchedAction: "Log 3", + }, + { + actionInstance: "Log 3", + event: Event.actionSucceeded, + watchedAction: "Log 4", + }, + { + actionInstance: "Log 4", + event: Event.actionSucceeded, + watchedAction: "Log 5", + }, + { + actionInstance: "Log 5", + event: Event.actionSucceeded, + watchedAction: "Log 6", + }, + { + actionInstance: "Log 6", + event: Event.actionSucceeded, + watchedAction: "Log 7", + }, + { + actionInstance: "Log 7", + event: Event.actionSucceeded, + watchedAction: "Log 8", + }, + { + actionInstance: "Log 8", + event: Event.actionSucceeded, + watchedAction: "Log 9", + }, + { + actionInstance: "Log 1", + event: Event.actionFailed, + watchedAction: "Email 1", + }, + ], + }, }, stageConnections: { Submitted: { From ca92e0faf89dc2662f96005d8399f75cb2a83067 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Mar 2025 17:59:13 +0100 Subject: [PATCH 19/31] dev: add sequential rule test --- .../actionsTab/StagePanelRuleCreator.tsx | 26 ++--- core/playwright/actions.rules.spec.ts | 98 +++++++++++++++++++ .../playwright/fixtures/stages-manage-page.ts | 54 +++++++++- 3 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 core/playwright/actions.rules.spec.ts diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx index 56e4d8c66..0f5674275 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx @@ -56,12 +56,14 @@ const ActionSelector = ({ label, placeholder, disabledActionId, + dataTestIdPrefix, }: { fieldProps: Omit, "name">; actionInstances: ActionInstances[]; label: string; placeholder: string; disabledActionId?: string; + dataTestIdPrefix?: string; }) => { return ( @@ -71,7 +73,7 @@ const ActionSelector = ({ defaultValue={fieldProps.value} value={fieldProps.value} > - + @@ -85,6 +87,7 @@ const ActionSelector = ({ value={instance.id} className="hover:bg-gray-100" disabled={isDisabled} + data-testid={`${dataTestIdPrefix}-select-item-${instance.name}`} >
@@ -234,7 +237,9 @@ export const StagePanelRuleCreator = (props: Props) => {
- + @@ -258,6 +263,7 @@ export const StagePanelRuleCreator = (props: Props) => { actionInstances={props.actionInstances} label="Run..." placeholder="Action" + dataTestIdPrefix="action-selector" /> )} /> @@ -272,22 +278,14 @@ export const StagePanelRuleCreator = (props: Props) => { <>