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>
This commit is contained in:
David Kaya
2026-04-05 21:11:59 +02:00
co-authored by Copilot
parent f05ec8ac7f
commit 4971dcf9fc
17 changed files with 1094 additions and 6 deletions
+6
View File
@@ -109,6 +109,8 @@ The durable state of the app is a **workspace**. The workspace contains:
- connected projects
- orchestration patterns
- workflow templates
- workflows
- sessions
- settings
- run history
@@ -150,6 +152,8 @@ That graph is now the execution contract for the sidecar: sequential order comes
The pattern editor renders an interactive graph canvas powered by React Flow (`@xyflow/react`). The canvas projects the authoritative `PatternGraph` into React Flow nodes and edges via a view-model layer (`src/renderer/lib/patternGraph.ts`). Users can drag nodes to reposition them, and in handoff mode can draw new agent-to-agent edges directly on the canvas. A right-side inspector panel shows the details of the selected node — system node metadata for system nodes, or the full agent configuration form (model, reasoning, instructions) for agent nodes. The mode selector, pattern metadata, approval checkpoints, and tool auto-approval settings remain below the graph as scrollable settings sections. The `syncPatternGraph()` adapter is still called when agents are added/removed or the mode changes, rebuilding the graph from the current state; direct graph edits (drag positions, handoff edges) are persisted without the adapter.
Workflows now sit alongside patterns as a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflow templates are derived from built-in patterns, while custom templates are persisted in workspace state and used by the main process to create new saved workflows without expanding the sidecar protocol.
### Sessions
A session is the working unit of the product. It binds together:
@@ -190,6 +194,8 @@ Typical examples:
- load workspace
- create session
- create a workflow from a template
- export or import a workflow definition
- send message
- update theme
- create or restart the integrated terminal
+3 -1
View File
@@ -18,7 +18,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
## Highlights
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor.
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor, reusable workflow templates, and workflow import/export.
- **Project-grounded** — attach local folders and repos so every conversation has real codebase context.
- **Live execution visibility** — watch agents think, delegate, call tools, and consume context in real time.
- **Persistent workspace** — sessions survive restarts. Search, pin, archive, branch, and return to past work.
@@ -53,6 +53,8 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
| Feature | Description |
|---------|-------------|
| Orchestration patterns | Single, sequential, concurrent, handoff, and group-chat agent flows |
| Workflow templates | Save workflows as reusable templates, bootstrap from built-ins, and upgrade patterns into workflows |
| Workflow import/export | Export workflows as YAML, Mermaid, or DOT and import normalized definitions from YAML or JSON |
| Visual pattern editor | Drag nodes, draw connections, and inspect each step in a graph view |
| Mid-turn steering | Send follow-up messages while an agent is running — input is injected immediately |
| Plan review & questions | Agents propose plans and ask clarifying questions before acting |
+158
View File
@@ -43,6 +43,20 @@ import {
type WorkflowDefinition,
type WorkflowReference,
} from '@shared/domain/workflow';
import {
exportWorkflowDefinition,
importWorkflowDefinition,
type WorkflowExportFormat,
type WorkflowExportResult,
} from '@shared/domain/workflowSerialization';
import {
applyWorkflowTemplate,
buildWorkflowFromPattern,
createWorkflowTemplateFromWorkflow,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateCategory,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import {
normalizeWorkspaceAgentDefinition,
resolvePatternAgents,
@@ -657,6 +671,116 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async saveWorkflowTemplate(
workflowId: string,
options?: {
templateId?: string;
name?: string;
description?: string;
category?: WorkflowTemplateCategory;
},
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const workflow = this.requireWorkflow(workspace, workflowId);
const candidate = createWorkflowTemplateFromWorkflow(workflow, options);
const existingIndex = workspace.workflowTemplates.findIndex((template) => template.id === candidate.id);
const existingTemplate = existingIndex >= 0 ? workspace.workflowTemplates[existingIndex] : undefined;
if (existingTemplate?.source === 'builtin') {
throw new Error(`Workflow template "${candidate.id}" is reserved by a built-in template.`);
}
const normalizedCandidate: WorkflowTemplateDefinition = normalizeWorkflowTemplateDefinition({
...candidate,
createdAt: existingTemplate?.createdAt ?? candidate.createdAt,
updatedAt: nowIso(),
});
if (existingIndex >= 0) {
workspace.workflowTemplates[existingIndex] = normalizedCandidate;
} else {
workspace.workflowTemplates.push(normalizedCandidate);
}
return this.persistAndBroadcast(workspace);
}
async createWorkflowFromTemplate(
templateId: string,
options?: {
workflowId?: string;
name?: string;
description?: string;
},
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const template = this.requireWorkflowTemplate(workspace, templateId);
const workflowId = options?.workflowId?.trim()
|| this.createUniqueWorkflowId(workspace, template.workflow.id);
const workflow = applyWorkflowTemplate(template, {
...options,
workflowId,
});
return this.saveWorkflow(workflow);
}
async exportWorkflow(workflowId: string, format: WorkflowExportFormat): Promise<WorkflowExportResult> {
const workspace = await this.loadWorkspace();
const workflow = this.requireWorkflow(workspace, workflowId);
return exportWorkflowDefinition(workflow, format);
}
async importWorkflow(
content: string,
format: 'yaml' | 'json',
options?: { save?: boolean },
): Promise<{ workflow: WorkflowDefinition; workspace?: WorkspaceState }> {
const workflow = importWorkflowDefinition(content, format);
if (!options?.save) {
return { workflow };
}
const workspace = await this.saveWorkflow(workflow);
return {
workflow,
workspace,
};
}
async upgradePatternToWorkflow(
patternId: string,
options?: {
workflowId?: string;
name?: string;
description?: string;
save?: boolean;
},
): Promise<{ workflow: WorkflowDefinition; workspace?: WorkspaceState }> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
const baseWorkflow = buildWorkflowFromPattern(pattern);
const workflowId = options?.workflowId?.trim()
|| this.createUniqueWorkflowId(workspace, baseWorkflow.id);
const workflow = normalizeWorkflowDefinition({
...baseWorkflow,
id: workflowId,
name: options?.name?.trim() || baseWorkflow.name,
description: options?.description?.trim() ?? baseWorkflow.description,
createdAt: nowIso(),
updatedAt: nowIso(),
});
if (!options?.save) {
return { workflow };
}
const savedWorkspace = await this.saveWorkflow(workflow);
return {
workflow,
workspace: savedWorkspace,
};
}
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
@@ -2250,6 +2374,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return pattern;
}
private requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
if (!template) {
throw new Error(`Workflow template "${templateId}" was not found.`);
}
return template;
}
private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
const workflow = workspace.workflows.find((current) => current.id === workflowId);
if (!workflow) {
@@ -2259,6 +2392,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return workflow;
}
private createUniqueWorkflowId(workspace: WorkspaceState, sourceId: string): string {
const normalizedSourceId = this.normalizeIdentifier(sourceId, 'workflow');
const existingIds = new Set(workspace.workflows.map((workflow) => workflow.id));
if (!existingIds.has(normalizedSourceId)) {
return normalizedSourceId;
}
let suffix = 2;
while (existingIds.has(`${normalizedSourceId}-${suffix}`)) {
suffix += 1;
}
return `${normalizedSourceId}-${suffix}`;
}
private normalizeIdentifier(value: string, fallbackPrefix: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || createId(fallbackPrefix);
}
private resolveSessionExecutionDefinition(
workspace: WorkspaceState,
session: SessionRecord,
+22 -2
View File
@@ -5,7 +5,9 @@ import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
CancelSessionTurnInput,
CommitProjectGitChangesInput,
CreateSessionInput,
CreateWorkflowFromTemplateInput,
CreateWorkflowSessionInput,
CreateProjectGitBranchInput,
DismissSessionMcpAuthInput,
@@ -14,13 +16,15 @@ import type {
DeleteSessionInput,
DiscardSessionRunGitChangesInput,
EditAndResendSessionMessageInput,
CommitProjectGitChangesInput,
ExportWorkflowInput,
ImportWorkflowInput,
ProjectGitDetailsInput,
ProjectGitFilePreviewInput,
ProjectGitFileSelectionInput,
ProjectGitInput,
PullProjectGitInput,
RegenerateSessionMessageInput,
ResolveWorkspaceDiscoveredToolingInput,
StartSessionMcpAuthInput,
SuggestProjectGitCommitMessageInput,
SwitchProjectGitBranchInput,
@@ -31,11 +35,11 @@ import type {
ResolveProjectDiscoveredToolingInput,
ResolveSessionApprovalInput,
ResolveSessionUserInputInput,
ResolveWorkspaceDiscoveredToolingInput,
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkflowInput,
SaveWorkflowTemplateInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
@@ -49,6 +53,7 @@ import type {
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
UpgradePatternToWorkflowInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
@@ -115,10 +120,25 @@ export function registerIpcHandlers(
service.setPatternFavorite(input.patternId, input.isFavorite),
);
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
ipcMain.handle(ipcChannels.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
service.saveWorkflowTemplate(input.workflowId, input.options),
);
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
service.listWorkflowReferences(workflowId),
);
ipcMain.handle(ipcChannels.createWorkflowFromTemplate, (_event, input: CreateWorkflowFromTemplateInput) =>
service.createWorkflowFromTemplate(input.templateId, input.options),
);
ipcMain.handle(ipcChannels.exportWorkflow, (_event, input: ExportWorkflowInput) =>
service.exportWorkflow(input.workflowId, input.format),
);
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
service.importWorkflow(input.content, input.format, input.options),
);
ipcMain.handle(ipcChannels.upgradePatternToWorkflow, (_event, input: UpgradePatternToWorkflowInput) =>
service.upgradePatternToWorkflow(input.patternId, input.options),
);
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
const result = await service.setTheme(theme);
applyTitleBarTheme(window, theme);
@@ -15,6 +15,11 @@ import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
} from '@shared/domain/tooling';
import {
createBuiltinWorkflowTemplates,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
import {
applyDefaultToolApprovalPolicy,
@@ -58,6 +63,16 @@ function mergePatterns(existingPatterns: PatternDefinition[], deletedBuiltinIds:
return [...mergedBuiltins, ...customPatterns];
}
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
const builtinTemplates = createBuiltinWorkflowTemplates(nowIso());
const builtinIds = new Set(builtinTemplates.map((template) => template.id));
const customTemplates = existingTemplates
.map(normalizeWorkflowTemplateDefinition)
.filter((template) => template.source !== 'builtin' && !builtinIds.has(template.id));
return [...builtinTemplates, ...customTemplates];
}
export class WorkspaceRepository {
readonly filePath = getWorkspaceFilePath();
readonly scratchpadPath = getScratchpadDirectoryPath();
@@ -122,6 +137,7 @@ export class WorkspaceRepository {
graph: resolvePatternGraph(pattern),
})),
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
projects,
sessions,
settings,
+5
View File
@@ -27,8 +27,13 @@ const api: ElectronApi = {
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId),
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
upgradePatternToWorkflow: (input) => ipcRenderer.invoke(ipcChannels.upgradePatternToWorkflow, input),
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
+5
View File
@@ -16,8 +16,13 @@ export const ipcChannels = {
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
saveWorkflow: 'workflows:save',
saveWorkflowTemplate: 'workflows:save-template',
deleteWorkflow: 'workflows:delete',
listWorkflowReferences: 'workflows:list-references',
createWorkflowFromTemplate: 'workflows:create-from-template',
exportWorkflow: 'workflows:export',
importWorkflow: 'workflows:import',
upgradePatternToWorkflow: 'workflows:upgrade-pattern',
createWorkflowSession: 'workflows:create-session',
setTheme: 'settings:set-theme',
setTerminalHeight: 'settings:set-terminal-height',
+54
View File
@@ -1,6 +1,8 @@
import type { ApprovalDecision } from '@shared/domain/approval';
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { WorkflowExportFormat, WorkflowExportResult } from '@shared/domain/workflowSerialization';
import type { WorkflowTemplateCategory } from '@shared/domain/workflowTemplate';
import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
import type {
ProjectGitBranchSummary,
@@ -42,6 +44,53 @@ export interface SaveWorkflowInput {
workflow: WorkflowDefinition;
}
export interface SaveWorkflowTemplateInput {
workflowId: string;
options?: {
templateId?: string;
name?: string;
description?: string;
category?: WorkflowTemplateCategory;
};
}
export interface CreateWorkflowFromTemplateInput {
templateId: string;
options?: {
workflowId?: string;
name?: string;
description?: string;
};
}
export interface ExportWorkflowInput {
workflowId: string;
format: WorkflowExportFormat;
}
export interface ImportWorkflowInput {
content: string;
format: 'yaml' | 'json';
options?: {
save?: boolean;
};
}
export interface UpgradePatternToWorkflowInput {
patternId: string;
options?: {
workflowId?: string;
name?: string;
description?: string;
save?: boolean;
};
}
export interface ImportWorkflowResult {
workflow: WorkflowDefinition;
workspace?: WorkspaceState;
}
export interface SendSessionMessageInput {
sessionId: string;
content: string;
@@ -285,8 +334,13 @@ export interface ElectronApi {
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]>;
createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise<WorkspaceState>;
exportWorkflow(input: ExportWorkflowInput): Promise<WorkflowExportResult>;
importWorkflow(input: ImportWorkflowInput): Promise<ImportWorkflowResult>;
upgradePatternToWorkflow(input: UpgradePatternToWorkflowInput): Promise<ImportWorkflowResult>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
+2 -2
View File
@@ -1084,12 +1084,12 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
continue;
}
if (!edge.condition || edge.condition.type === 'always') {
if (!edge.condition) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition',
edgeId: edge.id,
message: 'Loop edges require a non-default condition so the loop can terminate.',
message: 'Loop edges require a condition so the loop can terminate.',
});
}
+186
View File
@@ -0,0 +1,186 @@
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));
}
+444
View File
@@ -0,0 +1,444 @@
import {
createBuiltinPatterns,
resolvePatternGraph,
type PatternAgentDefinition,
type PatternDefinition,
type PatternGraphNode,
} from '@shared/domain/pattern';
import {
normalizeWorkflowDefinition,
type WorkflowDefinition,
type WorkflowEdge,
type WorkflowNode,
type WorkflowSettings,
} from '@shared/domain/workflow';
import { nowIso } from '@shared/utils/ids';
export type WorkflowTemplateCategory = 'orchestration' | 'data-pipeline' | 'human-in-loop';
export interface WorkflowTemplateDefinition {
id: string;
name: string;
description: string;
category: WorkflowTemplateCategory;
source: 'builtin' | 'custom';
workflow: WorkflowDefinition;
createdAt: string;
updatedAt: string;
}
const workflowTemplateCategories = new Set<WorkflowTemplateCategory>([
'orchestration',
'data-pipeline',
'human-in-loop',
]);
const builtinTemplateIdsByPatternId: Record<string, string> = {
'pattern-single-chat': 'workflow-template-single',
'pattern-sequential-review': 'workflow-template-sequential',
'pattern-concurrent-brainstorm': 'workflow-template-concurrent',
'pattern-handoff-support': 'workflow-template-handoff',
'pattern-group-chat': 'workflow-template-group-chat',
};
function normalizeTemplateCategory(category?: WorkflowTemplateCategory): WorkflowTemplateCategory {
return category && workflowTemplateCategories.has(category) ? category : 'orchestration';
}
function normalizeOptionalString(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeRequiredString(value: string | undefined, fallback: string): string {
return normalizeOptionalString(value) ?? fallback;
}
function toWorkflowIdFromPatternId(patternId: string): string {
const trimmed = patternId.trim();
return trimmed.startsWith('pattern-')
? `workflow-${trimmed.slice('pattern-'.length)}`
: `${trimmed}-workflow`;
}
function createWorkflowEdge(
source: string,
target: string,
kind: WorkflowEdge['kind'],
overrides?: Partial<WorkflowEdge>,
): WorkflowEdge {
return {
id: overrides?.id?.trim() || `workflow-edge-${source}-to-${target}`,
source,
target,
kind,
label: normalizeOptionalString(overrides?.label),
condition: overrides?.condition,
fanOutConfig: overrides?.fanOutConfig,
isLoop: overrides?.isLoop,
maxIterations: overrides?.maxIterations,
};
}
function sortAgentNodes(nodes: ReadonlyArray<PatternGraphNode>): PatternGraphNode[] {
return nodes
.filter((node) => node.kind === 'agent' && node.agentId)
.slice()
.sort((left, right) => {
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.id.localeCompare(right.id);
});
}
function normalizeTemplateWorkflow(
workflow: WorkflowDefinition | Partial<WorkflowDefinition> | undefined,
): WorkflowDefinition {
const candidate = workflow ?? {};
const settings = candidate.settings as Partial<WorkflowSettings> | undefined;
return normalizeWorkflowDefinition({
id: normalizeRequiredString(candidate.id, 'workflow-template'),
name: typeof candidate.name === 'string' ? candidate.name : '',
description: typeof candidate.description === 'string' ? candidate.description : '',
isFavorite: candidate.isFavorite,
graph: (candidate.graph as WorkflowDefinition['graph'] | 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 createWorkflowAgentNode(
patternNode: PatternGraphNode,
agent: PatternAgentDefinition,
fallbackOrder: number,
): WorkflowNode {
return {
id: patternNode.id.trim(),
kind: 'agent',
label: normalizeRequiredString(agent.name, `Agent ${fallbackOrder + 1}`),
position: {
x: patternNode.position.x,
y: patternNode.position.y,
},
order: patternNode.order ?? fallbackOrder,
config: {
kind: 'agent',
...agent,
},
};
}
function mapPatternNodeId(node: PatternGraphNode | undefined): string | undefined {
if (!node) {
return undefined;
}
switch (node.kind) {
case 'user-input':
return 'start';
case 'user-output':
return 'end';
case 'agent':
return node.id.trim();
default:
return undefined;
}
}
function buildWorkflowAdjacency(
edges: ReadonlyArray<WorkflowEdge>,
excludedEdgeId?: string,
): Map<string, string[]> {
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (edge.id === excludedEdgeId) {
continue;
}
const next = adjacency.get(edge.source);
if (next) {
next.push(edge.target);
} else {
adjacency.set(edge.source, [edge.target]);
}
}
return adjacency;
}
function canReachWorkflowNode(
edges: ReadonlyArray<WorkflowEdge>,
startNodeId: string,
targetNodeId: string,
excludedEdgeId?: string,
): boolean {
if (startNodeId === targetNodeId) {
return true;
}
const adjacency = buildWorkflowAdjacency(edges, excludedEdgeId);
const queue = [startNodeId];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) {
continue;
}
visited.add(current);
for (const candidate of adjacency.get(current) ?? []) {
if (candidate === targetNodeId) {
return true;
}
queue.push(candidate);
}
}
return false;
}
function markLoopEdges(
edges: ReadonlyArray<WorkflowEdge>,
maxIterations: number,
): WorkflowEdge[] {
return edges.map((edge) => {
const participatesInCycle = canReachWorkflowNode(edges, edge.target, edge.source, edge.id);
if (!participatesInCycle) {
return edge;
}
return {
...edge,
isLoop: true,
maxIterations,
condition: edge.condition ?? { type: 'always' },
};
});
}
export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDefinition): WorkflowTemplateDefinition {
const workflow = normalizeTemplateWorkflow(template.workflow);
return {
...template,
id: normalizeRequiredString(template.id, `workflow-template-${workflow.id}`),
name: normalizeRequiredString(template.name, workflow.name || 'Workflow Template'),
description: template.description?.trim() ?? '',
category: normalizeTemplateCategory(template.category),
source: template.source === 'builtin' ? 'builtin' : 'custom',
workflow,
createdAt: template.createdAt,
updatedAt: template.updatedAt,
};
}
export function buildWorkflowFromPattern(pattern: PatternDefinition): WorkflowDefinition {
const resolvedGraph = resolvePatternGraph(pattern);
const patternNodesById = new Map(resolvedGraph.nodes.map((node) => [node.id, node]));
const agentsById = new Map(pattern.agents.map((agent) => [agent.id, agent]));
const orderedAgentNodes = sortAgentNodes(resolvedGraph.nodes);
const startPosition = patternNodesById.get('system-user-input')?.position ?? { x: 0, y: 0 };
const endPosition = patternNodesById.get('system-user-output')?.position ?? { x: 500, y: 0 };
const workflowNodes: WorkflowNode[] = [
{
id: 'start',
kind: 'start',
label: 'Start',
position: { x: startPosition.x, y: startPosition.y },
config: { kind: 'start' },
},
...orderedAgentNodes.map((node, index) => {
const agent = node.agentId ? agentsById.get(node.agentId) : undefined;
if (!agent) {
throw new Error(`Pattern "${pattern.id}" references missing agent "${node.agentId ?? node.id}".`);
}
return createWorkflowAgentNode(node, agent, index);
}),
{
id: 'end',
kind: 'end',
label: 'End',
position: { x: endPosition.x, y: endPosition.y },
config: { kind: 'end' },
},
];
const orderedAgentIds = workflowNodes.filter((node) => node.kind === 'agent').map((node) => node.id);
const edges: WorkflowEdge[] = [];
const seenEdges = new Set<string>();
const pushEdge = (edge: WorkflowEdge) => {
const dedupeKey = `${edge.source}:${edge.target}:${edge.kind}:${edge.isLoop === true ? 'loop' : 'plain'}`;
if (seenEdges.has(dedupeKey)) {
return;
}
seenEdges.add(dedupeKey);
edges.push(edge);
};
switch (pattern.mode) {
case 'single':
case 'sequential':
case 'magentic': {
const path = ['start', ...orderedAgentIds, 'end'];
for (let index = 0; index < path.length - 1; index += 1) {
pushEdge(createWorkflowEdge(path[index]!, path[index + 1]!, 'direct'));
}
break;
}
case 'concurrent': {
for (const agentId of orderedAgentIds) {
pushEdge(createWorkflowEdge('start', agentId, 'fan-out', {
fanOutConfig: { strategy: 'broadcast' },
}));
pushEdge(createWorkflowEdge(agentId, 'end', 'fan-in'));
}
break;
}
case 'handoff': {
for (const patternEdge of resolvedGraph.edges) {
const sourceNode = patternNodesById.get(patternEdge.source);
const targetNode = patternNodesById.get(patternEdge.target);
const source = mapPatternNodeId(sourceNode);
const target = mapPatternNodeId(targetNode);
if (!source || !target) {
continue;
}
pushEdge(createWorkflowEdge(source, target, 'direct', {
id: `workflow-${patternEdge.id.trim()}`,
}));
}
break;
}
case 'group-chat': {
const path = ['start', ...orderedAgentIds, 'end'];
for (let index = 0; index < path.length - 1; index += 1) {
const source = path[index]!;
const target = path[index + 1]!;
pushEdge(createWorkflowEdge(source, target, 'direct', source !== 'start' && target !== 'end'
? {
isLoop: true,
maxIterations: pattern.maxIterations,
condition: { type: 'always' },
}
: undefined));
}
if (orderedAgentIds.length > 0) {
pushEdge(createWorkflowEdge(
orderedAgentIds[orderedAgentIds.length - 1]!,
orderedAgentIds[0]!,
'direct',
{
id: `workflow-edge-${orderedAgentIds[orderedAgentIds.length - 1]}-loop-${orderedAgentIds[0]}`,
isLoop: true,
maxIterations: pattern.maxIterations,
condition: { type: 'always' },
label: 'Loop',
},
));
}
break;
}
}
const finalizedEdges = pattern.mode === 'handoff'
? markLoopEdges(edges, Math.max(pattern.maxIterations, 1))
: edges;
return normalizeWorkflowDefinition({
id: toWorkflowIdFromPatternId(pattern.id),
name: pattern.name,
description: pattern.description,
graph: {
nodes: workflowNodes,
edges: finalizedEdges,
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
maxIterations: pattern.maxIterations,
approvalPolicy: pattern.approvalPolicy,
},
createdAt: pattern.createdAt,
updatedAt: pattern.updatedAt,
});
}
export function createBuiltinWorkflowTemplates(timestamp: string): WorkflowTemplateDefinition[] {
return createBuiltinPatterns(timestamp)
.filter((pattern) => pattern.availability !== 'unavailable')
.map((pattern) => normalizeWorkflowTemplateDefinition({
id: builtinTemplateIdsByPatternId[pattern.id] ?? `workflow-template-${pattern.id}`,
name: pattern.name,
description: pattern.description,
category: 'orchestration',
source: 'builtin',
workflow: buildWorkflowFromPattern(pattern),
createdAt: timestamp,
updatedAt: timestamp,
}));
}
export function createWorkflowTemplateFromWorkflow(
workflow: WorkflowDefinition,
options?: {
templateId?: string;
name?: string;
description?: string;
category?: WorkflowTemplateCategory;
},
): WorkflowTemplateDefinition {
const timestamp = nowIso();
const normalizedWorkflow = normalizeTemplateWorkflow(workflow);
return normalizeWorkflowTemplateDefinition({
id: normalizeOptionalString(options?.templateId) ?? `workflow-template-${normalizedWorkflow.id}`,
name: normalizeOptionalString(options?.name) ?? normalizedWorkflow.name,
description: options?.description?.trim() ?? normalizedWorkflow.description,
category: options?.category ?? 'orchestration',
source: 'custom',
workflow: normalizedWorkflow,
createdAt: timestamp,
updatedAt: timestamp,
});
}
export function applyWorkflowTemplate(
template: WorkflowTemplateDefinition,
options?: {
workflowId?: string;
name?: string;
description?: string;
},
): WorkflowDefinition {
const timestamp = nowIso();
const normalizedTemplate = normalizeWorkflowTemplateDefinition(template);
return normalizeWorkflowDefinition({
...normalizedTemplate.workflow,
id: normalizeOptionalString(options?.workflowId) ?? normalizedTemplate.workflow.id,
name: normalizeOptionalString(options?.name) ?? normalizedTemplate.workflow.name,
description: options?.description?.trim() ?? normalizedTemplate.workflow.description,
isFavorite: undefined,
createdAt: timestamp,
updatedAt: timestamp,
});
}
+6
View File
@@ -3,6 +3,10 @@ import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
import {
createBuiltinWorkflowTemplates,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { nowIso } from '@shared/utils/ids';
@@ -10,6 +14,7 @@ export interface WorkspaceState {
projects: ProjectRecord[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
workflowTemplates: WorkflowTemplateDefinition[];
sessions: SessionRecord[];
settings: WorkspaceSettings;
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
@@ -29,6 +34,7 @@ export function createWorkspaceSeed(): WorkspaceState {
projects: [],
patterns: createBuiltinPatterns(timestamp),
workflows: [],
workflowTemplates: createBuiltinWorkflowTemplates(timestamp),
sessions: [],
settings: createWorkspaceSettings(),
lastUpdatedAt: timestamp,
+59
View File
@@ -1,6 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import { exportWorkflowDefinition } from '@shared/domain/workflowSerialization';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
@@ -94,6 +95,64 @@ describe('AryxAppService workflow operations', () => {
expect(result.selectedWorkflowId).toBe('workflow-test');
});
test('persists a custom workflow template from a saved workflow', async () => {
const workspace = createWorkspaceSeed();
workspace.workflows.push(createWorkflow());
const service = createService(workspace);
const result = await service.saveWorkflowTemplate('workflow-test', {
name: 'Saved Template',
description: 'From workflow',
category: 'human-in-loop',
});
const template = result.workflowTemplates.find((candidate) => candidate.source === 'custom');
expect(template).toBeDefined();
expect(template?.name).toBe('Saved Template');
expect(template?.category).toBe('human-in-loop');
expect(template?.workflow.id).toBe('workflow-test');
});
test('creates workflows from templates and selects the new workflow', async () => {
const workspace = createWorkspaceSeed();
const service = createService(workspace);
const result = await service.createWorkflowFromTemplate('workflow-template-sequential', {
name: 'Template Copy',
});
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0]?.name).toBe('Template Copy');
expect(result.selectedWorkflowId).toBe(result.workflows[0]?.id);
});
test('upgrades a pattern to a saved workflow without removing the pattern', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'handoff');
if (!pattern) {
throw new Error('Expected a handoff pattern.');
}
const service = createService(workspace);
const result = await service.upgradePatternToWorkflow(pattern.id, { save: true });
expect(result.workspace?.workflows).toHaveLength(1);
expect(result.workspace?.patterns.some((candidate) => candidate.id === pattern.id)).toBe(true);
expect(result.workflow.id).toBe('workflow-handoff-support');
});
test('imports and saves workflows from yaml', async () => {
const workspace = createWorkspaceSeed();
const service = createService(workspace);
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
const result = await service.importWorkflow(yaml, 'yaml', { save: true });
expect(result.workspace?.workflows).toHaveLength(1);
expect(result.workspace?.selectedWorkflowId).toBe('workflow-test');
expect(result.workflow.id).toBe('workflow-test');
});
test('creates workflow-backed sessions', async () => {
const workspace = createWorkspaceSeed();
const project = {
+1
View File
@@ -12,6 +12,7 @@ describe('session workspace helpers', () => {
projects: [],
patterns: [],
workflows: [],
workflowTemplates: [],
settings: createWorkspaceSettings(),
sessions: [
{
+2
View File
@@ -77,6 +77,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
workflows: [],
workflowTemplates: [],
settings: createWorkspaceSettings(),
sessions: [
createSession(),
@@ -117,6 +118,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
return {
...workspace,
workflows: overrides?.workflows ?? workspace.workflows,
workflowTemplates: overrides?.workflowTemplates ?? workspace.workflowTemplates,
};
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test';
import { createBuiltinPatterns } from '@shared/domain/pattern';
import {
exportWorkflowDefinition,
importWorkflowDefinition,
} from '@shared/domain/workflowSerialization';
import {
buildWorkflowFromPattern,
createBuiltinWorkflowTemplates,
} from '@shared/domain/workflowTemplate';
import { validateWorkflowDefinition } from '@shared/domain/workflow';
const TIMESTAMP = '2026-04-05T00:00:00.000Z';
function requirePattern(mode: 'sequential' | 'concurrent' | 'handoff' | 'group-chat') {
const pattern = createBuiltinPatterns(TIMESTAMP).find((candidate) => candidate.mode === mode);
if (!pattern) {
throw new Error(`Expected built-in ${mode} pattern.`);
}
return pattern;
}
describe('workflow templates', () => {
test('builds a valid sequential workflow from a pattern', () => {
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
expect(workflow.id).toBe('workflow-sequential-review');
expect(workflow.graph.nodes.map((node) => node.kind)).toEqual(['start', 'agent', 'agent', 'agent', 'end']);
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}:${edge.kind}`)).toEqual([
'start->agent-node-agent-sequential-analyst:direct',
'agent-node-agent-sequential-analyst->agent-node-agent-sequential-builder:direct',
'agent-node-agent-sequential-builder->agent-node-agent-sequential-reviewer:direct',
'agent-node-agent-sequential-reviewer->end:direct',
]);
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('builds a valid concurrent workflow from a pattern', () => {
const workflow = buildWorkflowFromPattern(requirePattern('concurrent'));
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-out')).toHaveLength(3);
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-in')).toHaveLength(3);
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('builds a valid handoff workflow from a pattern graph', () => {
const workflow = buildWorkflowFromPattern(requirePattern('handoff'));
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain('start->agent-node-agent-handoff-triage');
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
'agent-node-agent-handoff-triage->agent-node-agent-handoff-ux',
);
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
'agent-node-agent-handoff-runtime->agent-node-agent-handoff-triage',
);
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('builds a group-chat workflow with a loop approximation', () => {
const workflow = buildWorkflowFromPattern(requirePattern('group-chat'));
const loopEdge = workflow.graph.edges.find((edge) =>
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'agent-node-agent-group-writer');
const entryEdge = workflow.graph.edges.find((edge) =>
edge.source === 'start' && edge.target === 'agent-node-agent-group-writer');
const exitEdge = workflow.graph.edges.find((edge) =>
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'end');
expect(loopEdge).toEqual(expect.objectContaining({
source: 'agent-node-agent-group-reviewer',
target: 'agent-node-agent-group-writer',
isLoop: true,
maxIterations: 5,
condition: { type: 'always' },
}));
expect(entryEdge?.isLoop).not.toBe(true);
expect(exitEdge?.isLoop).not.toBe(true);
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('generates built-in workflow templates for available patterns only', () => {
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
expect(templates.map((template) => template.id)).toEqual([
'workflow-template-single',
'workflow-template-sequential',
'workflow-template-concurrent',
'workflow-template-handoff',
'workflow-template-group-chat',
]);
expect(templates.every((template) => template.source === 'builtin')).toBe(true);
expect(templates.every((template) => template.category === 'orchestration')).toBe(true);
});
test('round trips workflow yaml import and export', () => {
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
const exported = exportWorkflowDefinition(workflow, 'yaml');
const imported = importWorkflowDefinition(exported.content, 'yaml');
expect(exported.format).toBe('yaml');
expect(imported).toEqual(workflow);
});
test('exports mermaid flowcharts with expected edges', () => {
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
const exported = exportWorkflowDefinition(workflow, 'mermaid');
expect(exported.content.startsWith('flowchart LR')).toBe(true);
expect(exported.content).toContain('-->');
expect(exported.content).toContain('n0 --> n1');
});
});
+12 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
import { createWorkspaceSeed } from '@shared/domain/workspace';
describe('workspace seed', () => {
test('starts empty and seeds the built-in orchestration patterns with a shared timestamp', () => {
test('starts empty and seeds built-in patterns and workflow templates with a shared timestamp', () => {
const workspace = createWorkspaceSeed();
expect(workspace.projects).toEqual([]);
@@ -30,6 +30,13 @@ describe('workspace seed', () => {
'group-chat',
'magentic',
]);
expect(workspace.workflowTemplates.map((template) => template.id)).toEqual([
'workflow-template-single',
'workflow-template-sequential',
'workflow-template-concurrent',
'workflow-template-handoff',
'workflow-template-group-chat',
]);
for (const pattern of workspace.patterns) {
expect(pattern.createdAt).toBe(workspace.lastUpdatedAt);
@@ -41,5 +48,9 @@ describe('workspace seed', () => {
expect(magentic?.availability).toBe('unavailable');
expect(magentic?.unavailabilityReason).toContain('unsupported');
for (const template of workspace.workflowTemplates) {
expect(template.createdAt).toBe(workspace.lastUpdatedAt);
expect(template.updatedAt).toBe(workspace.lastUpdatedAt);
}
});
});