feat(workflows): add sub-workflow backend support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 19:31:56 +02:00
co-authored by Copilot
parent ea9444ddac
commit 41e74c2fa9
18 changed files with 1064 additions and 29 deletions
+207
View File
@@ -0,0 +1,207 @@
import { describe, expect, mock, test } from 'bun:test';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
mock.module('electron', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
};
return {
...electronMock,
default: electronMock,
};
});
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { AryxAppService } = await import('@main/AryxAppService');
function createService(workspace: WorkspaceState): InstanceType<typeof AryxAppService> {
const service = new AryxAppService();
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
return service;
}
function createAgentWorkflow(id: string, name: string, agentId: string): WorkflowDefinition {
return {
id,
name,
description: `${name} description`,
createdAt: '2026-04-05T00:00:00.000Z',
updatedAt: '2026-04-05T00:00:00.000Z',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: agentId,
kind: 'agent',
label: name,
position: { x: 200, y: 0 },
order: 0,
config: {
kind: 'agent',
id: agentId,
name,
description: `${name} description`,
instructions: `Run ${name}`,
model: 'gpt-5.4',
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'edge-start-agent', source: 'start', target: agentId, kind: 'direct' },
{ id: 'edge-agent-end', source: agentId, target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
};
}
function createSubWorkflow(
id: string,
name: string,
config: { workflowId?: string; inlineWorkflow?: WorkflowDefinition },
): WorkflowDefinition {
return {
id,
name,
description: `${name} description`,
createdAt: '2026-04-05T00:00:00.000Z',
updatedAt: '2026-04-05T00:00:00.000Z',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: 'sub-workflow',
kind: 'sub-workflow',
label: 'Nested Workflow',
position: { x: 200, y: 0 },
config: {
kind: 'sub-workflow',
workflowId: config.workflowId,
inlineWorkflow: config.inlineWorkflow,
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' },
{ id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
};
}
describe('AryxAppService sub-workflow operations', () => {
test('rejects saving workflows with missing referenced workflows', async () => {
const workspace = createWorkspaceSeed();
const service = createService(workspace);
await expect(service.saveWorkflow(createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' })))
.rejects.toThrow('references unknown workflow "missing-child"');
});
test('rejects circular workflow references across saved workflows', async () => {
const workspace = createWorkspaceSeed();
workspace.workflows.push(createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' }));
const service = createService(workspace);
await expect(service.saveWorkflow(createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' })))
.rejects.toThrow('circular sub-workflow reference');
});
test('prevents deleting workflows that are still referenced', async () => {
const workspace = createWorkspaceSeed();
workspace.workflows.push(
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
);
const service = createService(workspace);
await expect(service.deleteWorkflow('child')).rejects.toThrow('cannot be deleted');
});
test('lists workflow references for referenced sub-workflows', async () => {
const workspace = createWorkspaceSeed();
workspace.workflows.push(
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
createSubWorkflow('inline-parent', 'Inline Parent', {
inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'child' }),
}),
);
const service = createService(workspace);
const references = await service.listWorkflowReferences('child');
expect(references).toEqual([
{
referencingWorkflowId: 'parent',
referencingWorkflowName: 'Parent Workflow',
nodeId: 'sub-workflow',
nodeLabel: 'Nested Workflow',
},
{
referencingWorkflowId: 'inline-parent',
referencingWorkflowName: 'Inline Parent',
nodeId: 'sub-workflow',
nodeLabel: 'Nested Workflow',
},
]);
});
test('creates workflow-backed sessions using nested sub-workflow agents for model defaults', async () => {
const workspace = createWorkspaceSeed();
const project = {
id: 'project-1',
name: 'Project',
path: 'C:\\workspace\\project-1',
addedAt: '2026-04-05T00:00:00.000Z',
};
workspace.projects.push(project);
workspace.workflows.push(
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
);
const service = createService(workspace);
const result = await service.createWorkflowSession(project.id, 'parent');
const session = result.sessions[0];
expect(session?.workflowId).toBe('parent');
expect(session?.sessionModelConfig).toEqual({
model: 'gpt-5.4',
reasoningEffort: 'medium',
});
});
});
+112
View File
@@ -60,6 +60,57 @@ function createWorkflow(): WorkflowDefinition {
};
}
function createReferencedSubWorkflow(): WorkflowDefinition {
return {
id: 'child-workflow',
name: 'Child Workflow',
description: 'Nested child workflow.',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
graph: {
nodes: [
{
id: 'start',
kind: 'start',
label: 'Start',
position: { x: 0, y: 0 },
config: { kind: 'start' },
},
{
id: 'agent-reviewer',
kind: 'agent',
label: 'Reviewer Agent',
position: { x: 200, y: 0 },
order: 0,
config: {
kind: 'agent',
id: 'agent-reviewer',
name: 'Reviewer Agent',
description: 'Reviews the task.',
instructions: 'Review the task.',
model: 'gpt-5.4',
},
},
{
id: 'end',
kind: 'end',
label: 'End',
position: { x: 400, y: 0 },
config: { kind: 'end' },
},
],
edges: [
{ id: 'edge-start-reviewer', source: 'start', target: 'agent-reviewer', kind: 'direct' },
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
};
}
describe('workflow validation', () => {
test('accepts a simple start-agent-end workflow', () => {
expect(validateWorkflowDefinition(createWorkflow())).toEqual([]);
@@ -84,6 +135,43 @@ describe('workflow validation', () => {
issue.message.includes('not executable yet'))).toBe(true);
});
test('accepts sub-workflow nodes with inline workflows', () => {
const inlineWorkflow = createReferencedSubWorkflow();
const workflow = createWorkflow();
workflow.graph.nodes[1] = {
id: 'sub-workflow',
kind: 'sub-workflow',
label: 'Nested Workflow',
position: { x: 200, y: 0 },
config: {
kind: 'sub-workflow',
inlineWorkflow,
},
};
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('rejects sub-workflow nodes without exactly one workflow source', () => {
const workflow = createWorkflow();
workflow.graph.nodes[1] = {
id: 'sub-workflow',
kind: 'sub-workflow',
label: 'Nested Workflow',
position: { x: 200, y: 0 },
config: {
kind: 'sub-workflow',
},
};
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
const issues = validateWorkflowDefinition(workflow);
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
});
test('builds a synthetic execution pattern from workflow agents', () => {
const pattern = buildWorkflowExecutionPattern(createWorkflow());
@@ -93,6 +181,30 @@ describe('workflow validation', () => {
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
});
test('includes referenced sub-workflow agents when building execution patterns', () => {
const childWorkflow = createReferencedSubWorkflow();
const workflow = createWorkflow();
workflow.graph.nodes[1] = {
id: 'sub-workflow',
kind: 'sub-workflow',
label: 'Nested Workflow',
position: { x: 200, y: 0 },
config: {
kind: 'sub-workflow',
workflowId: childWorkflow.id,
},
};
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
const pattern = buildWorkflowExecutionPattern(workflow, {
resolveWorkflow: (workflowId) => workflowId === childWorkflow.id ? childWorkflow : undefined,
});
expect(pattern.mode).toBe('single');
expect(pattern.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
});
test('accepts simple property and expression conditions', () => {
const workflow = createWorkflow();
workflow.graph.edges[0] = {