mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
refactor: extract AryxAppService into focused service delegates
Extract seven responsibility clusters from the 3,466-line AryxAppService monolith into focused service classes under src/main/services/: - WorkflowManager: workflow CRUD, templates, validation, resolution - McpProbeManager: MCP server probing, OAuth pre-auth, probe queuing - DiscoveredToolingSyncService: tooling/customization scanning, watchers - GitContextManager: git refresh orchestration, context updates, mutations - CheckpointRecoveryManager: checkpoint retry/recovery state handling - ApprovalCoordinator: approval/user-input/plan-review/OAuth state machine - SessionTurnExecutor: turn execution, streaming deltas, finalization AryxAppService remains the public facade consumed by IPC handlers but now delegates internally via constructor-injected service instances. A new AppServiceDeps type provides a clean dependency injection seam for testing. The facade is reduced from 3,466 to 2,572 lines. All existing tests pass with two test files migrated to constructor DI (appServiceGitRefresh, appServiceMcpProbing). New focused tests added for WorkflowManager and the DI seam itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
|
||||
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');
|
||||
|
||||
describe('AryxAppService dependency injection', () => {
|
||||
test('uses injected sidecar dependencies for capability lookups', async () => {
|
||||
const capabilities: SidecarCapabilities = {
|
||||
runtime: 'dotnet-maf',
|
||||
modes: {
|
||||
single: { available: true },
|
||||
sequential: { available: true },
|
||||
concurrent: { available: true },
|
||||
handoff: { available: true },
|
||||
'group-chat': { available: true },
|
||||
magentic: { available: true },
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
},
|
||||
],
|
||||
runtimeTools: [],
|
||||
connection: {
|
||||
status: 'ready',
|
||||
summary: 'Ready',
|
||||
checkedAt: '2026-04-07T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
const service = new AryxAppService({
|
||||
sidecar: {
|
||||
describeCapabilities: async () => capabilities,
|
||||
dispose: async () => undefined,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await expect(service.describeSidecarCapabilities()).resolves.toEqual(capabilities);
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,19 @@ function createService(
|
||||
runTurn?: (command: RunTurnCommand) => Promise<[]>;
|
||||
},
|
||||
): InstanceType<typeof AryxAppService> {
|
||||
const service = new AryxAppService();
|
||||
const service = new AryxAppService({
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: async (projectPath: string, scannedAt: string) => {
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath: string) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
internals.loadWorkspace = async () => {
|
||||
internals.workspace = workspace;
|
||||
@@ -216,33 +228,11 @@ function createService(
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
).sidecar = {
|
||||
runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [],
|
||||
resolveApproval: async () => undefined,
|
||||
resolveUserInput: async () => undefined,
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: (
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).gitService = {
|
||||
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -70,26 +70,27 @@ function createService(workspace: WorkspaceState): {
|
||||
service: InstanceType<typeof AryxAppService>;
|
||||
snapshots: WorkspaceState[];
|
||||
} {
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
const snapshots: WorkspaceState[] = [];
|
||||
const service = new AryxAppService({
|
||||
probeMcpServers: (async (
|
||||
servers: Array<{ id: string }>,
|
||||
_tokenLookup?: (serverUrl: string) => string | undefined,
|
||||
onResult?: (result: MockProbeResult) => void | Promise<void>,
|
||||
) => {
|
||||
probeCalls.push(servers.map((server) => server.id));
|
||||
for (const result of probeResults) {
|
||||
await onResult?.(result);
|
||||
}
|
||||
return probeResults;
|
||||
}) as never,
|
||||
});
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
|
||||
internals.loadWorkspace = async () => workspace;
|
||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => {
|
||||
snapshots.push(cloneWorkspaceState(nextWorkspace));
|
||||
return nextWorkspace;
|
||||
};
|
||||
internals.probeMcpServers = async (
|
||||
servers: Array<{ id: string }>,
|
||||
_tokenLookup?: (serverUrl: string) => string | undefined,
|
||||
onResult?: (result: MockProbeResult) => void | Promise<void>,
|
||||
) => {
|
||||
probeCalls.push(servers.map((server) => server.id));
|
||||
for (const result of probeResults) {
|
||||
await onResult?.(result);
|
||||
}
|
||||
return probeResults;
|
||||
};
|
||||
|
||||
return { service, snapshots };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { exportWorkflowDefinition } from '@shared/domain/workflowSerialization';
|
||||
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
||||
|
||||
import { WorkflowManager } from '@main/services/workflowManager';
|
||||
|
||||
function createWorkflow(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'workflow-test',
|
||||
name: 'Workflow Test',
|
||||
description: 'Simple workflow',
|
||||
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: 'agent-primary',
|
||||
kind: 'agent',
|
||||
label: 'Primary',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-primary',
|
||||
name: 'Primary',
|
||||
description: 'Main agent',
|
||||
instructions: 'Help the user',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: 'start', target: 'agent-primary', kind: 'direct' },
|
||||
{ id: 'e2', source: 'agent-primary', 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('WorkflowManager', () => {
|
||||
test('saves workflows into workspace state and selects them', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
|
||||
const result = manager.saveWorkflow(workspace, createWorkflow());
|
||||
|
||||
expect(result.workflows.some((workflow) => workflow.id === 'workflow-test')).toBe(true);
|
||||
expect(result.selectedWorkflowId).toBe('workflow-test');
|
||||
});
|
||||
|
||||
test('creates workflow templates and workflows from templates', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
manager.saveWorkflow(workspace, createWorkflow());
|
||||
|
||||
manager.saveWorkflowTemplate(workspace, 'workflow-test', {
|
||||
name: 'Saved Template',
|
||||
description: 'From workflow',
|
||||
category: 'human-in-loop',
|
||||
});
|
||||
|
||||
const template = workspace.workflowTemplates.find((candidate) => candidate.name === 'Saved Template');
|
||||
expect(template).toBeDefined();
|
||||
|
||||
manager.createWorkflowFromTemplate(workspace, template!.id, { name: 'Template Copy' });
|
||||
|
||||
const createdWorkflow = workspace.workflows.find((workflow) => workflow.name === 'Template Copy');
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
expect(workspace.selectedWorkflowId).toBe(createdWorkflow?.id);
|
||||
});
|
||||
|
||||
test('imports exported yaml workflows', () => {
|
||||
const manager = new WorkflowManager();
|
||||
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
|
||||
|
||||
const workflow = manager.importWorkflow(yaml, 'yaml');
|
||||
|
||||
expect(workflow.id).toBe('workflow-test');
|
||||
expect(workflow.name).toBe('Workflow Test');
|
||||
});
|
||||
|
||||
test('rejects missing and circular sub-workflow references', () => {
|
||||
const manager = new WorkflowManager();
|
||||
const missingWorkspace = createWorkspaceSeed();
|
||||
|
||||
expect(() => manager.saveWorkflow(
|
||||
missingWorkspace,
|
||||
createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' }),
|
||||
)).toThrow('references unknown workflow "missing-child"');
|
||||
|
||||
const circularWorkspace = createWorkspaceSeed();
|
||||
circularWorkspace.workflows.push(
|
||||
createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' }),
|
||||
);
|
||||
|
||||
expect(() => manager.saveWorkflow(
|
||||
circularWorkspace,
|
||||
createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' }),
|
||||
)).toThrow('circular sub-workflow reference');
|
||||
});
|
||||
|
||||
test('prevents deleting referenced workflows and lists references through inline workflows', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
|
||||
manager.saveWorkflow(workspace, createWorkflow());
|
||||
manager.saveWorkflow(
|
||||
workspace,
|
||||
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'workflow-test' }),
|
||||
);
|
||||
manager.saveWorkflow(
|
||||
workspace,
|
||||
createSubWorkflow('inline-parent', 'Inline Parent', {
|
||||
inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'workflow-test' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => manager.deleteWorkflow(workspace, 'workflow-test')).toThrow('cannot be deleted');
|
||||
expect(manager.listWorkflowReferences(workspace, 'workflow-test')).toEqual([
|
||||
{
|
||||
referencingWorkflowId: 'parent',
|
||||
referencingWorkflowName: 'Parent Workflow',
|
||||
nodeId: 'sub-workflow',
|
||||
nodeLabel: 'Nested Workflow',
|
||||
},
|
||||
{
|
||||
referencingWorkflowId: 'inline-parent',
|
||||
referencingWorkflowName: 'Inline Parent',
|
||||
nodeId: 'sub-workflow',
|
||||
nodeLabel: 'Nested Workflow',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user