EmboFlow/apps/web/src/runtime/workflow-editor-state.ts

451 lines
13 KiB
TypeScript

export type WorkflowLogicNode = {
id: string;
type: string;
};
export type WorkflowLogicEdge = {
from: string;
to: string;
};
export type WorkflowPoint = {
x: number;
y: number;
};
export type WorkflowViewport = WorkflowPoint & {
zoom: number;
};
export type WorkflowVisualGraph = {
viewport: WorkflowViewport;
nodePositions: Record<string, WorkflowPoint>;
};
export type WorkflowNodeDefinitionSummary = {
id: string;
name: string;
category?: string;
defaultExecutorType?: "python" | "docker" | "http";
defaultExecutorConfig?: Record<string, unknown>;
allowsMultipleIncoming?: boolean;
supportsCodeHook?: boolean;
};
export type WorkflowCodeHookSpec = {
language: "python";
entrypoint?: string;
source: string;
};
export type WorkflowNodeRuntimeConfig = {
definitionId?: string;
executorType?: "python" | "docker" | "http";
executorConfig?: Record<string, unknown>;
codeHookSpec?: WorkflowCodeHookSpec;
artifactType?: "json" | "directory" | "video";
artifactTitle?: string;
};
export type WorkflowDraft = {
visualGraph: WorkflowVisualGraph;
logicGraph: {
nodes: WorkflowLogicNode[];
edges: WorkflowLogicEdge[];
};
runtimeGraph: Record<string, unknown> & {
selectedPreset?: string;
nodeBindings?: Record<string, string>;
nodeConfigs?: Record<string, WorkflowNodeRuntimeConfig>;
};
pluginRefs: string[];
};
type WorkflowVersionLike = Partial<WorkflowDraft>;
export type WorkflowConnectionValidationReason =
| "missing_source"
| "missing_target"
| "self"
| "duplicate"
| "source_disallows_outgoing"
| "target_disallows_incoming"
| "target_already_has_incoming"
| "cycle";
export type WorkflowConnectionValidationResult =
| { ok: true }
| { ok: false; reason: WorkflowConnectionValidationReason };
const DEFAULT_VIEWPORT: WorkflowViewport = { x: 0, y: 0, zoom: 1 };
const DEFAULT_NODE_LAYOUT: Record<string, WorkflowPoint> = {
"source-asset": { x: 120, y: 120 },
"rename-folder": { x: 430, y: 280 },
"validate-structure": { x: 760, y: 450 },
};
const MULTI_INPUT_NODE_DEFINITION_IDS = new Set(["union-assets", "intersect-assets", "difference-assets"]);
function createDefaultNodePosition(index: number): WorkflowPoint {
const column = index % 3;
const row = Math.floor(index / 3);
return {
x: 140 + column * 280,
y: 120 + row * 180,
};
}
function createVisualGraph(input?: Partial<WorkflowVisualGraph>, nodes: WorkflowLogicNode[] = []): WorkflowVisualGraph {
const nodePositions = { ...(input?.nodePositions ?? {}) };
nodes.forEach((node, index) => {
nodePositions[node.id] ??= DEFAULT_NODE_LAYOUT[node.id] ?? createDefaultNodePosition(index);
});
return {
viewport: input?.viewport ? { ...DEFAULT_VIEWPORT, ...input.viewport } : { ...DEFAULT_VIEWPORT },
nodePositions,
};
}
function cloneDraft(draft: WorkflowDraft): WorkflowDraft {
return {
visualGraph: {
viewport: { ...draft.visualGraph.viewport },
nodePositions: Object.fromEntries(
Object.entries(draft.visualGraph.nodePositions).map(([nodeId, position]) => [
nodeId,
{ ...position },
]),
),
},
logicGraph: {
nodes: draft.logicGraph.nodes.map((node) => ({ ...node })),
edges: draft.logicGraph.edges.map((edge) => ({ ...edge })),
},
runtimeGraph: {
...draft.runtimeGraph,
nodeBindings: { ...(draft.runtimeGraph.nodeBindings ?? {}) },
nodeConfigs: Object.fromEntries(
Object.entries(draft.runtimeGraph.nodeConfigs ?? {}).map(([nodeId, config]) => [
nodeId,
{
...config,
executorConfig: config.executorConfig ? { ...config.executorConfig } : undefined,
codeHookSpec: config.codeHookSpec ? { ...config.codeHookSpec } : undefined,
},
]),
),
},
pluginRefs: [...draft.pluginRefs],
};
}
function inferNodeType(definition: WorkflowNodeDefinitionSummary): string {
const category = definition.category?.toLowerCase();
if (category === "source" || category === "transform" || category === "inspect" || category === "export") {
return category;
}
if (definition.id.startsWith("source")) {
return "source";
}
if (
definition.id.startsWith("extract") ||
definition.id.startsWith("rename") ||
definition.id.startsWith("transform")
) {
return "transform";
}
if (definition.id.startsWith("validate") || definition.id.startsWith("inspect")) {
return "inspect";
}
if (definition.id.startsWith("export")) {
return "export";
}
return "utility";
}
function inferDefinitionId(nodeId: string): string {
return nodeId.replace(/-\d+$/, "");
}
export function createDefaultWorkflowDraft(): WorkflowDraft {
const logicGraph = {
nodes: [
{ id: "source-asset", type: "source" },
{ id: "rename-folder", type: "transform" },
{ id: "validate-structure", type: "inspect" },
],
edges: [
{ from: "source-asset", to: "rename-folder" },
{ from: "rename-folder", to: "validate-structure" },
],
};
return {
visualGraph: createVisualGraph(undefined, logicGraph.nodes),
logicGraph,
runtimeGraph: {
selectedPreset: "delivery-normalization",
nodeBindings: {
"source-asset": "source-asset",
"rename-folder": "rename-folder",
"validate-structure": "validate-structure",
},
nodeConfigs: {},
},
pluginRefs: ["builtin:delivery-nodes"],
};
}
export function workflowDraftFromVersion(version?: WorkflowVersionLike | null): WorkflowDraft {
if (!version?.logicGraph?.nodes?.length) {
return createDefaultWorkflowDraft();
}
const nodeBindings = {
...(version.runtimeGraph?.nodeBindings ?? {}),
} as Record<string, string>;
const nodeConfigs = Object.fromEntries(
Object.entries(version.runtimeGraph?.nodeConfigs ?? {}).map(([nodeId, config]) => [
nodeId,
{
...(config as WorkflowNodeRuntimeConfig),
executorConfig: (config as WorkflowNodeRuntimeConfig)?.executorConfig
? { ...(config as WorkflowNodeRuntimeConfig).executorConfig }
: undefined,
codeHookSpec: (config as WorkflowNodeRuntimeConfig)?.codeHookSpec
? { ...(config as WorkflowNodeRuntimeConfig).codeHookSpec }
: undefined,
},
]),
) as Record<string, WorkflowNodeRuntimeConfig>;
for (const node of version.logicGraph.nodes) {
nodeBindings[node.id] ??= inferDefinitionId(node.id);
}
return {
visualGraph: createVisualGraph(version.visualGraph, version.logicGraph.nodes),
logicGraph: {
nodes: version.logicGraph.nodes.map((node) => ({ ...node })),
edges: (version.logicGraph.edges ?? []).map((edge) => ({ ...edge })),
},
runtimeGraph: {
...(version.runtimeGraph ?? {}),
nodeBindings,
nodeConfigs,
},
pluginRefs: [...(version.pluginRefs ?? [])],
};
}
export function addNodeToDraft(
draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary,
): { draft: WorkflowDraft; nodeId: string } {
return addNodeToDraftInternal(draft, definition, {
connectFromPrevious: true,
});
}
export function addNodeToDraftAtPosition(
draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary,
position: WorkflowPoint,
): { draft: WorkflowDraft; nodeId: string } {
return addNodeToDraftInternal(draft, definition, {
connectFromPrevious: false,
position,
});
}
function addNodeToDraftInternal(
draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary,
options: {
connectFromPrevious: boolean;
position?: WorkflowPoint;
},
): { draft: WorkflowDraft; nodeId: string } {
const next = cloneDraft(draft);
let suffix = 1;
let nodeId = `${definition.id}-${suffix}`;
const existingIds = new Set(next.logicGraph.nodes.map((node) => node.id));
while (existingIds.has(nodeId)) {
suffix += 1;
nodeId = `${definition.id}-${suffix}`;
}
const node: WorkflowLogicNode = {
id: nodeId,
type: inferNodeType(definition),
};
const previousNode = next.logicGraph.nodes.at(-1);
next.logicGraph.nodes.push(node);
if (options.connectFromPrevious && previousNode) {
next.logicGraph.edges.push({ from: previousNode.id, to: nodeId });
}
next.runtimeGraph.nodeBindings ??= {};
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
next.runtimeGraph.nodeConfigs ??= {};
next.visualGraph.nodePositions[nodeId] =
options.position ?? createDefaultNodePosition(next.logicGraph.nodes.length - 1);
return { draft: next, nodeId };
}
export function removeNodeFromDraft(draft: WorkflowDraft, nodeId: string): WorkflowDraft {
const next = cloneDraft(draft);
next.logicGraph.nodes = next.logicGraph.nodes.filter((node) => node.id !== nodeId);
next.logicGraph.edges = next.logicGraph.edges.filter(
(edge) => edge.from !== nodeId && edge.to !== nodeId,
);
if (next.runtimeGraph.nodeBindings) {
delete next.runtimeGraph.nodeBindings[nodeId];
}
if (next.runtimeGraph.nodeConfigs) {
delete next.runtimeGraph.nodeConfigs[nodeId];
}
delete next.visualGraph.nodePositions[nodeId];
return next;
}
export function resolveDefinitionIdForNode(draft: WorkflowDraft, nodeId: string): string {
return draft.runtimeGraph.nodeBindings?.[nodeId] ?? inferDefinitionId(nodeId);
}
export function getNodeRuntimeConfig(draft: WorkflowDraft, nodeId: string) {
return draft.runtimeGraph.nodeConfigs?.[nodeId];
}
export function setNodeRuntimeConfig(
draft: WorkflowDraft,
nodeId: string,
config: WorkflowNodeRuntimeConfig,
): WorkflowDraft {
const next = cloneDraft(draft);
next.runtimeGraph.nodeConfigs ??= {};
next.runtimeGraph.nodeConfigs[nodeId] = {
...config,
executorConfig: config.executorConfig ? { ...config.executorConfig } : undefined,
codeHookSpec: config.codeHookSpec ? { ...config.codeHookSpec } : undefined,
};
return next;
}
export function setNodePosition(draft: WorkflowDraft, nodeId: string, position: WorkflowPoint): WorkflowDraft {
const next = cloneDraft(draft);
next.visualGraph.nodePositions[nodeId] = { ...position };
return next;
}
function findNode(draft: WorkflowDraft, nodeId: string) {
return draft.logicGraph.nodes.find((node) => node.id === nodeId) ?? null;
}
function nodeDisallowsOutgoing(node: WorkflowLogicNode) {
return node.type === "export";
}
function nodeDisallowsIncoming(node: WorkflowLogicNode) {
return node.type === "source";
}
function allowsMultipleIncoming(draft: WorkflowDraft, nodeId: string) {
const definitionId = resolveDefinitionIdForNode(draft, nodeId);
return MULTI_INPUT_NODE_DEFINITION_IDS.has(definitionId);
}
function wouldCreateCycle(draft: WorkflowDraft, sourceNodeId: string, targetNodeId: string) {
const adjacency = new Map<string, string[]>();
for (const edge of draft.logicGraph.edges) {
const current = adjacency.get(edge.from) ?? [];
current.push(edge.to);
adjacency.set(edge.from, current);
}
const stack = [targetNodeId];
const visited = new Set<string>();
while (stack.length > 0) {
const currentNodeId = stack.pop();
if (!currentNodeId || visited.has(currentNodeId)) {
continue;
}
if (currentNodeId === sourceNodeId) {
return true;
}
visited.add(currentNodeId);
for (const nextNodeId of adjacency.get(currentNodeId) ?? []) {
stack.push(nextNodeId);
}
}
return false;
}
export function canConnectNodesInDraft(
draft: WorkflowDraft,
sourceNodeId: string | null | undefined,
targetNodeId: string | null | undefined,
): WorkflowConnectionValidationResult {
if (!sourceNodeId) {
return { ok: false, reason: "missing_source" };
}
if (!targetNodeId) {
return { ok: false, reason: "missing_target" };
}
if (sourceNodeId === targetNodeId) {
return { ok: false, reason: "self" };
}
const sourceNode = findNode(draft, sourceNodeId);
if (!sourceNode) {
return { ok: false, reason: "missing_source" };
}
const targetNode = findNode(draft, targetNodeId);
if (!targetNode) {
return { ok: false, reason: "missing_target" };
}
if (draft.logicGraph.edges.some((edge) => edge.from === sourceNodeId && edge.to === targetNodeId)) {
return { ok: false, reason: "duplicate" };
}
if (nodeDisallowsOutgoing(sourceNode)) {
return { ok: false, reason: "source_disallows_outgoing" };
}
if (nodeDisallowsIncoming(targetNode)) {
return { ok: false, reason: "target_disallows_incoming" };
}
if (!allowsMultipleIncoming(draft, targetNodeId) && draft.logicGraph.edges.some((edge) => edge.to === targetNodeId)) {
return { ok: false, reason: "target_already_has_incoming" };
}
if (wouldCreateCycle(draft, sourceNodeId, targetNodeId)) {
return { ok: false, reason: "cycle" };
}
return { ok: true };
}
export function connectNodesInDraft(
draft: WorkflowDraft,
sourceNodeId: string | null | undefined,
targetNodeId: string | null | undefined,
): WorkflowDraft {
const validation = canConnectNodesInDraft(draft, sourceNodeId, targetNodeId);
if (!validation.ok || !sourceNodeId || !targetNodeId) {
return draft;
}
const next = cloneDraft(draft);
next.logicGraph.edges.push({ from: sourceNodeId, to: targetNodeId });
return next;
}
export function setViewportInDraft(draft: WorkflowDraft, viewport: WorkflowViewport): WorkflowDraft {
const next = cloneDraft(draft);
next.visualGraph.viewport = { ...viewport };
return next;
}
export function serializeWorkflowDraft(draft: WorkflowDraft): WorkflowDraft {
return cloneDraft(draft);
}