mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat(workflows): add sub-workflow backend support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+120
-2
@@ -41,6 +41,7 @@ import {
|
||||
normalizeWorkflowDefinition,
|
||||
validateWorkflowDefinition,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowReference,
|
||||
} from '@shared/domain/workflow';
|
||||
import {
|
||||
normalizeWorkspaceAgentDefinition,
|
||||
@@ -644,6 +645,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
this.validateWorkflowReferences(workspace, candidate);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.workflows[existingIndex] = candidate;
|
||||
@@ -760,6 +762,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||
const references = this.listWorkflowReferencesInWorkspace(workspace, workflowId)
|
||||
.filter((reference) => reference.referencingWorkflowId !== workflowId);
|
||||
if (references.length > 0) {
|
||||
const blockingReference = references[0];
|
||||
throw new Error(
|
||||
`Workflow "${workflow.name}" cannot be deleted because workflow "${blockingReference.referencingWorkflowName}" references it from node "${blockingReference.nodeLabel}".`,
|
||||
);
|
||||
}
|
||||
|
||||
workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId);
|
||||
|
||||
if (workspace.selectedWorkflowId === workflowId) {
|
||||
@@ -769,6 +781,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
this.requireWorkflow(workspace, workflowId);
|
||||
return this.listWorkflowReferencesInWorkspace(workspace, workflowId);
|
||||
}
|
||||
|
||||
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||
@@ -934,7 +952,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||
const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog);
|
||||
const executionPattern = normalizePatternModels(this.buildResolvedWorkflowExecutionPattern(workspace, workflow), modelCatalog);
|
||||
|
||||
const session: SessionRecord = {
|
||||
id: createId('session'),
|
||||
@@ -1663,6 +1681,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
projectInstructions,
|
||||
pattern: patternForTurn,
|
||||
workflow: effectiveWorkflow,
|
||||
workflowLibrary: effectiveWorkflow ? workspace.workflows : undefined,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
promptInvocation,
|
||||
@@ -2248,7 +2267,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
||||
return {
|
||||
workflow,
|
||||
pattern: buildWorkflowExecutionPattern(workflow),
|
||||
pattern: this.buildResolvedWorkflowExecutionPattern(workspace, workflow),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2257,6 +2276,105 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
private buildResolvedWorkflowExecutionPattern(
|
||||
workspace: WorkspaceState,
|
||||
workflow: WorkflowDefinition,
|
||||
): PatternDefinition {
|
||||
return buildWorkflowExecutionPattern(workflow, {
|
||||
resolveWorkflow: (workflowId) => workspace.workflows.find((candidate) => candidate.id === workflowId),
|
||||
});
|
||||
}
|
||||
|
||||
private validateWorkflowReferences(
|
||||
workspace: WorkspaceState,
|
||||
workflow: WorkflowDefinition,
|
||||
): void {
|
||||
const workflowLibrary = new Map<string, WorkflowDefinition>();
|
||||
for (const candidate of workspace.workflows) {
|
||||
if (candidate.id !== workflow.id) {
|
||||
workflowLibrary.set(candidate.id, candidate);
|
||||
}
|
||||
}
|
||||
workflowLibrary.set(workflow.id, workflow);
|
||||
|
||||
const visitWorkflow = (
|
||||
currentWorkflow: WorkflowDefinition,
|
||||
path: string[],
|
||||
visitedInlineWorkflows: Set<WorkflowDefinition>,
|
||||
): void => {
|
||||
for (const node of currentWorkflow.graph.nodes) {
|
||||
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { inlineWorkflow, workflowId } = node.config;
|
||||
if (workflowId) {
|
||||
const referencedWorkflow = workflowLibrary.get(workflowId);
|
||||
if (!referencedWorkflow) {
|
||||
throw new Error(
|
||||
`Sub-workflow node "${node.label || node.id}" references unknown workflow "${workflowId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (path.includes(workflowId)) {
|
||||
throw new Error(
|
||||
`Saving workflow "${workflow.name}" would create a circular sub-workflow reference: ${[...path, workflowId].join(' -> ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
visitWorkflow(referencedWorkflow, [...path, workflowId], visitedInlineWorkflows);
|
||||
}
|
||||
|
||||
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||
visitedInlineWorkflows.add(inlineWorkflow);
|
||||
visitWorkflow(inlineWorkflow, path, visitedInlineWorkflows);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visitWorkflow(workflow, [workflow.id], new Set<WorkflowDefinition>());
|
||||
}
|
||||
|
||||
private listWorkflowReferencesInWorkspace(
|
||||
workspace: WorkspaceState,
|
||||
workflowId: string,
|
||||
): WorkflowReference[] {
|
||||
const references: WorkflowReference[] = [];
|
||||
|
||||
const visitWorkflow = (
|
||||
referencingWorkflow: WorkflowDefinition,
|
||||
currentWorkflow: WorkflowDefinition,
|
||||
visitedInlineWorkflows: Set<WorkflowDefinition>,
|
||||
): void => {
|
||||
for (const node of currentWorkflow.graph.nodes) {
|
||||
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { inlineWorkflow, workflowId: referencedWorkflowId } = node.config;
|
||||
if (referencedWorkflowId === workflowId) {
|
||||
references.push({
|
||||
referencingWorkflowId: referencingWorkflow.id,
|
||||
referencingWorkflowName: referencingWorkflow.name,
|
||||
nodeId: node.id,
|
||||
nodeLabel: node.label || node.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||
visitedInlineWorkflows.add(inlineWorkflow);
|
||||
visitWorkflow(referencingWorkflow, inlineWorkflow, visitedInlineWorkflows);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const referencingWorkflow of workspace.workflows) {
|
||||
visitWorkflow(referencingWorkflow, referencingWorkflow, new Set<WorkflowDefinition>());
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||
if (!session) {
|
||||
|
||||
@@ -116,6 +116,9 @@ export function registerIpcHandlers(
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
||||
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
||||
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
|
||||
service.listWorkflowReferences(workflowId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||
const result = await service.setTheme(theme);
|
||||
applyTitleBarTheme(window, theme);
|
||||
|
||||
@@ -134,11 +134,15 @@ export class SidecarClient {
|
||||
});
|
||||
}
|
||||
|
||||
async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise<unknown> {
|
||||
async validateWorkflow(
|
||||
workflow: ValidateWorkflowCommand['workflow'],
|
||||
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
|
||||
): Promise<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-workflow',
|
||||
requestId: `validate-workflow-${Date.now()}`,
|
||||
workflow,
|
||||
workflowLibrary,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const api: ElectronApi = {
|
||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
||||
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
||||
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId),
|
||||
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
||||
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ipcChannels = {
|
||||
setPatternFavorite: 'patterns:set-favorite',
|
||||
saveWorkflow: 'workflows:save',
|
||||
deleteWorkflow: 'workflows:delete',
|
||||
listWorkflowReferences: 'workflows:list-references',
|
||||
createWorkflowSession: 'workflows:create-session',
|
||||
setTheme: 'settings:set-theme',
|
||||
setTerminalHeight: 'settings:set-terminal-height',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
@@ -286,6 +286,7 @@ export interface ElectronApi {
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||
listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]>;
|
||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface ValidateWorkflowCommand {
|
||||
type: 'validate-workflow';
|
||||
requestId: string;
|
||||
workflow: WorkflowDefinition;
|
||||
workflowLibrary?: WorkflowDefinition[];
|
||||
}
|
||||
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
@@ -97,6 +98,7 @@ export interface RunTurnCommand {
|
||||
projectInstructions?: string;
|
||||
pattern: PatternDefinition;
|
||||
workflow?: WorkflowDefinition;
|
||||
workflowLibrary?: WorkflowDefinition[];
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
promptInvocation?: ProjectPromptInvocation;
|
||||
|
||||
+145
-10
@@ -159,7 +159,18 @@ export interface WorkflowValidationIssue {
|
||||
edgeId?: string;
|
||||
}
|
||||
|
||||
const executableNodeKinds = new Set<WorkflowNodeKind>(['start', 'end', 'agent']);
|
||||
export interface WorkflowReference {
|
||||
referencingWorkflowId: string;
|
||||
referencingWorkflowName: string;
|
||||
nodeId: string;
|
||||
nodeLabel: string;
|
||||
}
|
||||
|
||||
export interface WorkflowResolutionOptions {
|
||||
resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined;
|
||||
}
|
||||
|
||||
const executableNodeKinds = new Set<WorkflowNodeKind>(['start', 'end', 'agent', 'sub-workflow']);
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
@@ -362,9 +373,94 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgen
|
||||
});
|
||||
}
|
||||
|
||||
function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefinition['mode'] {
|
||||
const hasFanEdges = workflow.graph.edges.some((edge) => edge.kind !== 'direct');
|
||||
const agentCount = resolveWorkflowAgents(workflow).length;
|
||||
function hasWorkflowExecutionFanEdges(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
||||
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
||||
): boolean {
|
||||
if (workflow.graph.edges.some((edge) => edge.kind !== 'direct')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const node of workflow.graph.nodes) {
|
||||
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { inlineWorkflow, workflowId } = node.config;
|
||||
if (inlineWorkflow) {
|
||||
if (!visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||
visitedInlineWorkflows.add(inlineWorkflow);
|
||||
if (hasWorkflowExecutionFanEdges(inlineWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) {
|
||||
const referencedWorkflow = options.resolveWorkflow(workflowId);
|
||||
if (referencedWorkflow) {
|
||||
visitedReferencedWorkflowIds.add(workflowId);
|
||||
if (hasWorkflowExecutionFanEdges(referencedWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveWorkflowExecutionAgents(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
||||
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
||||
): PatternAgentDefinition[] {
|
||||
const agents = [...resolveWorkflowAgents(workflow)];
|
||||
|
||||
for (const node of workflow.graph.nodes) {
|
||||
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { inlineWorkflow, workflowId } = node.config;
|
||||
if (inlineWorkflow) {
|
||||
if (!visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||
visitedInlineWorkflows.add(inlineWorkflow);
|
||||
agents.push(...resolveWorkflowExecutionAgents(
|
||||
inlineWorkflow,
|
||||
options,
|
||||
visitedReferencedWorkflowIds,
|
||||
visitedInlineWorkflows,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) {
|
||||
const referencedWorkflow = options.resolveWorkflow(workflowId);
|
||||
if (referencedWorkflow) {
|
||||
visitedReferencedWorkflowIds.add(workflowId);
|
||||
agents.push(...resolveWorkflowExecutionAgents(
|
||||
referencedWorkflow,
|
||||
options,
|
||||
visitedReferencedWorkflowIds,
|
||||
visitedInlineWorkflows,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
function inferWorkflowPatternMode(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
): PatternDefinition['mode'] {
|
||||
const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options);
|
||||
const agentCount = resolveWorkflowExecutionAgents(workflow, options).length;
|
||||
if (hasFanEdges) {
|
||||
return 'concurrent';
|
||||
}
|
||||
@@ -372,13 +468,16 @@ function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefiniti
|
||||
return agentCount <= 1 ? 'single' : 'sequential';
|
||||
}
|
||||
|
||||
export function buildWorkflowExecutionPattern(workflow: WorkflowDefinition): PatternDefinition {
|
||||
const agents = resolveWorkflowAgents(workflow);
|
||||
export function buildWorkflowExecutionPattern(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
): PatternDefinition {
|
||||
const agents = resolveWorkflowExecutionAgents(workflow, options);
|
||||
const pattern: PatternDefinition = {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
mode: inferWorkflowPatternMode(workflow),
|
||||
mode: inferWorkflowPatternMode(workflow, options),
|
||||
availability: 'available',
|
||||
maxIterations: workflow.settings.maxIterations ?? 5,
|
||||
approvalPolicy: workflow.settings.approvalPolicy,
|
||||
@@ -608,6 +707,39 @@ function validateEdgeCondition(edge: WorkflowEdge, issues: WorkflowValidationIss
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubWorkflowNode(node: WorkflowNode, issues: WorkflowValidationIssue[]): void {
|
||||
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWorkflowId = Boolean(node.config.workflowId);
|
||||
const hasInlineWorkflow = Boolean(node.config.inlineWorkflow);
|
||||
if (hasWorkflowId === hasInlineWorkflow) {
|
||||
addIssue(issues, {
|
||||
level: 'error',
|
||||
field: 'graph.nodes.config',
|
||||
nodeId: node.id,
|
||||
message: 'Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.config.inlineWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const inlineIssue of validateWorkflowDefinition(node.config.inlineWorkflow)) {
|
||||
addIssue(issues, {
|
||||
...inlineIssue,
|
||||
field: inlineIssue.field
|
||||
? `graph.nodes.config.inlineWorkflow.${inlineIssue.field}`
|
||||
: 'graph.nodes.config.inlineWorkflow',
|
||||
nodeId: node.id,
|
||||
message: `Inline workflow for node "${node.label || node.id}": ${inlineIssue.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] {
|
||||
const normalized = normalizeWorkflowDefinition(workflow);
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
@@ -685,6 +817,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateSubWorkflowNode(node, issues);
|
||||
}
|
||||
for (const edge of normalized.graph.edges) {
|
||||
if (!edge.id) {
|
||||
@@ -725,7 +859,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
||||
}
|
||||
const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start');
|
||||
const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end');
|
||||
const agentNodes = normalized.graph.nodes.filter((node) => node.kind === 'agent');
|
||||
const executableWorkNodes = normalized.graph.nodes.filter((node) =>
|
||||
node.kind === 'agent' || node.kind === 'sub-workflow');
|
||||
|
||||
if (startNodes.length !== 1) {
|
||||
addIssue(issues, {
|
||||
@@ -743,11 +878,11 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
||||
});
|
||||
}
|
||||
|
||||
if (agentNodes.length === 0) {
|
||||
if (executableWorkNodes.length === 0) {
|
||||
addIssue(issues, {
|
||||
level: 'error',
|
||||
field: 'graph.nodes',
|
||||
message: 'Workflow graphs must contain at least one agent node.',
|
||||
message: 'Workflow graphs must contain at least one agent or sub-workflow node.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user