Files
aryx/src/shared/domain/workflowSerialization.ts
T
David KayaandCopilot 4971dcf9fc feat(workflows): add phase 5 backend templates and export support
- add shared workflow template and serialization helpers for YAML, Mermaid, and DOT export
- add pattern-to-workflow upgrade and template application flows in AryxAppService
- persist built-in and custom workflow templates in workspace state and expose new IPC/preload methods
- cover the new backend slice with shared and app-service tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:11:59 +02:00

187 lines
5.7 KiB
TypeScript

import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import {
normalizeWorkflowDefinition,
validateWorkflowDefinition,
type WorkflowDefinition,
type WorkflowEdge,
type WorkflowGraph,
type WorkflowNode,
type WorkflowSettings,
} from '@shared/domain/workflow';
import { nowIso } from '@shared/utils/ids';
export type WorkflowExportFormat = 'yaml' | 'mermaid' | 'dot';
export interface WorkflowExportResult {
format: WorkflowExportFormat;
content: string;
}
function coerceWorkflowDefinition(value: unknown): WorkflowDefinition {
if (!value || typeof value !== 'object') {
throw new Error('Imported workflow must be an object.');
}
const candidate = value as Partial<WorkflowDefinition>;
const settings = candidate.settings as Partial<WorkflowSettings> | undefined;
return normalizeWorkflowDefinition({
id: typeof candidate.id === 'string' ? candidate.id.trim() : '',
name: typeof candidate.name === 'string' ? candidate.name : '',
description: typeof candidate.description === 'string' ? candidate.description : '',
isFavorite: candidate.isFavorite,
graph: (candidate.graph as WorkflowGraph | undefined) ?? { nodes: [], edges: [] },
settings: {
checkpointing: {
enabled: settings?.checkpointing?.enabled ?? false,
},
executionMode: settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread',
maxIterations: settings?.maxIterations,
approvalPolicy: settings?.approvalPolicy,
stateScopes: settings?.stateScopes,
telemetry: settings?.telemetry,
},
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : nowIso(),
updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : nowIso(),
});
}
function ensureValidWorkflow(workflow: WorkflowDefinition): WorkflowDefinition {
const normalized = normalizeWorkflowDefinition(workflow);
const issue = validateWorkflowDefinition(normalized).find((candidate) => candidate.level === 'error');
if (issue) {
throw new Error(issue.message);
}
return normalized;
}
function escapeMermaidLabel(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '<br/>');
}
function escapeDotLabel(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function formatNodeLabel(node: WorkflowNode): string {
return node.label || node.id;
}
function formatEdgeConditionLabel(condition?: WorkflowEdge['condition']): string | undefined {
if (!condition) {
return undefined;
}
switch (condition.type) {
case 'always':
return 'always';
case 'message-type':
return `message:${condition.typeName}`;
case 'expression':
return condition.expression;
case 'property':
return condition.rules
.map((rule) => `${rule.propertyPath} ${rule.operator} ${rule.value}`)
.join(` ${condition.combinator === 'or' ? 'OR' : 'AND'} `);
}
}
function formatEdgeLabel(edge: WorkflowEdge): string | undefined {
const parts = [
edge.label,
edge.kind === 'fan-out' ? 'fan-out' : undefined,
edge.kind === 'fan-in' ? 'fan-in' : undefined,
edge.isLoop ? `loop≤${edge.maxIterations ?? '?'}` : undefined,
formatEdgeConditionLabel(edge.condition),
].filter((value): value is string => Boolean(value));
return parts.length > 0 ? parts.join(' • ') : undefined;
}
function buildMermaidContent(workflow: WorkflowDefinition): string {
const nodeIds = new Map(workflow.graph.nodes.map((node, index) => [node.id, `n${index}`]));
const lines = ['flowchart LR'];
for (const node of workflow.graph.nodes) {
const nodeId = nodeIds.get(node.id)!;
const label = escapeMermaidLabel(formatNodeLabel(node));
switch (node.kind) {
case 'start':
case 'end':
lines.push(` ${nodeId}(["${label}"])`);
break;
default:
lines.push(` ${nodeId}["${label}"]`);
break;
}
}
for (const edge of workflow.graph.edges) {
const source = nodeIds.get(edge.source);
const target = nodeIds.get(edge.target);
if (!source || !target) {
continue;
}
const label = formatEdgeLabel(edge);
lines.push(label
? ` ${source} -->|${escapeMermaidLabel(label)}| ${target}`
: ` ${source} --> ${target}`);
}
return `${lines.join('\n')}\n`;
}
function buildDotContent(workflow: WorkflowDefinition): string {
const lines = ['digraph Workflow {', ' rankdir=LR;'];
for (const node of workflow.graph.nodes) {
const shape = node.kind === 'start' || node.kind === 'end' ? 'oval' : 'box';
lines.push(` "${node.id}" [label="${escapeDotLabel(formatNodeLabel(node))}", shape=${shape}];`);
}
for (const edge of workflow.graph.edges) {
const label = formatEdgeLabel(edge);
lines.push(label
? ` "${edge.source}" -> "${edge.target}" [label="${escapeDotLabel(label)}"];`
: ` "${edge.source}" -> "${edge.target}";`);
}
lines.push('}');
return `${lines.join('\n')}\n`;
}
export function exportWorkflowDefinition(
workflow: WorkflowDefinition,
format: WorkflowExportFormat,
): WorkflowExportResult {
const normalized = ensureValidWorkflow(workflow);
switch (format) {
case 'yaml':
return {
format,
content: stringifyYaml(normalized, { indent: 2 }),
};
case 'mermaid':
return {
format,
content: buildMermaidContent(normalized),
};
case 'dot':
return {
format,
content: buildDotContent(normalized),
};
}
}
export function importWorkflowDefinition(content: string, format: 'yaml' | 'json'): WorkflowDefinition {
const parsed = format === 'yaml'
? parseYaml(content)
: JSON.parse(content) as unknown;
return ensureValidWorkflow(coerceWorkflowDefinition(parsed));
}