mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('session workspace helpers', () => {
|
||||
projects: [],
|
||||
patterns: [],
|
||||
workflows: [],
|
||||
workflowTemplates: [],
|
||||
settings: createWorkspaceSettings(),
|
||||
sessions: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user