From 4971dcf9fc79e63fef9d17ba888d2025a3367bbb Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 21:11:59 +0200 Subject: [PATCH] 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> --- ARCHITECTURE.md | 6 + README.md | 4 +- src/main/AryxAppService.ts | 158 +++++++ src/main/ipc/registerIpcHandlers.ts | 24 +- src/main/persistence/workspaceRepository.ts | 16 + src/preload/index.ts | 5 + src/shared/contracts/channels.ts | 5 + src/shared/contracts/ipc.ts | 54 +++ src/shared/domain/workflow.ts | 4 +- src/shared/domain/workflowSerialization.ts | 186 ++++++++ src/shared/domain/workflowTemplate.ts | 444 ++++++++++++++++++++ src/shared/domain/workspace.ts | 6 + tests/main/appServiceWorkflow.test.ts | 59 +++ tests/renderer/sessionWorkspace.test.ts | 1 + tests/shared/sessionLibrary.test.ts | 2 + tests/shared/workflowTemplate.test.ts | 113 +++++ tests/shared/workspace.test.ts | 13 +- 17 files changed, 1094 insertions(+), 6 deletions(-) create mode 100644 src/shared/domain/workflowSerialization.ts create mode 100644 src/shared/domain/workflowTemplate.ts create mode 100644 tests/shared/workflowTemplate.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17f0f35..bce4ff8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md index 54a11e7..14b14a5 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 59c14af..b7f3a5a 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { return this.persistAndBroadcast(workspace); } + async saveWorkflowTemplate( + workflowId: string, + options?: { + templateId?: string; + name?: string; + description?: string; + category?: WorkflowTemplateCategory; + }, + ): Promise { + 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 { + 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 { + 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 { const workspace = await this.loadWorkspace(); const pattern = this.requirePattern(workspace, patternId); @@ -2250,6 +2374,15 @@ export class AryxAppService extends EventEmitter { 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 { 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, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 4cc117b..e00ff51 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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); diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index d71b7e7..c91da70 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 7ba593c..b49f96e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 96afc06..b78adf8 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 167061b..cf745db 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; deletePattern(patternId: string): Promise; saveWorkflow(input: SaveWorkflowInput): Promise; + saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise; deleteWorkflow(workflowId: string): Promise; listWorkflowReferences(workflowId: string): Promise; + createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise; + exportWorkflow(input: ExportWorkflowInput): Promise; + importWorkflow(input: ImportWorkflowInput): Promise; + upgradePatternToWorkflow(input: UpgradePatternToWorkflowInput): Promise; saveMcpServer(input: SaveMcpServerInput): Promise; deleteMcpServer(serverId: string): Promise; saveLspProfile(input: SaveLspProfileInput): Promise; diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts index b3870ed..ddb39f7 100644 --- a/src/shared/domain/workflow.ts +++ b/src/shared/domain/workflow.ts @@ -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.', }); } diff --git a/src/shared/domain/workflowSerialization.ts b/src/shared/domain/workflowSerialization.ts new file mode 100644 index 0000000..a9fe500 --- /dev/null +++ b/src/shared/domain/workflowSerialization.ts @@ -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; + const settings = candidate.settings as Partial | 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, '
'); +} + +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)); +} diff --git a/src/shared/domain/workflowTemplate.ts b/src/shared/domain/workflowTemplate.ts new file mode 100644 index 0000000..1f38814 --- /dev/null +++ b/src/shared/domain/workflowTemplate.ts @@ -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([ + 'orchestration', + 'data-pipeline', + 'human-in-loop', +]); + +const builtinTemplateIdsByPatternId: Record = { + '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 { + 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[] { + 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 | undefined, +): WorkflowDefinition { + const candidate = workflow ?? {}; + const settings = candidate.settings as Partial | 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, + excludedEdgeId?: string, +): Map { + const adjacency = new Map(); + 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, + startNodeId: string, + targetNodeId: string, + excludedEdgeId?: string, +): boolean { + if (startNodeId === targetNodeId) { + return true; + } + + const adjacency = buildWorkflowAdjacency(edges, excludedEdgeId); + const queue = [startNodeId]; + const visited = new Set(); + 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, + 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(); + 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, + }); +} diff --git a/src/shared/domain/workspace.ts b/src/shared/domain/workspace.ts index ffa8ce5..d01dd41 100644 --- a/src/shared/domain/workspace.ts +++ b/src/shared/domain/workspace.ts @@ -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, diff --git a/tests/main/appServiceWorkflow.test.ts b/tests/main/appServiceWorkflow.test.ts index 46c0eb0..c7478f1 100644 --- a/tests/main/appServiceWorkflow.test.ts +++ b/tests/main/appServiceWorkflow.test.ts @@ -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 = { diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index c7b0bea..680461f 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -12,6 +12,7 @@ describe('session workspace helpers', () => { projects: [], patterns: [], workflows: [], + workflowTemplates: [], settings: createWorkspaceSettings(), sessions: [ { diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index 6ede92d..9c41401 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -77,6 +77,7 @@ function createWorkspace(overrides?: Partial): 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 { return { ...workspace, workflows: overrides?.workflows ?? workspace.workflows, + workflowTemplates: overrides?.workflowTemplates ?? workspace.workflowTemplates, }; } diff --git a/tests/shared/workflowTemplate.test.ts b/tests/shared/workflowTemplate.test.ts new file mode 100644 index 0000000..88ed95c --- /dev/null +++ b/tests/shared/workflowTemplate.test.ts @@ -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'); + }); +}); diff --git a/tests/shared/workspace.test.ts b/tests/shared/workspace.test.ts index 14e69be..e526a1a 100644 --- a/tests/shared/workspace.test.ts +++ b/tests/shared/workspace.test.ts @@ -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); + } }); });