Skip to main content

Step Output Schema Computation

workflow step 설정과 object metadata를 variable picker가 읽을 수 있는 output schema로 변환하는 패턴이다.

핵심은 frontend-computable schemas are generated locally, persisted schemas are reused다.

1. 모듈 코드

  • src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts: step type별로 output schema 생성기를 선택한다.
  • src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts: workflow version의 trigger/steps schema를 atom family에 populate하고 recompute flag를 관리한다.
  • src/modules/workflow/hooks/useComputeStepOutputSchema.ts: server 계산이 필요한 schema를 GraphQL mutation으로 요청하는 얇은 hook이다.
  • src/modules/workflow/graphql/mutations/computeStepOutputSchema.ts: compute mutation document를 정의한다.
// filepath: src/modules/workflow/workflow-variables/utils/generate/computeStepOutputSchema.ts
const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'AI_AGENT',
'CODE',
'HTTP_REQUEST',
'LOGIC_FUNCTION',
'WEBHOOK',
'ITERATOR',
];

export const computeStepOutputSchema = ({
step,
objectMetadataItems,
}: {
step: WorkflowTrigger | WorkflowAction;
objectMetadataItems: EnrichedObjectMetadataItem[];
}): OutputSchemaV2 | undefined => {
const stepType = step.type;

if (PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType)) {
return undefined;
}

switch (stepType) {
case 'DATABASE_EVENT': {
const parsed = parseEventName(step.settings?.eventName);
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
parsed.objectName,
);

return generateRecordEventOutputSchema(objectMetadataItem, parsed.action);
}

case 'MANUAL': {
const availability = step.settings?.availability;

if (availability.type === 'GLOBAL') {
return {
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}

const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
availability.objectNameSingular,
);

if (availability.type === 'SINGLE_RECORD') {
return {
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: {
isLeaf: false,
icon: objectMetadataItem.icon ?? undefined,
label: WORKFLOW_TRIGGER_RECORD_LABEL,
value: generateRecordOutputSchema(objectMetadataItem),
},
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}

return {
[WORKFLOW_TRIGGER_PAYLOAD_KEY]: {
isLeaf: false,
type: 'object',
label: WORKFLOW_TRIGGER_RECORDS_LABEL,
value: {
[objectMetadataItem.namePlural]: {
isLeaf: true,
label: objectMetadataItem.labelPlural,
type: 'array',
value: `Array of ${objectMetadataItem.labelPlural}`,
},
},
},
[WORKFLOW_TRIGGER_METADATA_KEY]: buildManualTriggerMetadataNode(),
};
}

case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD':
case 'UPSERT_RECORD':
case 'PICK_RECORD': {
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
step.settings?.input?.objectName,
);

return generateRecordOutputSchema(objectMetadataItem);
}

case 'FIND_RECORDS': {
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
step.settings?.input?.objectName,
);

return generateFindRecordsOutputSchema(objectMetadataItem);
}

case 'FORM': {
return generateFormOutputSchema(
step.settings?.input as WorkflowFormActionField[],
objectMetadataItems,
);
}

case 'SEND_EMAIL':
return {
success: { isLeaf: true, type: FieldMetadataType.BOOLEAN, label: 'Success', value: true },
headerMessageId: { isLeaf: true, type: FieldMetadataType.TEXT, label: 'Message-ID header', value: '' },
messageId: { isLeaf: true, type: FieldMetadataType.TEXT, label: 'Message record ID', value: '' },
messageThreadId: { isLeaf: true, type: FieldMetadataType.TEXT, label: 'Message thread ID', value: '' },
};

case 'DRAFT_EMAIL':
case 'CREATE_CALENDAR_EVENT':
case 'CRON':
case 'FILTER':
case 'DELAY':
case 'EMPTY':
default:
return {};
}
};

export const shouldComputeOutputSchemaOnFrontend = (
stepType: string,
): boolean => {
return !PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType);
};

// filepath: src/modules/workflow/workflow-variables/hooks/useStepsOutputSchema.ts
export const useStepsOutputSchema = () => {
const store = useStore();

const populateStepsOutputSchema = useCallback(
(workflowVersion: WorkflowVersion) => {
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);

workflowVersion.steps?.forEach((step) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
step.id,
);

if (!store.get(shouldRecomputeOutputSchemaFamilyState.atomFamily(stepKey))) {
return;
}

const outputSchema = shouldComputeOutputSchemaOnFrontend(step.type)
? computeStepOutputSchema({ step, objectMetadataItems })
: resolvePersistedStepOutputSchema({
stepType: step.type,
settings: step.settings,
});

store.set(stepsOutputSchemaFamilyState.atomFamily(stepKey), {
id: step.id,
name: step.name,
type: step.type,
icon: getActionIcon(step.type),
outputSchema: (outputSchema ?? {}) as OutputSchemaV2,
objectName: (step.settings?.input as { objectName?: string })?.objectName,
});
store.set(shouldRecomputeOutputSchemaFamilyState.atomFamily(stepKey), false);
});
},
[store],
);

const markStepForRecomputation = useCallback(
({ stepId, workflowVersionId }: { stepId: string; workflowVersionId: string }) => {
const stepKey = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
store.set(shouldRecomputeOutputSchemaFamilyState.atomFamily(stepKey), true);
},
[store],
);

const deleteStepsOutputSchema = useCallback(
({ stepIds, workflowVersionId }: { stepIds: string[]; workflowVersionId: string }) => {
stepIds.forEach((stepId) => {
const stepKey = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
store.set(stepsOutputSchemaFamilyState.atomFamily(stepKey), null);
store.set(shouldRecomputeOutputSchemaFamilyState.atomFamily(stepKey), true);
});
},
[store],
);

return {
populateStepsOutputSchema,
markStepForRecomputation,
deleteStepsOutputSchema,
};
};

// filepath: src/modules/workflow/hooks/useComputeStepOutputSchema.ts
export const useComputeStepOutputSchema = () => {
const apolloCoreClient = useApolloCoreClient();
const [mutate] = useMutation(COMPUTE_STEP_OUTPUT_SCHEMA, {
client: apolloCoreClient,
});

const computeStepOutputSchema = async (
input: ComputeStepOutputSchemaInput,
) => {
return await mutate({ variables: { input } });
};

return { computeStepOutputSchema };
};

// filepath: src/modules/workflow/graphql/mutations/computeStepOutputSchema.ts
export const COMPUTE_STEP_OUTPUT_SCHEMA = gql`
mutation ComputeStepOutputSchema($input: ComputeStepOutputSchemaInput!) {
computeStepOutputSchema(input: $input)
}
`;

2. 사용 예제

// filepath: src/modules/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect.tsx
const { populateStepsOutputSchema } = useStepsOutputSchema();

useEffect(() => {
if (!isDefined(workflowVersion) || !isDefined(workflowRun?.state)) {
return;
}

populateStepsOutputSchema({
...workflowVersion,
trigger: workflowRun.state.flow.trigger,
steps: workflowRun.state.flow.steps,
});
}, [populateStepsOutputSchema, workflowRun?.state, workflowVersion]);

// filepath: src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts
const { markStepForRecomputation } = useStepsOutputSchema();

const updateWorkflowVersionStep = async (
input: UpdateWorkflowVersionStepInput,
) => {
const result = await mutate({ variables: { input } });
const updatedStep = result?.data?.updateWorkflowVersionStep;

if (!isDefined(updatedStep)) {
return;
}

markStepForRecomputation({
stepId: updatedStep.id,
workflowVersionId: input.workflowVersionId,
});
};