mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +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
|
- connected projects
|
||||||
- orchestration patterns
|
- orchestration patterns
|
||||||
|
- workflow templates
|
||||||
|
- workflows
|
||||||
- sessions
|
- sessions
|
||||||
- settings
|
- settings
|
||||||
- run history
|
- 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.
|
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
|
### Sessions
|
||||||
|
|
||||||
A session is the working unit of the product. It binds together:
|
A session is the working unit of the product. It binds together:
|
||||||
@@ -190,6 +194,8 @@ Typical examples:
|
|||||||
|
|
||||||
- load workspace
|
- load workspace
|
||||||
- create session
|
- create session
|
||||||
|
- create a workflow from a template
|
||||||
|
- export or import a workflow definition
|
||||||
- send message
|
- send message
|
||||||
- update theme
|
- update theme
|
||||||
- create or restart the integrated terminal
|
- 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
|
## 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.
|
- **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.
|
- **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.
|
- **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 |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| Orchestration patterns | Single, sequential, concurrent, handoff, and group-chat agent flows |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Plan review & questions | Agents propose plans and ask clarifying questions before acting |
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ import {
|
|||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
type WorkflowReference,
|
type WorkflowReference,
|
||||||
} from '@shared/domain/workflow';
|
} 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 {
|
import {
|
||||||
normalizeWorkspaceAgentDefinition,
|
normalizeWorkspaceAgentDefinition,
|
||||||
resolvePatternAgents,
|
resolvePatternAgents,
|
||||||
@@ -657,6 +671,116 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
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> {
|
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const pattern = this.requirePattern(workspace, patternId);
|
const pattern = this.requirePattern(workspace, patternId);
|
||||||
@@ -2250,6 +2374,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return pattern;
|
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 {
|
private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
|
||||||
const workflow = workspace.workflows.find((current) => current.id === workflowId);
|
const workflow = workspace.workflows.find((current) => current.id === workflowId);
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
@@ -2259,6 +2392,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return workflow;
|
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(
|
private resolveSessionExecutionDefinition(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { ipcChannels } from '@shared/contracts/channels';
|
|||||||
import type {
|
import type {
|
||||||
BranchSessionInput,
|
BranchSessionInput,
|
||||||
CancelSessionTurnInput,
|
CancelSessionTurnInput,
|
||||||
|
CommitProjectGitChangesInput,
|
||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
|
CreateWorkflowFromTemplateInput,
|
||||||
CreateWorkflowSessionInput,
|
CreateWorkflowSessionInput,
|
||||||
CreateProjectGitBranchInput,
|
CreateProjectGitBranchInput,
|
||||||
DismissSessionMcpAuthInput,
|
DismissSessionMcpAuthInput,
|
||||||
@@ -14,13 +16,15 @@ import type {
|
|||||||
DeleteSessionInput,
|
DeleteSessionInput,
|
||||||
DiscardSessionRunGitChangesInput,
|
DiscardSessionRunGitChangesInput,
|
||||||
EditAndResendSessionMessageInput,
|
EditAndResendSessionMessageInput,
|
||||||
CommitProjectGitChangesInput,
|
ExportWorkflowInput,
|
||||||
|
ImportWorkflowInput,
|
||||||
ProjectGitDetailsInput,
|
ProjectGitDetailsInput,
|
||||||
ProjectGitFilePreviewInput,
|
ProjectGitFilePreviewInput,
|
||||||
ProjectGitFileSelectionInput,
|
ProjectGitFileSelectionInput,
|
||||||
ProjectGitInput,
|
ProjectGitInput,
|
||||||
PullProjectGitInput,
|
PullProjectGitInput,
|
||||||
RegenerateSessionMessageInput,
|
RegenerateSessionMessageInput,
|
||||||
|
ResolveWorkspaceDiscoveredToolingInput,
|
||||||
StartSessionMcpAuthInput,
|
StartSessionMcpAuthInput,
|
||||||
SuggestProjectGitCommitMessageInput,
|
SuggestProjectGitCommitMessageInput,
|
||||||
SwitchProjectGitBranchInput,
|
SwitchProjectGitBranchInput,
|
||||||
@@ -31,11 +35,11 @@ import type {
|
|||||||
ResolveProjectDiscoveredToolingInput,
|
ResolveProjectDiscoveredToolingInput,
|
||||||
ResolveSessionApprovalInput,
|
ResolveSessionApprovalInput,
|
||||||
ResolveSessionUserInputInput,
|
ResolveSessionUserInputInput,
|
||||||
ResolveWorkspaceDiscoveredToolingInput,
|
|
||||||
SaveLspProfileInput,
|
SaveLspProfileInput,
|
||||||
SaveMcpServerInput,
|
SaveMcpServerInput,
|
||||||
SavePatternInput,
|
SavePatternInput,
|
||||||
SaveWorkflowInput,
|
SaveWorkflowInput,
|
||||||
|
SaveWorkflowTemplateInput,
|
||||||
SaveWorkspaceAgentInput,
|
SaveWorkspaceAgentInput,
|
||||||
SendSessionMessageInput,
|
SendSessionMessageInput,
|
||||||
SetPatternFavoriteInput,
|
SetPatternFavoriteInput,
|
||||||
@@ -49,6 +53,7 @@ import type {
|
|||||||
UpdateSessionModelConfigInput,
|
UpdateSessionModelConfigInput,
|
||||||
UpdateSessionApprovalSettingsInput,
|
UpdateSessionApprovalSettingsInput,
|
||||||
UpdateSessionToolingInput,
|
UpdateSessionToolingInput,
|
||||||
|
UpgradePatternToWorkflowInput,
|
||||||
} from '@shared/contracts/ipc';
|
} from '@shared/contracts/ipc';
|
||||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||||
@@ -115,10 +120,25 @@ export function registerIpcHandlers(
|
|||||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
service.setPatternFavorite(input.patternId, input.isFavorite),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
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.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
||||||
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
|
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
|
||||||
service.listWorkflowReferences(workflowId),
|
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) => {
|
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||||
const result = await service.setTheme(theme);
|
const result = await service.setTheme(theme);
|
||||||
applyTitleBarTheme(window, theme);
|
applyTitleBarTheme(window, theme);
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
normalizeWorkspaceSettings,
|
normalizeWorkspaceSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
import {
|
||||||
|
createBuiltinWorkflowTemplates,
|
||||||
|
normalizeWorkflowTemplateDefinition,
|
||||||
|
type WorkflowTemplateDefinition,
|
||||||
|
} from '@shared/domain/workflowTemplate';
|
||||||
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
|
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
applyDefaultToolApprovalPolicy,
|
applyDefaultToolApprovalPolicy,
|
||||||
@@ -58,6 +63,16 @@ function mergePatterns(existingPatterns: PatternDefinition[], deletedBuiltinIds:
|
|||||||
return [...mergedBuiltins, ...customPatterns];
|
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 {
|
export class WorkspaceRepository {
|
||||||
readonly filePath = getWorkspaceFilePath();
|
readonly filePath = getWorkspaceFilePath();
|
||||||
readonly scratchpadPath = getScratchpadDirectoryPath();
|
readonly scratchpadPath = getScratchpadDirectoryPath();
|
||||||
@@ -122,6 +137,7 @@ export class WorkspaceRepository {
|
|||||||
graph: resolvePatternGraph(pattern),
|
graph: resolvePatternGraph(pattern),
|
||||||
})),
|
})),
|
||||||
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
|
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
|
||||||
|
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
|
||||||
projects,
|
projects,
|
||||||
sessions,
|
sessions,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ const api: ElectronApi = {
|
|||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||||
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
||||||
|
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
|
||||||
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
||||||
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, 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),
|
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
||||||
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export const ipcChannels = {
|
|||||||
deletePattern: 'patterns:delete',
|
deletePattern: 'patterns:delete',
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
saveWorkflow: 'workflows:save',
|
saveWorkflow: 'workflows:save',
|
||||||
|
saveWorkflowTemplate: 'workflows:save-template',
|
||||||
deleteWorkflow: 'workflows:delete',
|
deleteWorkflow: 'workflows:delete',
|
||||||
listWorkflowReferences: 'workflows:list-references',
|
listWorkflowReferences: 'workflows:list-references',
|
||||||
|
createWorkflowFromTemplate: 'workflows:create-from-template',
|
||||||
|
exportWorkflow: 'workflows:export',
|
||||||
|
importWorkflow: 'workflows:import',
|
||||||
|
upgradePatternToWorkflow: 'workflows:upgrade-pattern',
|
||||||
createWorkflowSession: 'workflows:create-session',
|
createWorkflowSession: 'workflows:create-session',
|
||||||
setTheme: 'settings:set-theme',
|
setTheme: 'settings:set-theme',
|
||||||
setTerminalHeight: 'settings:set-terminal-height',
|
setTerminalHeight: 'settings:set-terminal-height',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
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 { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
||||||
import type {
|
import type {
|
||||||
ProjectGitBranchSummary,
|
ProjectGitBranchSummary,
|
||||||
@@ -42,6 +44,53 @@ export interface SaveWorkflowInput {
|
|||||||
workflow: WorkflowDefinition;
|
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 {
|
export interface SendSessionMessageInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -285,8 +334,13 @@ export interface ElectronApi {
|
|||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||||
|
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
|
||||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||||
listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]>;
|
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>;
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -1084,12 +1084,12 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!edge.condition || edge.condition.type === 'always') {
|
if (!edge.condition) {
|
||||||
addIssue(issues, {
|
addIssue(issues, {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
field: 'graph.edges.condition',
|
field: 'graph.edges.condition',
|
||||||
edgeId: edge.id,
|
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 { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
||||||
|
import {
|
||||||
|
createBuiltinWorkflowTemplates,
|
||||||
|
type WorkflowTemplateDefinition,
|
||||||
|
} from '@shared/domain/workflowTemplate';
|
||||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ export interface WorkspaceState {
|
|||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
workflows: WorkflowDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
|
workflowTemplates: WorkflowTemplateDefinition[];
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
settings: WorkspaceSettings;
|
settings: WorkspaceSettings;
|
||||||
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
|
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
|
||||||
@@ -29,6 +34,7 @@ export function createWorkspaceSeed(): WorkspaceState {
|
|||||||
projects: [],
|
projects: [],
|
||||||
patterns: createBuiltinPatterns(timestamp),
|
patterns: createBuiltinPatterns(timestamp),
|
||||||
workflows: [],
|
workflows: [],
|
||||||
|
workflowTemplates: createBuiltinWorkflowTemplates(timestamp),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
lastUpdatedAt: timestamp,
|
lastUpdatedAt: timestamp,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
||||||
|
import { exportWorkflowDefinition } from '@shared/domain/workflowSerialization';
|
||||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
@@ -94,6 +95,64 @@ describe('AryxAppService workflow operations', () => {
|
|||||||
expect(result.selectedWorkflowId).toBe('workflow-test');
|
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 () => {
|
test('creates workflow-backed sessions', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const project = {
|
const project = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('session workspace helpers', () => {
|
|||||||
projects: [],
|
projects: [],
|
||||||
patterns: [],
|
patterns: [],
|
||||||
workflows: [],
|
workflows: [],
|
||||||
|
workflowTemplates: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
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' })],
|
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
||||||
workflows: [],
|
workflows: [],
|
||||||
|
workflowTemplates: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
createSession(),
|
createSession(),
|
||||||
@@ -117,6 +118,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
return {
|
return {
|
||||||
...workspace,
|
...workspace,
|
||||||
workflows: overrides?.workflows ?? workspace.workflows,
|
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';
|
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
||||||
|
|
||||||
describe('workspace seed', () => {
|
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();
|
const workspace = createWorkspaceSeed();
|
||||||
|
|
||||||
expect(workspace.projects).toEqual([]);
|
expect(workspace.projects).toEqual([]);
|
||||||
@@ -30,6 +30,13 @@ describe('workspace seed', () => {
|
|||||||
'group-chat',
|
'group-chat',
|
||||||
'magentic',
|
'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) {
|
for (const pattern of workspace.patterns) {
|
||||||
expect(pattern.createdAt).toBe(workspace.lastUpdatedAt);
|
expect(pattern.createdAt).toBe(workspace.lastUpdatedAt);
|
||||||
@@ -41,5 +48,9 @@ describe('workspace seed', () => {
|
|||||||
|
|
||||||
expect(magentic?.availability).toBe('unavailable');
|
expect(magentic?.availability).toBe('unavailable');
|
||||||
expect(magentic?.unavailabilityReason).toContain('unsupported');
|
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