Files
aryx/src/main/services/discoveredToolingSyncService.ts
T
David KayaandCopilot 574455729b 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>
2026-04-07 22:18:52 +02:00

196 lines
6.7 KiB
TypeScript

import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
applyDiscoveredMcpServerStatus,
normalizeDiscoveredToolingState,
type DiscoveredMcpServer,
type DiscoveredToolingState,
type DiscoveredToolingStatus,
} from '@shared/domain/discoveredTooling';
import {
normalizeProjectCustomizationState,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import type { WorkspaceState } from '@shared/domain/workspace';
import { ConfigScannerRegistry } from '@main/services/configScanner';
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
import { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher';
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
type DiscoveredToolingSyncServiceDeps = {
configScanner: ConfigScannerRegistry;
customizationScanner: ProjectCustomizationScanner;
projectCustomizationWatcher: ProjectCustomizationWatcher;
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
};
export class DiscoveredToolingSyncService {
private customizationWatcherUpdateQueue = Promise.resolve();
private readonly configScanner: ConfigScannerRegistry;
private readonly customizationScanner: ProjectCustomizationScanner;
private readonly projectCustomizationWatcher: ProjectCustomizationWatcher;
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
constructor(deps: DiscoveredToolingSyncServiceDeps) {
this.configScanner = deps.configScanner;
this.customizationScanner = deps.customizationScanner;
this.projectCustomizationWatcher = deps.projectCustomizationWatcher;
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
}
async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
return false;
}
workspace.settings.discoveredUserTooling = nextState;
return true;
}
async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
await this.projectCustomizationWatcher.syncProjects(
workspace.projects
.filter((project) => !isScratchpadProject(project))
.map((project) => ({
id: project.id,
path: project.path,
})),
);
}
async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
await this.enqueueCustomizationWatcherUpdate(async () => {
const workspace = await this.loadWorkspace();
const project = workspace.projects.find((candidate) => candidate.id === projectId);
await this.syncProjectCustomizationWatchers(workspace);
if (!project || isScratchpadProject(project)) {
return;
}
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
if (didSyncProjectCustomization) {
await this.persistWorkspace(workspace);
}
});
}
async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
return false;
}
project.customization = undefined;
return true;
}
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
if (this.equalProjectCustomizationState(project.customization, nextState)) {
return false;
}
project.customization = nextState;
return true;
}
async syncProjectDiscoveredTooling(
workspace: WorkspaceState,
project: ProjectRecord,
): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
return false;
}
project.discoveredTooling = undefined;
return true;
}
const nextState = await this.configScanner.scanProject(
project.id,
project.path,
project.discoveredTooling,
);
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
return false;
}
project.discoveredTooling = nextState;
return true;
}
resolveDiscoveredToolingStatus(
resolution: DiscoveredToolingResolution,
): Exclude<DiscoveredToolingStatus, 'pending'> {
return resolution === 'accept' ? 'accepted' : 'dismissed';
}
resolveWorkspaceDiscoveredTooling(
workspace: WorkspaceState,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
workspace.settings.discoveredUserTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
resolveProjectDiscoveredTooling(
project: ProjectRecord,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
project.discoveredTooling = applyDiscoveredMcpServerStatus(
project.discoveredTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
equalDiscoveredToolingState(
left?: DiscoveredToolingState,
right?: DiscoveredToolingState,
): boolean {
const stripRuntime = (servers: DiscoveredMcpServer[]) =>
servers.map(({ probedTools: _, ...rest }) => rest);
return JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(left).mcpServers))
=== JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(right).mcpServers));
}
equalProjectCustomizationState(
left?: ProjectCustomizationState,
right?: ProjectCustomizationState,
): boolean {
const normalizedLeft = normalizeProjectCustomizationState(left);
const normalizedRight = normalizeProjectCustomizationState(right);
return JSON.stringify({
instructions: normalizedLeft.instructions,
agentProfiles: normalizedLeft.agentProfiles,
promptFiles: normalizedLeft.promptFiles,
}) === JSON.stringify({
instructions: normalizedRight.instructions,
agentProfiles: normalizedRight.agentProfiles,
promptFiles: normalizedRight.promptFiles,
});
}
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
this.customizationWatcherUpdateQueue = scheduledTask.then(
() => undefined,
() => undefined,
);
return scheduledTask;
}
}