import { generateId } from '../_helpers'; import type { BusinessEventKey } from '../business-event'; import { requireObjectComponentField, requireStringComponentField } from '../_existing-component-fields'; import { getExistingManifestContext, type ExistingManifestContextOptions, } from '../existing-manifest-context'; import type { ManifestFunction } from '../manifest-function'; import type { ManifestComponentDeletion, ManifestScope } from '../manifest-builder'; import type { WorkflowActionPayloads } from './actions'; export type WorkflowVersionStatus = 'VERSION_STATUS_DRAFT' | 'VERSION_STATUS_PUBLISHED'; export type WorkflowTriggerStatus = | 'TRIGGER_STATUS_ACTIVE' | 'TRIGGER_STATUS_INACTIVE' | 'TRIGGER_STATUS_SYSTEM_DISABLED'; export type WorkflowConditionBuilderMode = 'CONDITION_BUILDER_MODE_BASIC' | 'CONDITION_BUILDER_MODE_ADVANCED'; export interface WorkflowExecutionRateLimit { execution_limit: number; } export type WorkflowCustomVariableType = | 'CUSTOM_VARIABLE_TYPE_TEXT' | 'CUSTOM_VARIABLE_TYPE_NUMBER' | 'CUSTOM_VARIABLE_TYPE_BOOLEAN' | 'CUSTOM_VARIABLE_TYPE_DATE' | 'CUSTOM_VARIABLE_TYPE_DATETIME'; export interface WorkflowCustomVariable { name: string; description?: string; data_type: WorkflowCustomVariableType; } export type WorkflowCategoryName = | 'WORKFLOW_CATEGORY_NAME_BILLING_INCLUDED' | 'WORKFLOW_CATEGORY_NAME_CUSTOMER_CLOUD_SOURCE' | 'WORKFLOW_CATEGORY_NAME_TRANSFORMATIONS_SCHEDULES'; export interface WorkflowCategory { name: WorkflowCategoryName; sub_categories?: string[]; } export interface WorkflowConditionalBranch { rql_formula?: string; node_id?: string; } export interface WorkflowFork { branches: string[]; } export interface WorkflowLoop { inner_node: string; } export interface WorkflowGroup { inner_node: string; } export interface WorkflowSwitch { branches: WorkflowConditionalBranch[]; default_node?: string; } export interface WorkflowSummaryBullet { text?: string; sub_bullets?: WorkflowSummaryBullet[]; metadata?: Record; } export interface WorkflowSummary { text?: string; bullets?: WorkflowSummaryBullet[]; does_not_do?: string[]; } export interface WorkflowFunctionProcessingContext { function_summary?: string; function_summary_steps?: WorkflowSummaryBullet[]; function_api_name?: string; action_config_ids?: string[]; summary?: WorkflowSummary; } export interface WorkflowDigestProcessingContext { digest_config_id?: string; } export type WorkflowProcessingContext = | { function_context: WorkflowFunctionProcessingContext; digest_context?: never } | { function_context?: never; digest_context: WorkflowDigestProcessingContext } | { function_context?: never; digest_context?: never }; export interface WorkflowNode { /** Studio's role-scoped Action Registry lists config IDs via GetActionConfigSummaries; the public SDK does not expose that RPC. */ config_id: string; /** Fetch this config_id's input JSON Schema with Action Registry GetActionConfig; payloads vary by role and company. */ payload?: Record; next?: string; step_alias?: string; fork?: WorkflowFork; loop?: WorkflowLoop; group?: WorkflowGroup; switch?: WorkflowSwitch; processing_context?: WorkflowProcessingContext; summary?: WorkflowSummary; } /** Typed authoring from rippling-main's Action Registry snapshot; raw WorkflowNode supports newer actions. */ export function workflowAction( configId: K, payload: NoInfer, options: Omit = {}, ): WorkflowNode { return { ...options, config_id: configId, payload }; } export interface WorkflowGraph { root_node_id: string; nodes: Record; } export type WorkflowAutomationEventType = | 'AUTOMATION_EVENT_TYPE_BUSINESS' | 'AUTOMATION_EVENT_TYPE_RECORD_CHANGE' | 'AUTOMATION_EVENT_TYPE_SCHEDULED' | 'AUTOMATION_EVENT_TYPE_MANUAL' | 'AUTOMATION_EVENT_TYPE_DATE_RELATIVE' | 'AUTOMATION_EVENT_TYPE_WEBHOOK' | 'AUTOMATION_EVENT_TYPE_INTEGRATION'; export type WorkflowRecordChangeOperationType = | 'RECORD_CHANGE_OPERATION_TYPE_CREATE' | 'RECORD_CHANGE_OPERATION_TYPE_UPDATE' | 'RECORD_CHANGE_OPERATION_TYPE_DELETE' | 'RECORD_CHANGE_OPERATION_TYPE_CREATE_OR_UPDATE'; export type WorkflowDateRelativeOperatorType = | 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_EQ' | 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_WITHIN' | 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_BETWEEN'; export type WorkflowScheduledFrequencyType = | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_DAILY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKDAY_DAILY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKLY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_BIWEEKLY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY_ON_DAY_OF_WEEK' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_ONE_TIME' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_QUARTERLY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_ANNUALLY' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_CUSTOM' | 'SCHEDULED_TRIGGER_FREQUENCY_TYPE_CRON'; export type WorkflowCustomScheduleFrequencyUnit = | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_DAYS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_WEEKS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MONTHS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_YEARS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_HOURS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MINUTES'; export interface WorkflowTimeOfDaySchedule { hour_of_day: number; minute_of_hour?: number; } export interface WorkflowOneTimeSchedule extends WorkflowTimeOfDaySchedule { year: number; month_of_year: number; day_of_month: number; } export interface WorkflowWeeklySchedule extends WorkflowTimeOfDaySchedule { day_of_week: number[]; } export interface WorkflowMonthlySchedule extends WorkflowTimeOfDaySchedule { day_of_month: number; start_month?: number; } export interface WorkflowMonthlyOnDayOfWeekSchedule extends WorkflowTimeOfDaySchedule { day_of_week: number; day_of_month: number; } export interface WorkflowQuarterlySchedule extends WorkflowTimeOfDaySchedule { day_of_month: number; month_of_year: number; } export interface WorkflowAnnualSchedule extends WorkflowTimeOfDaySchedule { day_of_month: number; month_of_year: number; start_month?: number; } export interface WorkflowCronSchedule { cron_expression: string; } type WorkflowCustomScheduleFields = { daily_schedule: WorkflowTimeOfDaySchedule; weekly_schedule: WorkflowWeeklySchedule; monthly_schedule: WorkflowMonthlySchedule; annual_schedule: WorkflowAnnualSchedule; }; type WorkflowCustomScheduleChoice = | { custom_schedule_frequency: 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_DAYS'; daily_schedule: WorkflowTimeOfDaySchedule; weekly_schedule?: never; monthly_schedule?: never; annual_schedule?: never; } | { custom_schedule_frequency: 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_WEEKS'; weekly_schedule: WorkflowWeeklySchedule; daily_schedule?: never; monthly_schedule?: never; annual_schedule?: never; } | { custom_schedule_frequency: 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MONTHS'; monthly_schedule: WorkflowMonthlySchedule; daily_schedule?: never; weekly_schedule?: never; annual_schedule?: never; } | { custom_schedule_frequency: 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_YEARS'; annual_schedule: WorkflowAnnualSchedule; daily_schedule?: never; weekly_schedule?: never; monthly_schedule?: never; } | ({ custom_schedule_frequency: | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_HOURS' | 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MINUTES'; } & { [K in keyof WorkflowCustomScheduleFields]?: never }); export type WorkflowCustomSchedule = WorkflowCustomScheduleChoice & { custom_schedule_frequency_value: number; hour_of_day?: number; minute_of_hour?: number; }; type WorkflowScheduledKeys = { SCHEDULED_TRIGGER_FREQUENCY_TYPE_DAILY: { daily_schedule: WorkflowTimeOfDaySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKDAY_DAILY: { weekday_schedule: WorkflowTimeOfDaySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKLY: { weekly_schedule: WorkflowWeeklySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_BIWEEKLY: { bi_weekly_schedule: WorkflowWeeklySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY: { monthly_schedule: WorkflowMonthlySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY_ON_DAY_OF_WEEK: { monthly_on_day_of_week_schedule: WorkflowMonthlyOnDayOfWeekSchedule; }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_ONE_TIME: { one_time_schedule: WorkflowOneTimeSchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_QUARTERLY: { quarterly_schedule: WorkflowQuarterlySchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_ANNUALLY: { annual_schedule: WorkflowAnnualSchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_CUSTOM: { custom_schedule: WorkflowCustomSchedule }; SCHEDULED_TRIGGER_FREQUENCY_TYPE_CRON: { cron_schedule: WorkflowCronSchedule }; }; type WorkflowScheduleKey = { [F in WorkflowScheduledFrequencyType]: keyof WorkflowScheduledKeys[F]; }[WorkflowScheduledFrequencyType]; export type WorkflowScheduledTriggerConfig = { [F in WorkflowScheduledFrequencyType]: { frequency: F; timezone: string; supported_role_states?: WorkflowRoleState[]; } & WorkflowScheduledKeys[F] & { [K in Exclude]?: never; }; }[WorkflowScheduledFrequencyType]; export interface WorkflowTriggerBase { automation_event_type: WorkflowAutomationEventType; og_module?: string; base_model?: string; rql_formula?: string; is_basic_workflow?: boolean; og_category?: string; condition_builder_mode?: WorkflowConditionBuilderMode; conditions_tree?: string; execution_rate_limit?: WorkflowExecutionRateLimit; } export interface WorkflowBusinessTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_BUSINESS'; /** Business event to subscribe to. Availability is validated during installation. */ event_name: BusinessEventKey; business_event_trigger?: { navigation_path?: string; }; } export type WorkflowBusinessEventKey = BusinessEventKey; export interface WorkflowRecordChangeTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_RECORD_CHANGE'; record_change_operation_type: WorkflowRecordChangeOperationType; } export type WorkflowRoleState = | 'ROLE_STATE_INIT' | 'ROLE_STATE_HIRED' | 'ROLE_STATE_ACCEPTED' | 'ROLE_STATE_ACTIVE' | 'ROLE_STATE_TERMINATED'; export type WorkflowDateRelativeBaseField = 'start_date' | 'end_date'; export interface WorkflowDateRelativeOperand { time_delta: { days: number }; } export type WorkflowDateRelativeTriggerConfig = { hour_of_day: number; minute_of_hour?: number; timezone?: string; supported_role_states?: WorkflowRoleState[]; trigger_on_anniversary?: boolean; should_trigger_exactly_once_per_trigger_object?: boolean; } & ( | { base_model_date_field: WorkflowDateRelativeBaseField; base_model_date_field_rql_path?: never } | { base_model_date_field?: never; base_model_date_field_rql_path: string } ) & ( | { date_range_operator?: 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_EQ'; operands: [WorkflowDateRelativeOperand]; } | { date_range_operator: 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_WITHIN'; operands: [WorkflowDateRelativeOperand]; } | { date_range_operator: 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_BETWEEN'; operands: [WorkflowDateRelativeOperand, WorkflowDateRelativeOperand]; } ); export interface WorkflowDateRelativeTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_DATE_RELATIVE'; base_model: string; date_relative_trigger: WorkflowDateRelativeTriggerConfig; } export interface WorkflowScheduledTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_SCHEDULED'; scheduled_trigger: WorkflowScheduledTriggerConfig; } export interface WorkflowManualTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_MANUAL'; } export interface WorkflowIntegrationSenderFilter { allowed_supergroup_id?: string; } export type WorkflowIntegrationTriggerProvider = | 'INTEGRATION_TRIGGER_PROVIDER_SLACK' | 'INTEGRATION_TRIGGER_PROVIDER_EMAIL'; export type WorkflowSlackTriggerEventType = | 'SLACK_TRIGGER_EVENT_TYPE_MESSAGE_POSTED' | 'SLACK_TRIGGER_EVENT_TYPE_REACTION_ADDED'; export interface WorkflowSlackTriggerConfig { event_type: WorkflowSlackTriggerEventType; channel_ids: string[]; allowed_sender_group_id?: string; message_text_regex?: string; reaction_names?: string[]; include_thread_replies?: boolean; has_attachment?: boolean; sender_filter?: WorkflowIntegrationSenderFilter; workspace_owner_id?: string; } export interface WorkflowEmailTriggerConfig { recipient_email: string; subject_contains?: string; body_contains?: string; has_attachment?: boolean; sender_filter?: WorkflowIntegrationSenderFilter; } export interface WorkflowSlackIntegrationTrigger { provider: 'INTEGRATION_TRIGGER_PROVIDER_SLACK'; slack_trigger: WorkflowSlackTriggerConfig; email_trigger?: never; } export interface WorkflowEmailIntegrationTrigger { provider: 'INTEGRATION_TRIGGER_PROVIDER_EMAIL'; email_trigger: WorkflowEmailTriggerConfig; slack_trigger?: never; } export type WorkflowIntegrationTriggerConfig = | WorkflowSlackIntegrationTrigger | WorkflowEmailIntegrationTrigger; export interface WorkflowIntegrationTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_INTEGRATION'; integration_trigger: WorkflowIntegrationTriggerConfig; } export interface WorkflowWebhookConfig { allowed_ips?: string[]; payload_schema?: Record; } export interface WorkflowWebhookDefinition { webhook_trigger_id?: string; production_webhook_url?: string; test_webhook_url?: string; config?: WorkflowWebhookConfig; auth_token_id?: string; } export interface WorkflowWebhookTrigger extends WorkflowTriggerBase { automation_event_type: 'AUTOMATION_EVENT_TYPE_WEBHOOK'; webhook_trigger: WorkflowWebhookDefinition; } export type WorkflowTrigger = | WorkflowBusinessTrigger | WorkflowRecordChangeTrigger | WorkflowDateRelativeTrigger | WorkflowScheduledTrigger | WorkflowManualTrigger | WorkflowIntegrationTrigger | WorkflowWebhookTrigger; export interface WorkflowDeletionIdentifier { definitionId: string; } export interface WorkflowProps { /** * Stable workflow definition id. Auto-generated when omitted. */ definitionId?: string; /** * Workflow title shown to admins. */ title: string; /** * Short workflow description shown to admins. */ description?: string; /** * The function this workflow runs. Pass a live {@link ManifestFunction} reference, * or a raw api_name string for functions defined outside this manifest. * */ function?: ManifestFunction | string; /** * Trigger definition in the workflows v2 proto JSON shape. */ trigger: WorkflowTrigger; recipeId?: string; versionStatus?: WorkflowVersionStatus; triggerStatus?: WorkflowTriggerStatus; graph?: WorkflowGraph; functionsInferredGraph?: WorkflowGraph; customVariables?: Record; functionApiNamesByOldId?: Record; categories?: WorkflowCategory[]; } function cloneJson(value: T): T { if (value == null) return value; return JSON.parse(JSON.stringify(value)) as T; } function assertNonEmptyString(value: unknown, path: string): void { if (typeof value !== 'string' || !value.trim()) throw new Error(`${path} is required.`); } function assertIntegerInRange(value: unknown, min: number, max: number, path: string): void { if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { throw new Error(`${path} must be an integer from ${min} to ${max}.`); } } function asRecord(value: unknown, path: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path} is required.`); return value as Record; } function assertOnly( value: Record, keys: readonly string[], selected: string, path: string, ): void { if (value[selected] == null) throw new Error(`${path}.${selected} is required.`); for (const key of keys) { if (key !== selected && value[key] !== undefined) throw new Error(`${path}.${key} is not allowed with ${selected}.`); } } function validateTime(value: unknown, path: string): void { const time = asRecord(value, path); assertIntegerInRange(time['hour_of_day'], 0, 23, `${path}.hour_of_day`); if (time['minute_of_hour'] !== undefined) assertIntegerInRange(time['minute_of_hour'], 0, 59, `${path}.minute_of_hour`); } function validateRoleStates(value: unknown, path: string): void { if (value === undefined) return; const allowed = [ 'ROLE_STATE_INIT', 'ROLE_STATE_HIRED', 'ROLE_STATE_ACCEPTED', 'ROLE_STATE_ACTIVE', 'ROLE_STATE_TERMINATED', ]; if (!Array.isArray(value)) throw new Error(`${path} must be an array of role states.`); value.forEach((state: unknown, index: number) => { if (typeof state !== 'string' || !allowed.includes(state)) throw new Error(`${path}[${index}] is invalid.`); }); } const scheduleKeys = { SCHEDULED_TRIGGER_FREQUENCY_TYPE_DAILY: 'daily_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKDAY_DAILY: 'weekday_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_WEEKLY: 'weekly_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_BIWEEKLY: 'bi_weekly_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY: 'monthly_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_MONTHLY_ON_DAY_OF_WEEK: 'monthly_on_day_of_week_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_ONE_TIME: 'one_time_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_QUARTERLY: 'quarterly_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_ANNUALLY: 'annual_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_CUSTOM: 'custom_schedule', SCHEDULED_TRIGGER_FREQUENCY_TYPE_CRON: 'cron_schedule', } as const; function validateSchedule(input: unknown, path: string): void { const config = asRecord(input, path); const frequency = config['frequency']; const key = typeof frequency === 'string' ? scheduleKeys[frequency as keyof typeof scheduleKeys] : undefined; if (!key) throw new Error(`${path}.frequency is invalid.`); assertNonEmptyString(config['timezone'], `${path}.timezone`); try { new Intl.DateTimeFormat('en', { timeZone: config['timezone'] as string }); } catch { throw new Error(`${path}.timezone must be an IANA timezone.`); } assertOnly(config, Object.values(scheduleKeys), key, path); validateRoleStates(config['supported_role_states'], `${path}.supported_role_states`); const schedule = asRecord(config[key], `${path}.${key}`); const p = `${path}.${key}`; if (key === 'cron_schedule') { if ( typeof schedule['cron_expression'] !== 'string' || schedule['cron_expression'].trim().split(/\s+/).length !== 5 ) { throw new Error(`${p}.cron_expression must have five fields.`); } return; } if (key === 'custom_schedule') { const customKeys = ['daily_schedule', 'weekly_schedule', 'monthly_schedule', 'annual_schedule']; const unitKey: Record = { CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_DAYS: 'daily_schedule', CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_WEEKS: 'weekly_schedule', CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MONTHS: 'monthly_schedule', CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_YEARS: 'annual_schedule', CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_HOURS: undefined, CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MINUTES: undefined, }; const unit = schedule['custom_schedule_frequency']; if (typeof unit !== 'string' || !Object.prototype.hasOwnProperty.call(unitKey, unit)) throw new Error(`${p}.custom_schedule_frequency is invalid.`); assertIntegerInRange( schedule['custom_schedule_frequency_value'], 1, 2147483647, `${p}.custom_schedule_frequency_value`, ); if (schedule['hour_of_day'] !== undefined) assertIntegerInRange(schedule['hour_of_day'], 0, 23, `${p}.hour_of_day`); if (schedule['minute_of_hour'] !== undefined) assertIntegerInRange(schedule['minute_of_hour'], 0, 59, `${p}.minute_of_hour`); const nested = unitKey[unit]; if (nested) { assertOnly(schedule, customKeys, nested, p); const inner = asRecord(schedule[nested], `${p}.${nested}`); if (schedule['hour_of_day'] !== undefined && schedule['hour_of_day'] !== inner['hour_of_day']) { throw new Error(`${p}.hour_of_day must match ${nested}.hour_of_day.`); } if ( schedule['minute_of_hour'] !== undefined && schedule['minute_of_hour'] !== (inner['minute_of_hour'] ?? 0) ) { throw new Error(`${p}.minute_of_hour must match ${nested}.minute_of_hour.`); } validateScheduleFields(inner, `${p}.${nested}`, nested); } else { for (const other of customKeys) if (schedule[other] !== undefined) throw new Error(`${p}.${other} is not allowed.`); if ( unit === 'CUSTOM_SCHEDULE_TRIGGER_FREQUENCY_UNIT_MINUTES' && ((schedule['custom_schedule_frequency_value'] as number) < 15 || (schedule['custom_schedule_frequency_value'] as number) % 15 !== 0) ) { throw new Error(`${p}.custom_schedule_frequency_value must be a multiple of 15 and at least 15.`); } } return; } validateScheduleFields(schedule, p, key); } function validateScheduleFields(input: unknown, path: string, key: string): void { const schedule = asRecord(input, path); validateTime(schedule, path); if (key === 'weekly_schedule' || key === 'bi_weekly_schedule') { const days = schedule['day_of_week']; if (!Array.isArray(days) || !days.length) throw new Error(`${path}.day_of_week is required.`); days.forEach((day: unknown, i: number) => assertIntegerInRange(day, 0, 6, `${path}.day_of_week[${i}]`)); } if (key === 'monthly_on_day_of_week_schedule') { assertIntegerInRange(schedule['day_of_week'], 0, 6, `${path}.day_of_week`); assertIntegerInRange(schedule['day_of_month'], 1, 5, `${path}.day_of_month`); } else if ( ['one_time_schedule', 'monthly_schedule', 'quarterly_schedule', 'annual_schedule'].includes(key) ) { assertIntegerInRange(schedule['day_of_month'], 1, 31, `${path}.day_of_month`); } if (['one_time_schedule', 'quarterly_schedule', 'annual_schedule'].includes(key)) { assertIntegerInRange( schedule['month_of_year'], 1, key === 'quarterly_schedule' ? 3 : 12, `${path}.month_of_year`, ); } if (schedule['start_month'] !== undefined) assertIntegerInRange(schedule['start_month'], 1, 12, `${path}.start_month`); if (key === 'one_time_schedule') assertIntegerInRange(schedule['year'], 1, 9999, `${path}.year`); } function validateWorkflowTrigger(trigger: WorkflowTrigger): void { const path = 'Workflow.trigger'; const t = asRecord(trigger, path); const variants: Record = { AUTOMATION_EVENT_TYPE_BUSINESS: undefined, AUTOMATION_EVENT_TYPE_RECORD_CHANGE: 'record_change_operation_type', AUTOMATION_EVENT_TYPE_DATE_RELATIVE: 'date_relative_trigger', AUTOMATION_EVENT_TYPE_SCHEDULED: 'scheduled_trigger', AUTOMATION_EVENT_TYPE_INTEGRATION: 'integration_trigger', AUTOMATION_EVENT_TYPE_WEBHOOK: 'webhook_trigger', AUTOMATION_EVENT_TYPE_MANUAL: undefined, }; const kind = t['automation_event_type']; if (typeof kind !== 'string' || !Object.prototype.hasOwnProperty.call(variants, kind)) throw new Error(`${path}.automation_event_type is invalid.`); const ownFields = [ 'event_name', 'business_event_trigger', 'record_change_operation_type', 'date_relative_trigger', 'scheduled_trigger', 'integration_trigger', 'webhook_trigger', ]; const selected = kind === 'AUTOMATION_EVENT_TYPE_BUSINESS' ? ['event_name', 'business_event_trigger'] : variants[kind] ? [variants[kind]] : []; for (const field of ownFields) { if (!selected.includes(field) && t[field] !== undefined) throw new Error(`${path}.${field} is not allowed with ${kind}.`); } if (kind === 'AUTOMATION_EVENT_TYPE_BUSINESS') assertNonEmptyString(t['event_name'], `${path}.event_name`); else if (variants[kind] && t[variants[kind]] == null) throw new Error(`${path}.${variants[kind]} is required.`); if (t['execution_rate_limit'] !== undefined) { assertIntegerInRange( asRecord(t['execution_rate_limit'], `${path}.execution_rate_limit`)['execution_limit'], 100000, 200000, `${path}.execution_rate_limit.execution_limit`, ); } if (kind === 'AUTOMATION_EVENT_TYPE_DATE_RELATIVE') { const d = asRecord(t['date_relative_trigger'], `${path}.date_relative_trigger`); const p = `${path}.date_relative_trigger`; assertNonEmptyString(t['base_model'], `${path}.base_model`); validateTime(d, p); validateRoleStates(d['supported_role_states'], `${p}.supported_role_states`); const field = d['base_model_date_field']; const rql = d['base_model_date_field_rql_path']; if (field === undefined && rql === undefined) throw new Error(`${p}.base_model_date_field or ${p}.base_model_date_field_rql_path is required.`); if (field !== undefined && rql !== undefined) throw new Error(`${p}.base_model_date_field_rql_path conflicts with base_model_date_field.`); if (field !== undefined && field !== 'start_date' && field !== 'end_date') throw new Error(`${p}.base_model_date_field is invalid.`); if (rql !== undefined) assertNonEmptyString(rql, `${p}.base_model_date_field_rql_path`); const operator = d['date_range_operator']; const between = operator === 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_BETWEEN'; if ( operator !== undefined && ![ 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_EQ', 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_WITHIN', 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_BETWEEN', ].includes(operator as string) ) throw new Error(`${p}.date_range_operator is invalid.`); if (!Array.isArray(d['operands']) || d['operands'].length !== (between ? 2 : 1)) throw new Error(`${p}.operands must contain exactly ${between ? 2 : 1} operand${between ? 's' : ''}.`); const days = d['operands'].map((operand: unknown, i: number) => { const value = asRecord( asRecord(operand, `${p}.operands[${i}]`)['time_delta'], `${p}.operands[${i}].time_delta`, )['days']; assertIntegerInRange(value, -2147483648, 2147483647, `${p}.operands[${i}].time_delta.days`); return value as number; }); if (between && days[0]! * days[1]! < 0) throw new Error(`${p}.operands must have the same sign.`); if ( d['trigger_on_anniversary'] && operator !== undefined && operator !== 'DATE_RELATIVE_TRIGGER_OPERATOR_TYPE_EQ' ) throw new Error(`${p}.trigger_on_anniversary requires EQ.`); if (d['trigger_on_anniversary'] && d['should_trigger_exactly_once_per_trigger_object']) throw new Error( `${p}.should_trigger_exactly_once_per_trigger_object conflicts with trigger_on_anniversary.`, ); } if (kind === 'AUTOMATION_EVENT_TYPE_SCHEDULED') validateSchedule(t['scheduled_trigger'], `${path}.scheduled_trigger`); if (kind === 'AUTOMATION_EVENT_TYPE_INTEGRATION') { const integration = asRecord(t['integration_trigger'], `${path}.integration_trigger`); const p = `${path}.integration_trigger`; const selectedPayload = integration['provider'] === 'INTEGRATION_TRIGGER_PROVIDER_SLACK' ? 'slack_trigger' : integration['provider'] === 'INTEGRATION_TRIGGER_PROVIDER_EMAIL' ? 'email_trigger' : undefined; if (!selectedPayload) throw new Error(`${p}.provider is invalid.`); assertOnly(integration, ['slack_trigger', 'email_trigger'], selectedPayload, p); const payload = asRecord(integration[selectedPayload], `${p}.${selectedPayload}`); const q = `${p}.${selectedPayload}`; if (selectedPayload === 'email_trigger') assertNonEmptyString(payload['recipient_email'], `${q}.recipient_email`); else { if ( !['SLACK_TRIGGER_EVENT_TYPE_MESSAGE_POSTED', 'SLACK_TRIGGER_EVENT_TYPE_REACTION_ADDED'].includes( payload['event_type'] as string, ) ) throw new Error(`${q}.event_type is required.`); const channels = payload['channel_ids']; if (!Array.isArray(channels) || !channels.length) throw new Error(`${q}.channel_ids is required.`); channels.forEach((channel: unknown, i: number) => assertNonEmptyString(channel, `${q}.channel_ids[${i}]`), ); if (typeof payload['message_text_regex'] === 'string' && payload['message_text_regex'].length > 512) throw new Error(`${q}.message_text_regex must be at most 512 characters.`); if ( payload['event_type'] === 'SLACK_TRIGGER_EVENT_TYPE_MESSAGE_POSTED' && payload['reaction_names'] !== undefined ) throw new Error(`${q}.reaction_names is not allowed for MESSAGE_POSTED.`); if (payload['event_type'] === 'SLACK_TRIGGER_EVENT_TYPE_REACTION_ADDED') { if (payload['message_text_regex'] !== undefined) throw new Error(`${q}.message_text_regex is not allowed for REACTION_ADDED.`); if (payload['include_thread_replies'] === true) throw new Error(`${q}.include_thread_replies is not allowed for REACTION_ADDED.`); if (payload['has_attachment'] === true) throw new Error(`${q}.has_attachment is not allowed for REACTION_ADDED.`); if (payload['reaction_names'] !== undefined) { if (!Array.isArray(payload['reaction_names'])) throw new Error(`${q}.reaction_names must be an array.`); payload['reaction_names'].forEach((name: unknown, i: number) => { assertNonEmptyString(name, `${q}.reaction_names[${i}]`); if ((name as string).includes(':')) throw new Error(`${q}.reaction_names[${i}] must not contain colons.`); }); } } } } } function validateWorkflowGraph(graph: WorkflowGraph, path: string): void { if (!graph || !graph.nodes || typeof graph.nodes !== 'object' || Array.isArray(graph.nodes)) throw new Error(`${path}.nodes is required.`); const nodes = graph.nodes; const ids = Object.keys(nodes); if (!ids.length && graph.root_node_id === '') return; assertNonEmptyString(graph.root_node_id, `${path}.root_node_id`); if (!Object.prototype.hasOwnProperty.call(nodes, graph.root_node_id)) throw new Error(`${path}.root_node_id must reference a node.`); const visited = new Set(); const active = new Set(); const visit = (id: string): void => { if (active.has(id)) throw new Error(`${path}.nodes.${id} contains a cycle.`); if (visited.has(id)) return; active.add(id); const node = nodes[id]; if (!node) throw new Error(`${path}.nodes.${id} is required.`); const p = `${path}.nodes.${id}`; assertNonEmptyString(id, `${path}.nodes key`); assertNonEmptyString(node?.config_id, `${p}.config_id`); const logic = (['fork', 'loop', 'group', 'switch'] as const).filter((key) => node[key] !== undefined); if (logic.length > 1) throw new Error(`${p}.${logic[1]} conflicts with ${logic[0]}.`); if ( node.processing_context?.function_context !== undefined && node.processing_context?.digest_context !== undefined ) { throw new Error(`${p}.processing_context contains conflicting contexts.`); } const edge = (next: unknown, edgePath: string): void => { assertNonEmptyString(next, edgePath); if (typeof next !== 'string' || !Object.prototype.hasOwnProperty.call(nodes, next)) throw new Error(`${edgePath} must reference a node.`); visit(next); }; if (node.next !== undefined) edge(node.next, `${p}.next`); if (node.fork !== undefined) { const fork = asRecord(node.fork, `${p}.fork`); if (!Array.isArray(fork['branches'])) throw new Error(`${p}.fork.branches is required.`); fork['branches'].forEach((branch: unknown, i: number) => edge(branch, `${p}.fork.branches[${i}]`)); } if (node.loop !== undefined) edge(asRecord(node.loop, `${p}.loop`)['inner_node'], `${p}.loop.inner_node`); if (node.group !== undefined) edge(asRecord(node.group, `${p}.group`)['inner_node'], `${p}.group.inner_node`); if (node.switch !== undefined) { const sw = asRecord(node.switch, `${p}.switch`); if (!Array.isArray(sw['branches'])) throw new Error(`${p}.switch.branches is required.`); sw['branches'].forEach((branch: unknown, i: number) => { const choice = asRecord(branch, `${p}.switch.branches[${i}]`); if (choice['node_id'] !== undefined) edge(choice['node_id'], `${p}.switch.branches[${i}].node_id`); }); if (sw['default_node'] !== undefined) edge(sw['default_node'], `${p}.switch.default_node`); } active.delete(id); visited.add(id); }; visit(graph.root_node_id); for (const id of ids) if (!visited.has(id)) throw new Error(`${path}.nodes.${id} is unreachable from root_node_id.`); } /** Defines a workflow using a trigger, function shorthand, or raw graph. */ export class Workflow { static readonly componentType = 'WORKFLOW_DEFINITION' as const; static toDeletionIdentifier(identifier: WorkflowDeletionIdentifier | string): ManifestComponentDeletion { const definitionId = typeof identifier === 'string' ? identifier : identifier.definitionId; return { type: Workflow.componentType, definition_id: definitionId, }; } private readonly _definitionId: string; private readonly _title: string; private readonly _description: string | undefined; private readonly _functionApiName: string | undefined; private readonly _trigger: WorkflowTrigger; private readonly _props: WorkflowProps; constructor(scope: ManifestScope, props: WorkflowProps) { this._definitionId = props.definitionId ?? generateId('workflow'); this._title = props.title; this._description = props.description; this._functionApiName = typeof props.function === 'string' ? props.function : props.function?.getApiName(); this._trigger = props.trigger; this._props = props; this._validate(); scope._register(this); } static loadFromExisting(definitionId: string, options: ExistingManifestContextOptions = {}): Workflow { return getExistingManifestContext(options).loadWorkflow(definitionId); } /** @internal Hydrates a workflow from existing manifest wire JSON. */ static _fromExistingComponent(scope: ManifestScope, component: Record): Workflow { const workflowMeta = requireObjectComponentField(component, 'workflow_meta', Workflow.componentType); const definitionId = requireStringComponentField(component, 'definition_id', Workflow.componentType); const props: WorkflowProps = { definitionId, title: requireStringComponentField( { definition_id: definitionId, name: workflowMeta['name'] }, 'name', Workflow.componentType, ), trigger: cloneJson( requireObjectComponentField(component, 'trigger', Workflow.componentType), ) as WorkflowTrigger, }; if (typeof workflowMeta['description'] === 'string') props.description = workflowMeta['description']; if (typeof workflowMeta['recipe_id'] === 'string') props.recipeId = workflowMeta['recipe_id']; if (typeof component['function_api_name'] === 'string') { props.function = component['function_api_name']; } if (typeof component['version_status'] === 'string') { props.versionStatus = component['version_status'] as WorkflowVersionStatus; } if (typeof component['trigger_status'] === 'string') { props.triggerStatus = component['trigger_status'] as WorkflowTriggerStatus; } if (component['graph'] != null) { props.graph = cloneJson(component['graph']) as WorkflowGraph; } if (component['functions_inferred_graph'] != null) { props.functionsInferredGraph = cloneJson(component['functions_inferred_graph']) as WorkflowGraph; } if (component['custom_variables'] != null) { props.customVariables = cloneJson(component['custom_variables']) as Record< string, WorkflowCustomVariable >; } if (component['function_api_names_by_old_id'] != null) { props.functionApiNamesByOldId = cloneJson(component['function_api_names_by_old_id']) as Record< string, string >; } if (component['categories'] != null) { props.categories = cloneJson(component['categories']) as WorkflowCategory[]; } return new Workflow(scope, props); } private _validate(): void { assertNonEmptyString(this._title, 'Workflow.workflow_meta.name'); if (this._props.function !== undefined) assertNonEmptyString(this._functionApiName, 'Workflow.function_api_name'); validateWorkflowTrigger(this._trigger); if (this._props.graph !== undefined) validateWorkflowGraph(this._props.graph, 'Workflow.graph'); if (this._props.functionsInferredGraph !== undefined) validateWorkflowGraph(this._props.functionsInferredGraph, 'Workflow.functions_inferred_graph'); for (const [id, variable] of Object.entries(this._props.customVariables ?? {})) { assertNonEmptyString(id, 'Workflow.custom_variables key'); assertNonEmptyString(variable?.name, `Workflow.custom_variables.${id}.name`); } for (const [id, apiName] of Object.entries(this._props.functionApiNamesByOldId ?? {})) { assertNonEmptyString(id, 'Workflow.function_api_names_by_old_id key'); assertNonEmptyString(apiName, `Workflow.function_api_names_by_old_id.${id}`); } if (this._functionApiName !== undefined && this._props.graph !== undefined) { const graph = this._props.graph; const root = graph.nodes[graph.root_node_id]; const context = root?.processing_context?.['function_context']; if ( Object.keys(graph.nodes).length !== 1 || root?.config_id !== 'RunFunction' || root.next !== undefined || !context || typeof context !== 'object' || !('function_api_name' in context) || context.function_api_name !== this._functionApiName ) { throw new Error('Workflow.graph conflicts with function_api_name shorthand.'); } } if ( (this._props.versionStatus ?? 'VERSION_STATUS_DRAFT') === 'VERSION_STATUS_DRAFT' && (this._props.triggerStatus ?? 'TRIGGER_STATUS_INACTIVE') !== 'TRIGGER_STATUS_INACTIVE' ) { throw new Error('Workflow.trigger_status must be inactive for a draft workflow.'); } } getDefinitionId(): string { return this._definitionId; } toDict(): Record { this._validate(); const workflowMeta: Record = { name: this._title }; if (this._description !== undefined) workflowMeta['description'] = this._description; if (this._props.recipeId !== undefined) workflowMeta['recipe_id'] = this._props.recipeId; const component: Record = { type: Workflow.componentType, definition_id: this._definitionId, version_status: this._props.versionStatus ?? 'VERSION_STATUS_DRAFT', trigger_status: this._props.triggerStatus ?? 'TRIGGER_STATUS_INACTIVE', trigger: this._trigger, workflow_meta: workflowMeta, }; if (this._functionApiName !== undefined) component['function_api_name'] = this._functionApiName; if (this._props.graph !== undefined) component['graph'] = this._props.graph; if (this._props.functionsInferredGraph !== undefined) component['functions_inferred_graph'] = this._props.functionsInferredGraph; if (this._props.customVariables !== undefined) component['custom_variables'] = this._props.customVariables; if (this._props.functionApiNamesByOldId !== undefined) component['function_api_names_by_old_id'] = this._props.functionApiNamesByOldId; if (this._props.categories !== undefined) component['categories'] = this._props.categories; return component; } }