diff --git a/apps/docs/content/docs/integrations/greenhouse.mdx b/apps/docs/content/docs/integrations/greenhouse.mdx index b72085f4def..93d259a2f84 100644 --- a/apps/docs/content/docs/integrations/greenhouse.mdx +++ b/apps/docs/content/docs/integrations/greenhouse.mdx @@ -27,6 +27,8 @@ Harvest v3 returns applications, attachments, education, and employment through List operations return one page, its `count`, `nextCursor`, and `hasMore`. Set a page size from 1 to 500 and pass `nextCursor` into the next call until `hasMore` is false. A cursor carries the initial query, so other filters and page size are ignored while it is set. Date filters accept a JSON object such as `{"gte":"2026-01-01T00:00:00Z"}`; use either `created_at` or `updated_at`, not both. +For list operations, Job IDs uses comma-separated IDs. For prospect application creation, Job IDs uses a JSON array of integers. Created At takes a JSON date range on list operations, an ISO 8601 timestamp when creating an application, and a `YYYY-MM-DD` date when updating an application. + Partial reads leave unrequested fields as `null`, including collections. An explicit empty array means the collection is empty. Date-only filters, such as offer `sent_on` and `starts_on`, use `YYYY-MM-DD` bounds. When updating candidate contact arrays, tags, or linked users, read the current candidate first and submit the complete desired array: supplied arrays replace their existing values. Unset inputs are omitted; custom-field entries may use `null` to clear a value. Creating an email note records an email in Greenhouse and does not send one. Application movement may trigger Greenhouse stage automation, and rejection can optionally send a rejection email. Move, hire, reject, and unreject operations return no provider fields on success. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 0b5a92b1103..2362569678e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -23,6 +23,7 @@ interface ToolSubBlockRendererProps { /** The tool's block type (e.g. `gmail`), so its params' selectors resolve dependencies. */ toolType: string toolParams: Record | undefined + operation?: string onParamChange: (toolIndex: number, paramId: string, value: string) => void disabled: boolean canonicalToggle?: { @@ -44,6 +45,7 @@ export function ToolSubBlockRenderer({ effectiveParamId, toolType, toolParams, + operation, onParamChange, disabled, canonicalToggle, @@ -128,7 +130,7 @@ export function ToolSubBlockRenderer({ isPreview={false} disabled={disabled} canonicalToggle={canonicalToggle} - dependencyContext={toolParams} + dependencyContext={{ ...toolParams, operation }} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts index 77e44462cf0..5751d69b74c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts @@ -1,5 +1,8 @@ import { getTransitiveSubBlockDependents } from '@/lib/workflows/subblocks/dependencies' +import type { StoredTool } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' +import type { ToolMetadata } from '@/tools/metadata' +import { isUserFacingToolParam } from '@/tools/params' /** * Clear every TRANSITIVE `dependsOn` descendant of `changedParamId` in a nested tool's params, @@ -23,3 +26,43 @@ export function clearDependentToolParams( } return next ?? params } + +/** Keeps user-configured inputs that the newly selected operation accepts. */ +export function getParamsForToolOperation( + tool: Pick, + nextTool: ToolMetadata +): Record { + const newParamIds = new Set( + Object.entries(nextTool.params ?? {}) + .filter(([, param]) => isUserFacingToolParam(param)) + .map(([paramId]) => paramId) + ) + const subBlocks = getBlock(tool.type)?.subBlocks ?? [] + if (nextTool.oauth) { + const credentialControls = subBlocks.filter( + (subBlock) => + subBlock.type === 'oauth-input' && subBlock.serviceId === nextTool.oauth?.provider + ) + const credentialCanonicalIds = new Set( + credentialControls.flatMap((subBlock) => + subBlock.canonicalParamId ? [subBlock.canonicalParamId] : [] + ) + ) + for (const subBlock of subBlocks) { + if ( + credentialControls.includes(subBlock) || + (subBlock.canonicalParamId && + credentialCanonicalIds.has(subBlock.canonicalParamId) && + (!subBlock.serviceId || subBlock.serviceId === nextTool.oauth.provider)) + ) { + newParamIds.add(subBlock.id) + if (subBlock.canonicalParamId) newParamIds.add(subBlock.canonicalParamId) + } + } + } + const preservedParams: Record = {} + Object.entries(tool.params || {}).forEach(([paramId, value]) => { + if (newParamIds.has(paramId) && value) preservedParams[paramId] = value + }) + return clearDependentToolParams(tool.type, preservedParams, 'operation') +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 63effdd697d..bd584693029 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -59,7 +59,10 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' import { ToolUsageControl } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control' -import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' +import { + clearDependentToolParams, + getParamsForToolOperation, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' import { isAgentToolBlock, @@ -111,7 +114,6 @@ import { formatParameterLabel, getSubBlocksForToolInput, getToolIdForOperation, - isUserFacingToolParam, type SubBlocksForToolInput, } from '@/tools/params' @@ -979,18 +981,7 @@ export const ToolInput = memo(function ToolInput({ return } - const newParamIds = new Set( - Object.entries(newToolConfig.params ?? {}) - .filter(([, param]) => isUserFacingToolParam(param)) - .map(([paramId]) => paramId) - ) - - const preservedParams: Record = {} - Object.entries(tool.params || {}).forEach(([paramId, value]) => { - if (newParamIds.has(paramId) && value) { - preservedParams[paramId] = value - } - }) + const preservedParams = getParamsForToolOperation(tool, newToolConfig) if (tool.type === 'jira') { const subBlockStore = useSubBlockStore.getState() @@ -1529,9 +1520,18 @@ export const ToolInput = memo(function ToolInput({ ? (toolBlocks.find((block) => block.type === tool.type) ?? getBlock(tool.type)) : null + const hasOperations = + !isCustomTool && !isMcpFamily && hasMultipleOperations(toolBlock ?? undefined) + const operationChoices = hasOperations + ? getOperationChoices(toolBlock ?? undefined) + : null + const selectedOperation = + tool.operation || + operationChoices?.options.find((option) => !operationChoices.denied.has(option.id))?.id + const currentToolId = !isCustomTool && !isMcpFamily - ? getToolIdForOperation(tool.type, tool.operation, toolBlock ?? undefined) || + ? getToolIdForOperation(tool.type, selectedOperation, toolBlock ?? undefined) || tool.toolId || '' : tool.toolId || '' @@ -1555,8 +1555,8 @@ export const ToolInput = memo(function ToolInput({ currentToolId, tool.type, { - operation: tool.operation, ...tool.params, + operation: selectedOperation, }, toolScopedOverrides, toolBlock ?? undefined @@ -1609,8 +1609,6 @@ export const ToolInput = memo(function ToolInput({ toolCredential?.type === sb.reactiveCondition.requiredType ) - const hasOperations = - !isCustomTool && !isMcpFamily && hasMultipleOperations(toolBlock ?? undefined) const showToolControl = supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) const hasToolBody = showToolControl || hasOperations || displaySubBlocks.length > 0 @@ -1869,10 +1867,8 @@ export const ToolInput = memo(function ToolInput({ )} {/* Operation dropdown for tools with multiple operations */} {(() => { - if (!hasOperations) return null - const { options: operationOptions, denied } = getOperationChoices( - toolBlock ?? undefined - ) + if (!operationChoices) return null + const { options: operationOptions, denied } = operationChoices if (operationOptions.length === 0) return null return ( @@ -1884,10 +1880,7 @@ export const ToolInput = memo(function ToolInput({ value: option.id, hidden: denied.has(option.id), }))} - value={ - tool.operation || - operationOptions.find((option) => !denied.has(option.id))?.id - } + value={selectedOperation} onChange={(value) => handleOperationChange(toolIndex, value)} placeholder='Select operation' /* Denied operations only drop out once the config @@ -1912,7 +1905,7 @@ export const ToolInput = memo(function ToolInput({ canonicalGroup && hasCanonicalPair ? resolveCanonicalMode( canonicalGroup, - { operation: tool.operation, ...tool.params }, + { ...tool.params, operation: selectedOperation }, toolScopedOverrides ) : undefined @@ -1949,6 +1942,7 @@ export const ToolInput = memo(function ToolInput({ effectiveParamId={effectiveParamId} toolType={tool.type} toolParams={tool.params} + operation={selectedOperation} onParamChange={handleParamChange} disabled={disabled} canonicalToggle={canonicalToggleProp} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 247af54d085..9c7b014571a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -11,6 +11,7 @@ import { import { isEqual } from 'es-toolkit' import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' import type { FallbackModelEntry } from '@/lib/workflows/blocks/fallback-models' +import { buildSubBlockValues } from '@/lib/workflows/subblocks/visibility' import { useWorkflowScope } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider' import { CheckboxList, @@ -121,10 +122,10 @@ interface SubBlockProps { * Supports boolean, condition objects, and functions that return conditions. * * @param config - The sub-block configuration containing requirement rules - * @param subBlockValues - Current values of all subblocks for condition evaluation + * @param values - Flat sibling values for condition evaluation * @returns `true` if the field is required based on current context */ -const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record): boolean => { +const isFieldRequired = (config: SubBlockConfig, values?: Record): boolean => { if (!config.required) return false if (typeof config.required === 'boolean') return config.required @@ -141,7 +142,7 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record ): boolean => { - const fieldValue = values[cond.field]?.value + const fieldValue = values[cond.field] const condValue = cond.value let match: boolean @@ -154,7 +155,7 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record, + requiredValues?: Record, wandState?: { isSearchActive: boolean searchQuery: string @@ -246,7 +248,7 @@ const renderLabel = ( if (config.type === 'switch') return null if (!config.title) return null - const required = isFieldRequired(config, subBlockValues) + const required = isFieldRequired(config, requiredValues) const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview const showCopy = copyState?.showCopyButton && !wandState?.isPreview @@ -617,6 +619,12 @@ function SubBlockComponent({ | null | undefined + const requiredValues = + !isPreview && dependencyContext + ? dependencyContext + : subBlockValues + ? buildSubBlockValues(subBlockValues) + : undefined const contextValues = dependencyContext ?? (isPreview ? subBlockValues : undefined) const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, { @@ -956,7 +964,7 @@ function SubBlockComponent({ blockId={blockId} subBlock={config} disabled={isDisabled} - required={isFieldRequired(config, subBlockValues)} + required={isFieldRequired(config, requiredValues)} isPreview={isPreview} previewValue={previewValue} /> @@ -1248,7 +1256,7 @@ function SubBlockComponent({ {renderLabel( config, isValidJson, - subBlockValues, + requiredValues, { isSearchActive, searchQuery, diff --git a/apps/sim/blocks/blocks/greenhouse.ts b/apps/sim/blocks/blocks/greenhouse.ts index 33b64b28dd6..582e93139f1 100644 --- a/apps/sim/blocks/blocks/greenhouse.ts +++ b/apps/sim/blocks/blocks/greenhouse.ts @@ -547,48 +547,6 @@ export const GreenhouseBlockMeta = { ], } as const satisfies BlockMeta -const GREENHOUSE_V2_PARAM_REMAPS: Record> = { - list_application_stages: { created_at: 'created_at_filter' }, - list_applications: { created_at: 'created_at_filter', status: 'applications_status' }, - list_applied_candidate_tags: { created_at: 'created_at_filter' }, - list_attachments: { created_at: 'created_at_filter', type: 'attachments_type' }, - list_candidate_educations: { created_at: 'created_at_filter' }, - list_candidate_employments: { created_at: 'created_at_filter' }, - list_candidate_tags: { created_at: 'created_at_filter' }, - list_candidates: { created_at: 'created_at_filter' }, - list_close_reasons: { created_at: 'created_at_filter' }, - list_custom_field_options: { created_at: 'created_at_filter' }, - list_custom_fields: { created_at: 'created_at_filter' }, - list_departments: { created_at: 'created_at_filter' }, - list_email_templates: { created_at: 'created_at_filter' }, - list_interviews: { created_at: 'created_at_filter', status: 'interviews_status' }, - list_job_hiring_managers: { created_at: 'created_at_filter' }, - list_job_interview_stages: { created_at: 'created_at_filter' }, - list_job_owners: { created_at: 'created_at_filter', type: 'job_owners_type' }, - list_jobs: { created_at: 'created_at_filter', status: 'jobs_status' }, - list_notes: { created_at: 'created_at_filter', visibility: 'notes_visibility' }, - list_offers: { created_at: 'created_at_filter', status: 'offers_status' }, - list_offices: { created_at: 'created_at_filter' }, - list_openings: { created_at: 'created_at_filter' }, - list_prospect_pool_stages: { created_at: 'created_at_filter' }, - list_prospect_pools: { created_at: 'created_at_filter' }, - list_referrers: { created_at: 'created_at_filter' }, - list_rejection_reasons: { created_at: 'created_at_filter' }, - list_scorecards: { created_at: 'created_at_filter', status: 'scorecards_status' }, - list_sources: { created_at: 'created_at_filter' }, - list_users: { created_at: 'created_at_filter' }, - create_attachment: { type: 'attachments_type', visibility: 'attachment_visibility' }, - update_application: { id: 'update_application_id', created_at: 'application_created_on' }, - update_candidate: { id: 'update_application_id' }, - convert_prospect: { id: 'update_application_id', job_id: 'convert_prospect_job_id' }, - hire_application: { id: 'update_application_id', opening_id: 'hire_application_opening_id' }, - move_application: { id: 'update_application_id' }, - reject_application: { id: 'update_application_id' }, - unreject_application: { id: 'update_application_id' }, - create_application: { job_ids: 'create_application_job_ids' }, - create_note: { visibility: 'attachment_visibility' }, -} - export const GreenhouseV2Block: BlockConfig = { type: 'greenhouse_v2', name: 'Greenhouse', @@ -656,28 +614,14 @@ export const GreenhouseV2Block: BlockConfig = { list_sources: ['List sources'], list_users: ['List users'], get_user: [{ text: 'Get user', field: 'id', core: true }], - update_application: [ - { text: 'Update application', field: 'update_application_id', core: true }, - ], - update_candidate: [ - { text: 'Update candidate', field: 'update_application_id', core: true }, - ], + update_application: [{ text: 'Update application', field: 'id', core: true }], + update_candidate: [{ text: 'Update candidate', field: 'id', core: true }], create_application: ['Create application'], - convert_prospect: [ - { text: 'Convert prospect', field: 'update_application_id', core: true }, - ], - hire_application: [ - { text: 'Hire application', field: 'update_application_id', core: true }, - ], - move_application: [ - { text: 'Move application', field: 'update_application_id', core: true }, - ], - reject_application: [ - { text: 'Reject application', field: 'update_application_id', core: true }, - ], - unreject_application: [ - { text: 'Unreject application', field: 'update_application_id', core: true }, - ], + convert_prospect: [{ text: 'Convert prospect', field: 'id', core: true }], + hire_application: [{ text: 'Hire application', field: 'id', core: true }], + move_application: [{ text: 'Move application', field: 'id', core: true }], + reject_application: [{ text: 'Reject application', field: 'id', core: true }], + unreject_application: [{ text: 'Unreject application', field: 'id', core: true }], apply_candidate_tag: ['Apply candidate tag'], create_attachment: ['Create attachment'], create_candidate_tag: ['Create candidate tag'], @@ -757,6 +701,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'id', + dependsOn: ['operation'], title: 'Resource ID', type: 'short-input', placeholder: 'Enter resource ID', @@ -771,6 +716,13 @@ export const GreenhouseV2Block: BlockConfig = { 'get_candidate', 'get_job', 'get_user', + 'update_application', + 'update_candidate', + 'convert_prospect', + 'hire_application', + 'move_application', + 'reject_application', + 'unreject_application', ], }, required: { @@ -784,11 +736,19 @@ export const GreenhouseV2Block: BlockConfig = { 'get_candidate', 'get_job', 'get_user', + 'update_application', + 'update_candidate', + 'convert_prospect', + 'hire_application', + 'move_application', + 'reject_application', + 'unreject_application', ], }, }, { id: 'cursor', + dependsOn: ['operation'], title: 'Cursor', type: 'short-input', placeholder: 'Enter cursor', @@ -873,6 +833,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'ids', + dependsOn: ['operation'], title: 'Ids', type: 'short-input', placeholder: '123,456', @@ -919,13 +880,15 @@ export const GreenhouseV2Block: BlockConfig = { mode: 'advanced', }, { - id: 'created_at_filter', - title: 'Created At Filter', - type: 'code', - placeholder: '{"gte":"2026-01-01T00:00:00Z"}', + id: 'created_at', + dependsOn: ['operation'], + title: 'Created At', + type: 'long-input', + placeholder: 'Date, timestamp, or JSON range for the selected operation', condition: { field: 'operation', value: [ + 'create_application', 'list_application_stages', 'list_applications', 'list_applied_candidate_tags', @@ -955,17 +918,20 @@ export const GreenhouseV2Block: BlockConfig = { 'list_scorecards', 'list_sources', 'list_users', + 'update_application', ], }, required: false, - language: 'json', wandConfig: { enabled: true, + generationType: 'timestamp', prompt: - 'Created at filter as JSON with gte, lte, gt, lt keys and ISO 8601 values; cannot combine with updated_at, for example {"gte":"2026-01-01T00:00:00Z"}. Return ONLY the JSON.', - placeholder: 'Describe the created at', + 'For list operations, return ONLY a JSON object with gte/lte/gt/lt keys and ISO 8601 timestamps. For Create Application, return ONLY an ISO 8601 timestamp. For Update Application, return ONLY a YYYY-MM-DD date. Use the requested operation.', + placeholder: 'Describe the date range or date and operation', }, mode: 'advanced', + description: + 'Lists: JSON range using gte, lte, gt, lt with ISO 8601 timestamps; cannot combine with Updated At. Create Application: ISO 8601 timestamp. Update Application: YYYY-MM-DD date.', }, { id: 'updated_at', @@ -1059,6 +1025,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'fields', + dependsOn: ['operation'], title: 'Fields', type: 'short-input', placeholder: 'id,name', @@ -1146,9 +1113,10 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'job_ids', + dependsOn: ['operation'], title: 'Job IDs', - type: 'short-input', - placeholder: '123,456', + type: 'long-input', + placeholder: 'List filter: 123,456; prospect creation: [123,456]', condition: { field: 'operation', value: [ @@ -1160,16 +1128,19 @@ export const GreenhouseV2Block: BlockConfig = { 'list_offers', 'list_openings', 'list_prospect_pools', + 'create_application', ], }, required: false, wandConfig: { enabled: true, prompt: - 'Job IDs Comma-separated values (maximum 50). Return ONLY the comma-separated list.', - placeholder: 'Describe the values to include', + 'For list operations, return ONLY comma-separated job IDs (maximum 50). For Create Application with Prospect enabled, return ONLY a JSON array of integer job IDs.', + placeholder: 'Describe the job IDs and operation', }, mode: 'advanced', + description: + 'For list operations, comma-separated IDs (maximum 50). For prospect application creation, a JSON array of integer job IDs.', }, { id: 'prospective_job_ids', @@ -1247,19 +1218,89 @@ export const GreenhouseV2Block: BlockConfig = { mode: 'advanced', }, { - id: 'applications_status', + id: 'status', + dependsOn: ['operation'], title: 'Status', type: 'dropdown', - condition: { field: 'operation', value: ['list_applications'] }, + condition: { + field: 'operation', + value: [ + 'list_applications', + 'list_interviews', + 'list_jobs', + 'list_offers', + 'list_scorecards', + ], + }, required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Rejected', id: 'rejected' }, - { label: 'Hired', id: 'hired' }, - { label: 'Converted', id: 'converted' }, - { label: 'Active', id: 'active' }, - ], - mode: 'advanced', + options: (params) => { + const options: Record = { + list_applications: [ + { label: 'Not set', id: '' }, + { label: 'Rejected', id: 'rejected' }, + { label: 'Hired', id: 'hired' }, + { label: 'Converted', id: 'converted' }, + { label: 'Active', id: 'active' }, + ], + list_interviews: [ + { label: 'Not set', id: '' }, + { label: 'To Be Scheduled', id: 'to_be_scheduled' }, + { label: 'Scheduled', id: 'scheduled' }, + { label: 'Awaiting Feedback', id: 'awaiting_feedback' }, + { label: 'Complete', id: 'complete' }, + { label: 'Skipped', id: 'skipped' }, + { label: 'Collect Feedback', id: 'collect_feedback' }, + { label: 'To Be Sent', id: 'to_be_sent' }, + { label: 'Sent', id: 'sent' }, + { label: 'Received', id: 'received' }, + ], + list_jobs: [ + { label: 'Not set', id: '' }, + { label: 'Open', id: 'open' }, + { label: 'Draft', id: 'draft' }, + { label: 'Closed', id: 'closed' }, + ], + list_offers: [ + { label: 'Not set', id: '' }, + { label: 'Created', id: 'Created' }, + { label: 'Accepted', id: 'Accepted' }, + { label: 'Rejected', id: 'Rejected' }, + { label: 'Deprecated', id: 'Deprecated' }, + ], + list_scorecards: [ + { label: 'Not set', id: '' }, + { label: 'Draft', id: 'draft' }, + { label: 'Complete', id: 'complete' }, + ], + } + return ( + options[String(params?.values.operation)] ?? [ + { label: 'Not set', id: '' }, + { label: 'Rejected', id: 'rejected' }, + { label: 'Hired', id: 'hired' }, + { label: 'Converted', id: 'converted' }, + { label: 'Active', id: 'active' }, + { label: 'To Be Scheduled', id: 'to_be_scheduled' }, + { label: 'Scheduled', id: 'scheduled' }, + { label: 'Awaiting Feedback', id: 'awaiting_feedback' }, + { label: 'Complete', id: 'complete' }, + { label: 'Skipped', id: 'skipped' }, + { label: 'Collect Feedback', id: 'collect_feedback' }, + { label: 'To Be Sent', id: 'to_be_sent' }, + { label: 'Sent', id: 'sent' }, + { label: 'Received', id: 'received' }, + { label: 'Open', id: 'open' }, + { label: 'Draft', id: 'draft' }, + { label: 'Closed', id: 'closed' }, + { label: 'Created', id: 'Created' }, + { label: 'Accepted', id: 'Accepted' }, + { label: 'Rejected', id: 'Rejected' }, + { label: 'Deprecated', id: 'Deprecated' }, + ] + ) + }, + mode: 'advanced', + emptyIsValid: true, }, { id: 'stage_name', @@ -1333,23 +1374,91 @@ export const GreenhouseV2Block: BlockConfig = { mode: 'advanced', }, { - id: 'attachments_type', + id: 'type', + dependsOn: ['operation'], title: 'Type', - type: 'dropdown', - condition: { field: 'operation', value: ['list_attachments', 'create_attachment'] }, - required: { field: 'operation', value: ['create_attachment'] }, - options: [ - { label: 'Resume', id: 'resume' }, - { label: 'Cover Letter', id: 'cover_letter' }, - { label: 'Take Home Test', id: 'take_home_test' }, - { label: 'Offer Packet', id: 'offer_packet' }, - { label: 'Offer Letter', id: 'offer_letter' }, - { label: 'Signed Offer Letter', id: 'signed_offer_letter' }, - { label: 'Other', id: 'other' }, - { label: 'Form Attachment', id: 'form_attachment' }, - { label: 'Midfunnel Agreement', id: 'midfunnel_agreement' }, - { label: 'Automated Agreement', id: 'automated_agreement' }, - ], + type: 'combobox', + placeholder: 'Select a type, or enter comma-separated note types', + condition: { + field: 'operation', + value: ['list_notes', 'list_attachments', 'create_attachment', 'list_job_owners'], + }, + required: { + field: 'operation', + value: 'create_attachment', + }, + options: (params) => { + const options: Record = { + list_notes: [ + { label: 'Not set', id: '' }, + { label: 'Note', id: 'NOTE' }, + { label: 'Activity', id: 'ACTIVITY' }, + { label: 'Interview', id: 'INTERVIEW' }, + { label: 'Email', id: 'EMAIL' }, + { label: 'Follow Up', id: 'FOLLOW_UP' }, + { label: 'Take Home Test', id: 'TAKE_HOME_TEST' }, + { label: 'Linkedin Note', id: 'LINKEDIN_NOTE' }, + { label: 'Linkedin Inmail', id: 'LINKEDIN_INMAIL' }, + { label: 'Availability Request', id: 'AVAILABILITY_REQUEST' }, + { label: 'Migration Error', id: 'MIGRATION_ERROR' }, + { label: 'Touchpoint', id: 'TOUCHPOINT' }, + { label: 'Form', id: 'FORM' }, + { label: 'Feedback', id: 'FEEDBACK' }, + ], + list_attachments: [ + { label: 'Resume', id: 'resume' }, + { label: 'Cover Letter', id: 'cover_letter' }, + { label: 'Take Home Test', id: 'take_home_test' }, + { label: 'Offer Packet', id: 'offer_packet' }, + { label: 'Offer Letter', id: 'offer_letter' }, + { label: 'Signed Offer Letter', id: 'signed_offer_letter' }, + { label: 'Other', id: 'other' }, + { label: 'Form Attachment', id: 'form_attachment' }, + { label: 'Midfunnel Agreement', id: 'midfunnel_agreement' }, + { label: 'Automated Agreement', id: 'automated_agreement' }, + ], + create_attachment: [ + { label: 'Resume', id: 'resume' }, + { label: 'Cover Letter', id: 'cover_letter' }, + { label: 'Take Home Test', id: 'take_home_test' }, + { label: 'Offer Packet', id: 'offer_packet' }, + { label: 'Offer Letter', id: 'offer_letter' }, + { label: 'Signed Offer Letter', id: 'signed_offer_letter' }, + { label: 'Other', id: 'other' }, + { label: 'Form Attachment', id: 'form_attachment' }, + { label: 'Midfunnel Agreement', id: 'midfunnel_agreement' }, + { label: 'Automated Agreement', id: 'automated_agreement' }, + ], + list_job_owners: [ + { label: 'Not set', id: '' }, + { label: 'Sourcer', id: 'sourcer' }, + { label: 'Recruiter', id: 'recruiter' }, + { label: 'Coordinator', id: 'coordinator' }, + ], + } + return ( + options[String(params?.values.operation)] ?? [ + ...options.list_notes, + { label: 'Resume', id: 'resume' }, + { label: 'Cover Letter', id: 'cover_letter' }, + { label: 'Take Home Test', id: 'take_home_test' }, + { label: 'Offer Packet', id: 'offer_packet' }, + { label: 'Offer Letter', id: 'offer_letter' }, + { label: 'Signed Offer Letter', id: 'signed_offer_letter' }, + { label: 'Other', id: 'other' }, + { label: 'Form Attachment', id: 'form_attachment' }, + { label: 'Midfunnel Agreement', id: 'midfunnel_agreement' }, + { label: 'Automated Agreement', id: 'automated_agreement' }, + { label: 'Not set', id: '' }, + { label: 'Sourcer', id: 'sourcer' }, + { label: 'Recruiter', id: 'recruiter' }, + { label: 'Coordinator', id: 'coordinator' }, + ] + ) + }, + emptyIsValid: true, + description: + 'Attachment type for attachment operations; recruiter, sourcer, or coordinator for job owners; comma-separated note types for List Notes.', }, { id: 'start_at', @@ -1498,6 +1607,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'name', + dependsOn: ['operation'], title: 'Name', type: 'short-input', placeholder: 'Enter name', @@ -1515,6 +1625,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'parent_id', + dependsOn: ['operation'], title: 'Parent ID', type: 'short-input', placeholder: 'Enter numeric parent id', @@ -1524,6 +1635,7 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'external_id', + dependsOn: ['operation'], title: 'External ID', type: 'short-input', placeholder: 'Enter external id', @@ -1719,26 +1831,6 @@ export const GreenhouseV2Block: BlockConfig = { required: false, mode: 'advanced', }, - { - id: 'interviews_status', - title: 'Status', - type: 'dropdown', - condition: { field: 'operation', value: ['list_interviews'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'To Be Scheduled', id: 'to_be_scheduled' }, - { label: 'Scheduled', id: 'scheduled' }, - { label: 'Awaiting Feedback', id: 'awaiting_feedback' }, - { label: 'Complete', id: 'complete' }, - { label: 'Skipped', id: 'skipped' }, - { label: 'Collect Feedback', id: 'collect_feedback' }, - { label: 'To Be Sent', id: 'to_be_sent' }, - { label: 'Sent', id: 'sent' }, - { label: 'Received', id: 'received' }, - ], - mode: 'advanced', - }, { id: 'user_ids', title: 'User IDs', @@ -1757,20 +1849,6 @@ export const GreenhouseV2Block: BlockConfig = { }, mode: 'advanced', }, - { - id: 'job_owners_type', - title: 'Type', - type: 'dropdown', - condition: { field: 'operation', value: ['list_job_owners'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Sourcer', id: 'sourcer' }, - { label: 'Recruiter', id: 'recruiter' }, - { label: 'Coordinator', id: 'coordinator' }, - ], - mode: 'advanced', - }, { id: 'opened_at', title: 'Opened At', @@ -1844,46 +1922,52 @@ export const GreenhouseV2Block: BlockConfig = { mode: 'advanced', }, { - id: 'jobs_status', - title: 'Status', - type: 'dropdown', - condition: { field: 'operation', value: ['list_jobs'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Open', id: 'open' }, - { label: 'Draft', id: 'draft' }, - { label: 'Closed', id: 'closed' }, - ], - mode: 'advanced', - }, - { - id: 'type', - title: 'Type', - type: 'short-input', - placeholder: '123,456', - condition: { field: 'operation', value: ['list_notes'] }, - required: false, - wandConfig: { - enabled: true, - prompt: 'Type Comma-separated values (maximum 50). Return ONLY the comma-separated list.', - placeholder: 'Describe the values to include', - }, - mode: 'advanced', - }, - { - id: 'notes_visibility', + id: 'visibility', + dependsOn: ['operation'], title: 'Visibility', type: 'dropdown', - condition: { field: 'operation', value: ['list_notes'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Admin Only Visible', id: 'admin_only_visible' }, - { label: 'Privately Visible', id: 'privately_visible' }, - { label: 'Publicly Visible', id: 'publicly_visible' }, - ], - mode: 'advanced', + condition: { + field: 'operation', + value: ['list_notes', 'create_attachment', 'create_note'], + }, + required: { + field: 'operation', + value: 'create_note', + }, + options: (params) => { + const options: Record = { + list_notes: [ + { label: 'Not set', id: '' }, + { label: 'Admin Only Visible', id: 'admin_only_visible' }, + { label: 'Privately Visible', id: 'privately_visible' }, + { label: 'Publicly Visible', id: 'publicly_visible' }, + ], + create_attachment: [ + { label: 'Admin Only', id: 'admin_only' }, + { label: 'Private', id: 'private' }, + { label: 'Public', id: 'public' }, + ], + create_note: [ + { label: 'Admin Only', id: 'admin_only' }, + { label: 'Private', id: 'private' }, + { label: 'Public', id: 'public' }, + ], + } + return ( + options[String(params?.values.operation)] ?? [ + { label: 'Not set', id: '' }, + { label: 'Admin Only Visible', id: 'admin_only_visible' }, + { label: 'Privately Visible', id: 'privately_visible' }, + { label: 'Publicly Visible', id: 'publicly_visible' }, + { label: 'Admin Only', id: 'admin_only' }, + { label: 'Private', id: 'private' }, + { label: 'Public', id: 'public' }, + ] + ) + }, + emptyIsValid: false, + description: + 'List Notes uses admin_only_visible, privately_visible, or publicly_visible. Create Note and Create Attachment use admin_only, private, or public.', }, { id: 'opening_ids', @@ -1913,21 +1997,6 @@ export const GreenhouseV2Block: BlockConfig = { ], mode: 'advanced', }, - { - id: 'offers_status', - title: 'Status', - type: 'dropdown', - condition: { field: 'operation', value: ['list_offers'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Created', id: 'Created' }, - { label: 'Accepted', id: 'Accepted' }, - { label: 'Rejected', id: 'Rejected' }, - { label: 'Deprecated', id: 'Deprecated' }, - ], - mode: 'advanced', - }, { id: 'resolved_at', title: 'Resolved At', @@ -2006,12 +2075,18 @@ export const GreenhouseV2Block: BlockConfig = { }, { id: 'opening_id', + dependsOn: ['operation'], title: 'Opening ID', type: 'short-input', placeholder: 'Enter opening id', - condition: { field: 'operation', value: ['list_openings'] }, + condition: { + field: 'operation', + value: ['list_openings', 'hire_application'], + }, required: false, mode: 'advanced', + description: + 'List Openings: external opening ID. Hire Application: numeric internal opening ID returned by List Openings.', }, { id: 'prospect_pool_ids', @@ -2148,19 +2223,6 @@ export const GreenhouseV2Block: BlockConfig = { }, mode: 'advanced', }, - { - id: 'scorecards_status', - title: 'Status', - type: 'dropdown', - condition: { field: 'operation', value: ['list_scorecards'] }, - required: false, - options: [ - { label: 'Not set', id: '' }, - { label: 'Draft', id: 'draft' }, - { label: 'Complete', id: 'complete' }, - ], - mode: 'advanced', - }, { id: 'agency_ids', title: 'Agency IDs', @@ -2274,36 +2336,6 @@ export const GreenhouseV2Block: BlockConfig = { ], mode: 'advanced', }, - { - id: 'update_application_id', - title: 'Resource ID', - type: 'short-input', - placeholder: 'Enter numeric id', - condition: { - field: 'operation', - value: [ - 'update_application', - 'update_candidate', - 'convert_prospect', - 'hire_application', - 'move_application', - 'reject_application', - 'unreject_application', - ], - }, - required: { - field: 'operation', - value: [ - 'update_application', - 'update_candidate', - 'convert_prospect', - 'hire_application', - 'move_application', - 'reject_application', - 'unreject_application', - ], - }, - }, { id: 'source_id', title: 'Source ID', @@ -2373,38 +2405,9 @@ export const GreenhouseV2Block: BlockConfig = { }, mode: 'advanced', }, - { - id: 'application_created_on', - title: 'Application Creation Date', - type: 'short-input', - placeholder: '2026-01-01', - condition: { field: 'operation', value: ['update_application'] }, - required: false, - wandConfig: { - enabled: true, - generationType: 'timestamp', - prompt: 'Generate an ISO 8601 date (YYYY-MM-DD). Return ONLY the formatted date.', - placeholder: 'Describe the date', - }, - mode: 'advanced', - }, - { - id: 'created_at', - title: 'Created At', - type: 'short-input', - placeholder: '2026-01-01T00:00:00Z', - condition: { field: 'operation', value: ['create_application'] }, - required: false, - wandConfig: { - enabled: true, - generationType: 'timestamp', - prompt: 'Generate an ISO 8601 timestamp. Return ONLY the formatted timestamp.', - placeholder: 'Describe the date', - }, - mode: 'advanced', - }, { id: 'custom_fields', + dependsOn: ['operation'], title: 'Custom Fields', type: 'code', placeholder: '[]', @@ -2791,20 +2794,20 @@ export const GreenhouseV2Block: BlockConfig = { title: 'Job ID', type: 'short-input', placeholder: 'Enter numeric job ID', - condition: { field: 'operation', value: 'create_application' }, - required: { + condition: { field: 'operation', - value: 'create_application', - and: { field: 'prospect', value: ['true', true], not: true }, + value: ['create_application', 'convert_prospect'], }, - }, - { - id: 'convert_prospect_job_id', - title: 'Job ID', - type: 'short-input', - placeholder: 'Enter numeric job ID', - condition: { field: 'operation', value: 'convert_prospect' }, - required: true, + required: (values) => ({ + field: 'operation', + value: + values?.operation === 'convert_prospect' || + (values !== undefined && values.prospect !== true && values.prospect !== 'true') + ? ['create_application', 'convert_prospect'] + : ['convert_prospect'], + }), + description: + 'For application creation, required unless Prospect is true. For conversion, select a job already associated with the prospect or its pool.', }, { id: 'initial_stage_id', @@ -2815,21 +2818,6 @@ export const GreenhouseV2Block: BlockConfig = { required: false, mode: 'advanced', }, - { - id: 'create_application_job_ids', - title: 'Job IDs', - type: 'code', - placeholder: '[]', - condition: { field: 'operation', value: ['create_application'] }, - required: false, - language: 'json', - wandConfig: { - enabled: true, - prompt: 'Prospect job IDs as a JSON array of integers. Return ONLY the JSON.', - placeholder: 'Describe the job ids', - }, - mode: 'advanced', - }, { id: 'prospect_pool_stage_id', title: 'Prospect Pool Stage ID', @@ -2890,15 +2878,6 @@ export const GreenhouseV2Block: BlockConfig = { }, mode: 'advanced', }, - { - id: 'hire_application_opening_id', - title: 'Opening ID', - type: 'short-input', - placeholder: 'Enter numeric opening id', - condition: { field: 'operation', value: ['hire_application'] }, - required: false, - mode: 'advanced', - }, { id: 'close_reason_id', title: 'Close Reason ID', @@ -3018,18 +2997,6 @@ export const GreenhouseV2Block: BlockConfig = { required: false, mode: 'advanced', }, - { - id: 'attachment_visibility', - title: 'Visibility', - type: 'dropdown', - condition: { field: 'operation', value: ['create_attachment', 'create_note'] }, - required: { field: 'operation', value: ['create_note'] }, - options: [ - { label: 'Admin Only', id: 'admin_only' }, - { label: 'Private', id: 'private' }, - { label: 'Public', id: 'public' }, - ], - }, { id: 'application', title: 'Application', @@ -3215,7 +3182,6 @@ export const GreenhouseV2Block: BlockConfig = { 'parent_id', 'department_id', 'office_id', - 'update_application_id', 'source_id', 'referrer_id', 'recruiter_id', @@ -3224,14 +3190,12 @@ export const GreenhouseV2Block: BlockConfig = { 'prospect_stage_id', 'candidate_id', 'job_id', - 'convert_prospect_job_id', 'initial_stage_id', 'prospect_pool_stage_id', 'prospect_owner_id', 'prospective_department_id', 'prospective_office_id', 'to_job_interview_stage_id', - 'hire_application_opening_id', 'close_reason_id', 'from_stage_id', 'to_stage_id', @@ -3264,9 +3228,6 @@ export const GreenhouseV2Block: BlockConfig = { else if (params[key] === 'false' || params[key] === false) result[key] = false else result[key] = undefined } - const remaps = GREENHOUSE_V2_PARAM_REMAPS[String(params.operation)] ?? {} - for (const [target, source] of Object.entries(remaps)) - result[target] = params[source] === '' ? undefined : (result[source] ?? params[source]) return result }, }, @@ -3274,7 +3235,10 @@ export const GreenhouseV2Block: BlockConfig = { inputs: { operation: { type: 'string', description: 'Harvest v3 operation' }, oauthCredential: { type: 'string', description: 'Connected Greenhouse OAuth credential' }, - id: { type: 'string', description: 'Greenhouse resource ID' }, + id: { + type: 'string', + description: 'Greenhouse resource ID', + }, cursor: { type: 'string', description: @@ -3285,10 +3249,10 @@ export const GreenhouseV2Block: BlockConfig = { description: 'Page size from 1 to 500 (default 100). Each call retrieves one page.', }, ids: { type: 'string', description: 'Ids Comma-separated values (maximum 50).' }, - created_at_filter: { + created_at: { type: 'json', description: - 'Created at filter as JSON with gte, lte, gt, lt keys and ISO 8601 values; cannot combine with updated_at, for example {"gte":"2026-01-01T00:00:00Z"}.', + 'Lists: JSON range using gte, lte, gt, lt with ISO 8601 timestamps; cannot combine with Updated At. Create Application: ISO 8601 timestamp. Update Application: YYYY-MM-DD date.', }, updated_at: { type: 'json', @@ -3313,7 +3277,11 @@ export const GreenhouseV2Block: BlockConfig = { type: 'string', description: 'Candidate IDs Comma-separated values (maximum 50).', }, - job_ids: { type: 'string', description: 'Job IDs Comma-separated values (maximum 50).' }, + job_ids: { + type: 'string', + description: + 'For list operations, comma-separated IDs (maximum 50). For prospect application creation, a JSON array of integer job IDs.', + }, prospective_job_ids: { type: 'string', description: 'Prospective job IDs Comma-separated values (maximum 50).', @@ -3328,9 +3296,10 @@ export const GreenhouseV2Block: BlockConfig = { description: 'Referrer IDs Comma-separated values (maximum 50).', }, stage_ids: { type: 'string', description: 'Stage IDs Comma-separated values (maximum 50).' }, - applications_status: { + status: { type: 'string', - description: 'Status Allowed: rejected, hired, converted, active.', + description: + 'Status filter for applications, interviews, jobs, offers, or scorecards. Use the values documented for the selected operation; offer statuses are case-sensitive.', }, stage_name: { type: 'string', description: 'Stage name' }, custom_field_option_id: { type: 'number', description: 'Custom field option ID' }, @@ -3348,10 +3317,10 @@ export const GreenhouseV2Block: BlockConfig = { type: 'string', description: 'Candidate tag IDs Comma-separated values (maximum 50).', }, - attachments_type: { + type: { type: 'string', description: - 'Type Allowed: resume, cover_letter, take_home_test, offer_packet, offer_letter, signed_offer_letter, other, form_attachment, midfunnel_agreement, automated_agreement.', + 'Attachment type for attachment operations; recruiter, sourcer, or coordinator for job owners; comma-separated note types for List Notes.', }, start_at: { type: 'json', @@ -3421,16 +3390,7 @@ export const GreenhouseV2Block: BlockConfig = { 'All day end on filter as JSON with gte, lte, gt, lt keys and YYYY-MM-DD dates, for example {"gte":"2026-01-01"}.', }, external_event_id: { type: 'string', description: 'External event ID' }, - interviews_status: { - type: 'string', - description: - 'Status Allowed: to_be_scheduled, scheduled, awaiting_feedback, complete, skipped, collect_feedback, to_be_sent, sent, received.', - }, user_ids: { type: 'string', description: 'User IDs Comma-separated values (maximum 50).' }, - job_owners_type: { - type: 'string', - description: 'Type Allowed: sourcer, recruiter, coordinator.', - }, opened_at: { type: 'json', description: @@ -3445,21 +3405,16 @@ export const GreenhouseV2Block: BlockConfig = { department_id: { type: 'number', description: 'Department ID' }, office_id: { type: 'number', description: 'Office ID' }, confidential: { type: 'boolean', description: 'Confidential' }, - jobs_status: { type: 'string', description: 'Status Allowed: open, draft, closed.' }, - type: { type: 'string', description: 'Type Comma-separated values (maximum 50).' }, - notes_visibility: { + visibility: { type: 'string', - description: 'Visibility Allowed: admin_only_visible, privately_visible, publicly_visible.', + description: + 'List Notes uses admin_only_visible, privately_visible, or publicly_visible. Create Note and Create Attachment use admin_only, private, or public.', }, opening_ids: { type: 'string', description: 'Opening IDs Comma-separated values (maximum 50).', }, current_only: { type: 'boolean', description: 'Current only' }, - offers_status: { - type: 'string', - description: 'Status Allowed: Created, Accepted, Rejected, Deprecated.', - }, resolved_at: { type: 'json', description: @@ -3482,7 +3437,8 @@ export const GreenhouseV2Block: BlockConfig = { open: { type: 'boolean', description: 'Open' }, opening_id: { type: 'string', - description: 'External opening ID to filter openings.', + description: + 'List Openings: external opening ID. Hire Application: numeric internal opening ID returned by List Openings.', }, prospect_pool_ids: { type: 'string', @@ -3516,7 +3472,6 @@ export const GreenhouseV2Block: BlockConfig = { description: 'Submitted at filter as JSON with gte, lte, gt, lt keys and ISO 8601 values, for example {"gte":"2026-01-01T00:00:00Z"}.', }, - scorecards_status: { type: 'string', description: 'Status Allowed: draft, complete.' }, agency_ids: { type: 'string', description: 'Agency IDs Comma-separated values (maximum 50).' }, linked_candidate_ids: { type: 'string', @@ -3535,7 +3490,6 @@ export const GreenhouseV2Block: BlockConfig = { external_office_id: { type: 'string', description: 'External office ID' }, external_department_id: { type: 'string', description: 'External department ID' }, show_service_accounts: { type: 'boolean', description: 'Show service accounts' }, - update_application_id: { type: 'number', description: 'Greenhouse resource ID' }, source_id: { type: 'number', description: 'Source ID' }, referrer_id: { type: 'number', description: 'Referrer ID' }, recruiter_id: { type: 'number', description: 'Recruiter ID' }, @@ -3543,14 +3497,6 @@ export const GreenhouseV2Block: BlockConfig = { prospect_pool_id: { type: 'number', description: 'Prospect pool ID' }, prospect_stage_id: { type: 'number', description: 'Prospect stage ID' }, rejected_at: { type: 'string', description: 'Rejected at (YYYY-MM-DD)' }, - created_at: { - type: 'string', - description: 'Application creation timestamp in ISO 8601 format', - }, - application_created_on: { - type: 'string', - description: 'Application creation date (YYYY-MM-DD)', - }, custom_fields: { type: 'array', description: @@ -3612,26 +3558,18 @@ export const GreenhouseV2Block: BlockConfig = { type: 'number', description: 'Candidate ID. Required for every note and application creation.', }, - job_id: { type: 'number', description: 'Job ID' }, - convert_prospect_job_id: { + job_id: { type: 'number', - description: 'Destination job ID for converting the prospect application', + description: + 'For application creation, required unless Prospect is true. For conversion, select a job already associated with the prospect or its pool.', }, initial_stage_id: { type: 'number', description: 'Initial stage ID' }, - create_application_job_ids: { - type: 'array', - description: 'Prospect job IDs as a JSON array of integers.', - }, prospect_pool_stage_id: { type: 'number', description: 'Prospect pool stage ID' }, prospect_owner_id: { type: 'number', description: 'Prospect owner ID' }, prospective_department_id: { type: 'number', description: 'Prospective department ID' }, prospective_office_id: { type: 'number', description: 'Prospective office ID' }, to_job_interview_stage_id: { type: 'number', description: 'To job interview stage ID' }, start_date: { type: 'string', description: 'Start date (ISO 8601)' }, - hire_application_opening_id: { - type: 'number', - description: 'Opening ID; required when hiring into a job with multiple open openings.', - }, close_reason_id: { type: 'number', description: 'Close reason ID' }, from_stage_id: { type: 'number', @@ -3667,10 +3605,6 @@ export const GreenhouseV2Block: BlockConfig = { description: 'Public attachment URL for Greenhouse to fetch. Supply exactly one of url or content.', }, - attachment_visibility: { - type: 'string', - description: 'Visibility Allowed: admin_only, private, public.', - }, application: { type: 'json', description: diff --git a/apps/sim/lib/workflows/migrations/greenhouse-agent-inputs.ts b/apps/sim/lib/workflows/migrations/greenhouse-agent-inputs.ts new file mode 100644 index 00000000000..1646b2aeb30 --- /dev/null +++ b/apps/sim/lib/workflows/migrations/greenhouse-agent-inputs.ts @@ -0,0 +1,101 @@ +import { isPlainRecord, omit } from '@sim/utils/object' +import { getBlock } from '@/blocks' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const GREENHOUSE_RETIRED_INPUTS: Record> = { + list_application_stages: { created_at: 'created_at_filter' }, + list_applications: { created_at: 'created_at_filter', status: 'applications_status' }, + list_applied_candidate_tags: { created_at: 'created_at_filter' }, + list_attachments: { created_at: 'created_at_filter', type: 'attachments_type' }, + list_candidate_educations: { created_at: 'created_at_filter' }, + list_candidate_employments: { created_at: 'created_at_filter' }, + list_candidate_tags: { created_at: 'created_at_filter' }, + list_candidates: { created_at: 'created_at_filter' }, + list_close_reasons: { created_at: 'created_at_filter' }, + list_custom_field_options: { created_at: 'created_at_filter' }, + list_custom_fields: { created_at: 'created_at_filter' }, + list_departments: { created_at: 'created_at_filter' }, + list_email_templates: { created_at: 'created_at_filter' }, + list_interviews: { created_at: 'created_at_filter', status: 'interviews_status' }, + list_job_hiring_managers: { created_at: 'created_at_filter' }, + list_job_interview_stages: { created_at: 'created_at_filter' }, + list_job_owners: { created_at: 'created_at_filter', type: 'job_owners_type' }, + list_jobs: { created_at: 'created_at_filter', status: 'jobs_status' }, + list_notes: { created_at: 'created_at_filter', visibility: 'notes_visibility' }, + list_offers: { created_at: 'created_at_filter', status: 'offers_status' }, + list_offices: { created_at: 'created_at_filter' }, + list_openings: { created_at: 'created_at_filter' }, + list_prospect_pool_stages: { created_at: 'created_at_filter' }, + list_prospect_pools: { created_at: 'created_at_filter' }, + list_referrers: { created_at: 'created_at_filter' }, + list_rejection_reasons: { created_at: 'created_at_filter' }, + list_scorecards: { created_at: 'created_at_filter', status: 'scorecards_status' }, + list_sources: { created_at: 'created_at_filter' }, + list_users: { created_at: 'created_at_filter' }, + create_attachment: { type: 'attachments_type', visibility: 'attachment_visibility' }, + update_application: { id: 'update_application_id', created_at: 'application_created_on' }, + update_candidate: { id: 'update_application_id' }, + convert_prospect: { id: 'update_application_id', job_id: 'convert_prospect_job_id' }, + hire_application: { id: 'update_application_id', opening_id: 'hire_application_opening_id' }, + move_application: { id: 'update_application_id' }, + reject_application: { id: 'update_application_id' }, + unreject_application: { id: 'update_application_id' }, + create_application: { job_ids: 'create_application_job_ids' }, + create_note: { visibility: 'attachment_visibility' }, +} + +/** Operation-scoped renames shared by the workflow migration registry and normalizer. */ +export const GREENHOUSE_SUBBLOCK_ID_MIGRATIONS = Object.entries(GREENHOUSE_RETIRED_INPUTS).flatMap( + ([operation, fields]) => + Object.entries(fields).map(([to, from]) => ({ from, to, whenOperation: [operation] })) +) + +const RETIRED_IDS = [...new Set(GREENHOUSE_SUBBLOCK_ID_MIGRATIONS.map(({ from }) => from))] + +/** + * Moves the active Harvest v3 UI aliases onto their tool parameter IDs. Canonical targets + * may already hold values from unrelated operations, so the old operation's alias wins, + * including an intentional blank. Retired aliases are all removed so changing operations + * later cannot restore a stale alias over a newly configured canonical value. Incomplete + * imports retain their fields until a recognized operation determines which values apply. + */ +export function migrateGreenhouseAgentInputs(block: BlockState): BlockState { + if (block.type !== 'greenhouse_v2') return block + const config = getBlock(block.type) + const storedOperation: unknown = block.subBlocks.operation + const operation = isPlainRecord(storedOperation) ? storedOperation.value : storedOperation + const operations = config?.subBlocks.find((field) => field.id === 'operation')?.options + if ( + typeof operation !== 'string' || + !Array.isArray(operations) || + !operations.some((option) => option.id === operation) + ) + return block + const aliases = GREENHOUSE_RETIRED_INPUTS[operation] ?? {} + let subBlocks = block.subBlocks + for (const [target, source] of Object.entries(aliases)) { + if (!Object.hasOwn(subBlocks, source)) continue + const saved: unknown = subBlocks[source] + const targetConfig = config?.subBlocks.find((field) => field.id === target) + if (!targetConfig) continue + subBlocks = omit(subBlocks, [source]) + if (isPlainRecord(saved)) { + subBlocks[target] = { + ...saved, + id: target, + type: targetConfig.type, + value: (Object.hasOwn(saved, 'value') + ? saved.value + : null) as BlockState['subBlocks'][string]['value'], + } + } else { + subBlocks[target] = { + id: target, + type: targetConfig.type, + value: saved as BlockState['subBlocks'][string]['value'], + } + } + } + if (RETIRED_IDS.some((id) => id in subBlocks)) subBlocks = omit(subBlocks, RETIRED_IDS) + return subBlocks === block.subBlocks ? block : { ...block, subBlocks } +} diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 0f498d913ee..27a40e954c5 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -126,6 +126,168 @@ describe('migration targets', () => { }) describe('migrateSubblockIds', () => { + it.each([ + { operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, value: '42' }, + { operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, value: '' }, + { operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, value: null }, + { operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, value: false }, + { operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, value: 0 }, + { operation: 'convert_prospect', value: '42' }, + ])( + 'preserves bare Greenhouse input $value during import normalization', + ({ operation, value }) => { + const legacySubBlocks: Record = { + operation, + id: { id: 'id', type: 'short-input', value: 'unrelated-candidate' }, + update_application_id: value, + convert_prospect_job_id: '', + } + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: legacySubBlocks as BlockState['subBlocks'], + }) + const result = migrateSubblockIds({ 'block-1': block }) + const saved = result.blocks['block-1'].subBlocks + expect(saved.id.value).toBe(value) + expect(saved.job_id.value).toBe('') + expect(saved).not.toHaveProperty('update_application_id') + expect(migrateSubblockIds(result.blocks).migrated).toBe(false) + } + ) + + it('retains wrapped Greenhouse source metadata while replacing the canonical target', () => { + const source = { + id: 'update_application_id', + type: 'short-input' as const, + value: '42', + metadata: { source: { reference: 'candidate-lookup' } }, + } + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'update_application' }, + id: { id: 'id', type: 'short-input', value: 'unrelated-candidate' }, + update_application_id: source, + }, + }) + const result = migrateSubblockIds({ 'block-1': block }) + expect(result.blocks['block-1'].subBlocks.id).toMatchObject({ + value: '42', + metadata: source.metadata, + }) + expect(block.subBlocks.update_application_id).toBe(source) + }) + + it('preserves a bare Greenhouse array over a stale list filter during import', () => { + const legacySubBlocks: Record = { + operation: { id: 'operation', type: 'dropdown', value: 'create_application' }, + job_ids: { id: 'job_ids', type: 'short-input', value: '99' }, + create_application_job_ids: [42, 81], + } + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: legacySubBlocks as BlockState['subBlocks'], + }) + const result = migrateSubblockIds({ 'block-1': block }) + expect(result.blocks['block-1'].subBlocks.job_ids.value).toEqual([42, 81]) + expect(result.blocks['block-1'].subBlocks).not.toHaveProperty('create_application_job_ids') + }) + + it.each(['42', ''])( + 'preserves the active Greenhouse alias %j over an unrelated stored target', + (value) => { + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'convert_prospect' }, + id: { id: 'id', type: 'short-input', value: 'stale-candidate' }, + update_application_id: { id: 'update_application_id', type: 'short-input', value }, + job_id: { id: 'job_id', type: 'short-input', value: '123' }, + convert_prospect_job_id: { + id: 'convert_prospect_job_id', + type: 'short-input', + value: '', + }, + created_at_filter: { + id: 'created_at_filter', + type: 'code', + value: '{"gte":"2026-01-01"}', + }, + }, + }) + const result = migrateSubblockIds({ 'block-1': block }) + const saved = result.blocks['block-1'].subBlocks + expect(saved.id.value).toBe(value) + expect(saved.job_id.value).toBe('') + expect(saved).not.toHaveProperty('update_application_id') + expect(saved).not.toHaveProperty('convert_prospect_job_id') + expect(saved).not.toHaveProperty('created_at_filter') + expect(block.subBlocks.id.value).toBe('stale-candidate') + expect(migrateSubblockIds(result.blocks).migrated).toBe(false) + } + ) + + it.each([undefined, 'unrecognized_operation'])( + 'preserves incomplete Greenhouse imports with operation %j', + (operation) => { + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: { + ...(operation + ? { operation: { id: 'operation', type: 'dropdown' as const, value: operation } } + : {}), + update_application_id: { id: 'update_application_id', type: 'short-input', value: '42' }, + created_at_filter: { + id: 'created_at_filter', + type: 'code', + value: '{"gte":"2026-01-01"}', + }, + }, + }) + const result = migrateSubblockIds({ 'block-1': block }) + expect(result.migrated).toBe(false) + expect(result.blocks['block-1']).toBe(block) + } + ) + + it('drops inactive Greenhouse aliases before a later operation switch can overwrite a new value', () => { + const block = makeBlock({ + type: 'greenhouse_v2', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'create_candidate' }, + id: { id: 'id', type: 'short-input', value: null }, + update_application_id: { id: 'update_application_id', type: 'short-input', value: '' }, + }, + }) + const first = migrateSubblockIds({ 'block-1': block }) + expect(first.blocks['block-1'].subBlocks).not.toHaveProperty('update_application_id') + const edited = { + ...first.blocks['block-1'], + subBlocks: { + ...first.blocks['block-1'].subBlocks, + operation: { id: 'operation', type: 'dropdown' as const, value: 'update_application' }, + id: { id: 'id', type: 'short-input' as const, value: '123' }, + }, + } + const second = migrateSubblockIds({ 'block-1': edited }) + expect(second.blocks['block-1'].subBlocks.id.value).toBe('123') + expect(second.migrated).toBe(false) + }) + + it('leaves the legacy Greenhouse block unchanged', () => { + const block = makeBlock({ + type: 'greenhouse', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'get_candidate' }, + id: { id: 'id', type: 'short-input', value: '42' }, + update_application_id: { id: 'update_application_id', type: 'short-input', value: '99' }, + }, + }) + const result = migrateSubblockIds({ 'block-1': block }) + expect(result.migrated).toBe(false) + expect(result.blocks['block-1']).toBe(block) + }) + it('preserves MCP canonical modes through the full normalization pipeline', () => { const block = makeBlock({ type: 'mcp', diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index c78e57c4ea5..694c3184793 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' import { isPlainRecord } from '@sim/utils/object' import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks' +import { + GREENHOUSE_SUBBLOCK_ID_MIGRATIONS, + migrateGreenhouseAgentInputs, +} from '@/lib/workflows/migrations/greenhouse-agent-inputs' import { migrateMcpOperationControls } from '@/lib/workflows/migrations/mcp-operation-controls' import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks' import { @@ -117,6 +121,8 @@ export const SUBBLOCK_ID_MIGRATIONS: Record): { continue } - const normalized = migrateMcpOperationControls(block) + const normalized = migrateGreenhouseAgentInputs(migrateMcpOperationControls(block)) const migrations = SUBBLOCK_ID_MIGRATIONS[block.type] const renamed = migrations ? migrateBlockSubblockIds(block.type, normalized.subBlocks, migrations) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index d00d45ddf1f..dfc8223f782 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -11,11 +11,14 @@ vi.mock('@/lib/internal/workflows/read-tool-enrichment', () => ({ readWorkflowMetadataForTool: workflowMetadataMocks.readWorkflowMetadataForTool, })) +import { getParamsForToolOperation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' +import { GreenhouseV2Block } from '@/blocks/blocks/greenhouse' import { IntuneBlock } from '@/blocks/blocks/intune' import { MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel' import { RampBlock } from '@/blocks/blocks/ramp' import { RevenueCatBlock } from '@/blocks/blocks/revenuecat' import { VideoGeneratorV3Block } from '@/blocks/blocks/video_generator' +import * as blockRegistry from '@/blocks/registry' import { normalizeFileInput } from '@/blocks/utils' import { findProviderFromModel, @@ -43,11 +46,29 @@ import { transformBlockTool, } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' +import { greenhouseConvertProspectV2Tool } from '@/tools/greenhouse/convert_prospect_v2' +import { greenhouseCreateApplicationV2Tool } from '@/tools/greenhouse/create_application_v2' +import { greenhouseHireApplicationV2Tool } from '@/tools/greenhouse/hire_application_v2' +import { greenhouseListCandidatesV2Tool } from '@/tools/greenhouse/list_candidates_v2' +import { greenhouseListJobOwnersV2Tool } from '@/tools/greenhouse/list_job_owners_v2' +import { greenhouseListJobsV2Tool } from '@/tools/greenhouse/list_jobs_v2' +import { greenhouseListNotesV2Tool } from '@/tools/greenhouse/list_notes_v2' +import { greenhouseListOffersV2Tool } from '@/tools/greenhouse/list_offers_v2' +import type { + GreenhouseConvertProspectV2Params, + GreenhouseCreateApplicationV2Params, + GreenhouseHireApplicationV2Params, + GreenhouseListCandidatesV2Params, + GreenhouseUpdateApplicationV2Params, +} from '@/tools/greenhouse/types' +import { greenhouseUpdateApplicationV2Tool } from '@/tools/greenhouse/update_application_v2' import { intuneListDetectedAppsTool } from '@/tools/intune/list-detected-apps' import { intuneListDevicesTool } from '@/tools/intune/list-devices' +import * as toolMetadata from '@/tools/metadata' import { clearRangeTool } from '@/tools/microsoft_excel/clear_range' import { createTableTool } from '@/tools/microsoft_excel/create_table' import { sortRangeTool } from '@/tools/microsoft_excel/sort_range' +import { getSubBlocksForToolInput } from '@/tools/params' import { rampListReimbursementsTool } from '@/tools/ramp/list-reimbursements' import { rampListTransactionsTool } from '@/tools/ramp/list-transactions' import { revenuecatGetCustomerTool } from '@/tools/revenuecat/get_customer' @@ -58,6 +79,303 @@ const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require describe('integration filters through agent execution', () => { + it.each([ + { + operation: 'list_jobs', + field: 'status', + staleValue: 'active', + tool: greenhouseListJobsV2Tool, + }, + { + operation: 'list_job_owners', + field: 'type', + staleValue: 'NOTE', + tool: greenhouseListJobOwnersV2Tool, + }, + { + operation: 'list_notes', + field: 'visibility', + staleValue: 'private', + tool: greenhouseListNotesV2Tool, + }, + { + operation: 'list_candidates', + field: 'created_at', + staleValue: '2026-01-01', + tool: greenhouseListCandidatesV2Tool, + }, + ])( + 'clears incompatible $field when switching to $operation', + async ({ operation, field, staleValue, tool }) => { + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GreenhouseV2Block) + const previousParams = { + [field]: staleValue, + per_page: '25', + credential: 'fixture-credential', + } + const params = getParamsForToolOperation( + { type: 'greenhouse_v2', params: previousParams }, + tool + ) + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params }, + { + selectedOperation: operation, + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution(providerTool, {}, {}, undefined) + const url = + typeof tool.request.url === 'function' + ? tool.request.url({ ...executionParams, accessToken: 'fixture-token' }) + : tool.request.url + expect(new URL(url).searchParams.has(field)).toBe(false) + expect(new URL(url).searchParams.get('per_page')).toBe('25') + expect(params.credential).toBe('fixture-credential') + expect(previousParams[field]).toBe(staleValue) + } + ) + + it('uses the newly supplied application target and fields after leaving candidate operations', async () => { + const tool = greenhouseUpdateApplicationV2Tool + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GreenhouseV2Block) + const params = getParamsForToolOperation( + { + type: 'greenhouse_v2', + params: { + id: '42', + custom_fields: '[{"custom_field_id":7,"value":"candidate value"}]', + source_id: '81', + }, + }, + tool + ) + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params }, + { + selectedOperation: 'update_application', + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution(providerTool, { id: 99 }, {}, undefined) + const execution = { + ...executionParams, + accessToken: 'fixture-token', + } as GreenhouseUpdateApplicationV2Params + const url = + typeof tool.request.url === 'function' ? tool.request.url(execution) : tool.request.url + expect(new URL(url).pathname).toBe('/v3/applications/99') + expect(tool.request.body?.(execution)).toEqual({ source_id: 81 }) + }) + + it('does not carry a list-filter job CSV into a prospect creation', async () => { + const tool = greenhouseCreateApplicationV2Tool + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GreenhouseV2Block) + const params = getParamsForToolOperation( + { + type: 'greenhouse_v2', + params: { + job_ids: '11,12', + candidate_id: '42', + }, + }, + tool + ) + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params }, + { + selectedOperation: 'create_application', + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { prospect: true, job_ids: [81] }, + {}, + undefined + ) + expect( + tool.request.body?.({ + ...executionParams, + accessToken: 'fixture-token', + } as GreenhouseCreateApplicationV2Params) + ).toEqual({ candidate_id: 42, prospect: true, job_ids: [81] }) + }) + + it('uses a newly supplied internal opening ID after leaving opening list filters', async () => { + const tool = greenhouseHireApplicationV2Tool + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GreenhouseV2Block) + const params = getParamsForToolOperation( + { type: 'greenhouse_v2', params: { opening_id: 'external-opening' } }, + tool + ) + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params }, + { + selectedOperation: 'hire_application', + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { id: 99, opening_id: 81 }, + {}, + undefined + ) + expect( + tool.request.body?.({ + ...executionParams, + accessToken: 'fixture-token', + } as GreenhouseHireApplicationV2Params) + ).toEqual({ opening_id: 81 }) + }) + + it.each(['credential', 'oauthCredential'])( + 'preserves configured %s but not a resolved token across OAuth operations', + (credentialId) => { + vi.spyOn(blockRegistry, 'getBlock').mockReturnValue(GreenhouseV2Block) + const params = getParamsForToolOperation( + { + type: 'greenhouse_v2', + params: { + [credentialId]: 'fixture-credential', + accessToken: 'expired-resolved-token', + }, + }, + greenhouseListJobsV2Tool + ) + expect(params[credentialId]).toBe('fixture-credential') + expect(params).not.toHaveProperty('accessToken') + } + ) + + it.each([ + { operation: 'list_jobs', field: 'status', tool: greenhouseListJobsV2Tool }, + { operation: 'list_offers', field: 'status', tool: greenhouseListOffersV2Tool }, + { operation: 'list_job_owners', field: 'type', tool: greenhouseListJobOwnersV2Tool }, + { operation: 'list_notes', field: 'visibility', tool: greenhouseListNotesV2Tool }, + ])( + 'sends every offered $operation $field choice to the API', + async ({ operation, field, tool }) => { + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const control = getSubBlocksForToolInput( + tool.id, + 'greenhouse_v2', + { operation }, + undefined, + GreenhouseV2Block + )?.subBlocks.find((item) => item.id === field) + if (!control) throw new Error('Configured filter control is unavailable') + const choices = + typeof control.options === 'function' ? control.options({ values: {} }) : control.options + if (!choices?.some((choice) => choice.id)) throw new Error('Filter choices are unavailable') + + for (const choice of choices.filter((item) => item.id)) { + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params: { [control.id]: choice.id } }, + { + selectedOperation: operation, + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution(providerTool, {}, {}, undefined) + const params = { ...executionParams, accessToken: 'fixture-token' } + const url = + typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).searchParams.get(field)).toBe(choice.id) + } + } + ) + + it('sends configured Greenhouse conversion fields from the offered controls to the API', async () => { + const tool = greenhouseConvertProspectV2Tool + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const fields = getSubBlocksForToolInput( + tool.id, + 'greenhouse_v2', + { operation: 'convert_prospect' }, + undefined, + GreenhouseV2Block + )?.subBlocks + const idField = fields?.find((field) => field.id === 'id') + const jobField = fields?.find((field) => field.id === 'job_id') + if (!idField || !jobField) throw new Error('Conversion target controls are unavailable') + const providerTool = await transformBlockTool( + { type: 'greenhouse_v2', params: { [idField.id]: '42', [jobField.id]: '81' } }, + { + selectedOperation: 'convert_prospect', + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { id: 99, job_id: 98 }, + {}, + undefined + ) + const params = { + ...executionParams, + accessToken: 'fixture-token', + } as GreenhouseConvertProspectV2Params + const url = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).pathname).toBe('/v3/applications/42/convert_to_candidate') + expect(tool.request.body?.(params)).toEqual({ job_id: 81 }) + }) + + it.each(['configured', 'model'] as const)( + 'sends a %s Greenhouse date range without a stored operation', + async (source) => { + const tool = greenhouseListCandidatesV2Tool + vi.spyOn(toolMetadata, 'getToolMetadata').mockReturnValue(tool) + const field = getSubBlocksForToolInput( + tool.id, + 'greenhouse_v2', + { operation: 'list_candidates' }, + undefined, + GreenhouseV2Block + )?.subBlocks.find((item) => item.id === 'created_at') + if (!field) throw new Error('Created-at filter control is unavailable') + const range = { gte: '2026-01-01T00:00:00Z' } + const providerTool = await transformBlockTool( + { + type: 'greenhouse_v2', + params: source === 'configured' ? { [field.id]: JSON.stringify(range) } : {}, + }, + { + selectedOperation: 'list_candidates', + getAllBlocks: () => [GreenhouseV2Block], + getTool: () => tool, + } + ) + if (!providerTool) throw new Error('Greenhouse agent tool was not created') + const { executionParams } = prepareToolExecution( + providerTool, + { created_at: range }, + {}, + undefined + ) + const params = { + ...executionParams, + accessToken: 'fixture-token', + } as GreenhouseListCandidatesV2Params + const url = + typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + expect(new URL(url).searchParams.get('created_at')).toBe('gte|2026-01-01T00:00:00Z') + } + ) + const rampCases = [ { operation: 'list_transactions', diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 41fa200cc38..d7e44f100f3 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -7,6 +7,7 @@ import { } from '@sim/workflow-persistence/subflow-helpers' import { resolveBlockRetryConfig } from '@sim/workflow-types/workflow' import type { Edge } from '@xyflow/react' +import { migrateGreenhouseAgentInputs } from '@/lib/workflows/migrations/greenhouse-agent-inputs' import { migrateMcpOperationControls } from '@/lib/workflows/migrations/mcp-operation-controls' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { @@ -238,7 +239,7 @@ export class Serializer { } } - block = migrateMcpOperationControls(block) + block = migrateGreenhouseAgentInputs(migrateMcpOperationControls(block)) const blockConfig = getBlock(block.type) if (!blockConfig) { throw new Error(`Invalid block type: ${block.type}`) @@ -520,7 +521,7 @@ export function selectToolId(blockConfig: any, params: Record): str * params exactly the way execution (serializeBlock) does. */ export function extractBlockParams(block: BlockState): Record { - block = migrateMcpOperationControls(block) + block = migrateGreenhouseAgentInputs(migrateMcpOperationControls(block)) if (block.type === 'loop' || block.type === 'parallel') { return {} } diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 1729248b130..8ff780bcc11 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -900,7 +900,11 @@ export function getSubBlocksForToolInput( return { toolConfig, - subBlocks: filtered, + subBlocks: filtered.map((subBlock) => + typeof subBlock.options === 'function' + ? { ...subBlock, options: subBlock.options({ values: valuesWithOperation }) } + : subBlock + ), oauthConfig: toolConfig.oauth, } } catch (error) {