From 574455729bac3e803e65dfdf5e6d4447a536c40f Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 7 Apr 2026 22:18:52 +0200 Subject: [PATCH] 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> --- src/main/AryxAppService.ts | 1653 +++-------------- src/main/services/approvalCoordinator.ts | 414 +++++ .../services/checkpointRecoveryManager.ts | 171 ++ .../services/discoveredToolingSyncService.ts | 195 ++ src/main/services/gitContextManager.ts | 306 +++ src/main/services/mcpProbeManager.ts | 309 +++ src/main/services/sessionTurnExecutor.ts | 952 ++++++++++ src/main/services/workflowManager.ts | 299 +++ tests/main/appServiceDeps.test.ts | 71 + tests/main/appServiceGitRefresh.test.ts | 38 +- tests/main/appServiceMcpProbing.test.ts | 27 +- tests/main/workflowManager.test.ts | 181 ++ 12 files changed, 3230 insertions(+), 1386 deletions(-) create mode 100644 src/main/services/approvalCoordinator.ts create mode 100644 src/main/services/checkpointRecoveryManager.ts create mode 100644 src/main/services/discoveredToolingSyncService.ts create mode 100644 src/main/services/gitContextManager.ts create mode 100644 src/main/services/mcpProbeManager.ts create mode 100644 src/main/services/sessionTurnExecutor.ts create mode 100644 src/main/services/workflowManager.ts create mode 100644 tests/main/appServiceDeps.test.ts create mode 100644 tests/main/workflowManager.test.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index c7620df..8964494 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -32,9 +32,7 @@ import { import { buildWorkflowExecutionDefinition, isReasoningEffort, - normalizeWorkflowDefinition, resolveWorkflowAgentNodes, - validateWorkflowDefinition, type ReasoningEffort, type WorkflowDefinition, type WorkflowReference, @@ -47,8 +45,6 @@ import { } from '@shared/domain/workflowSerialization'; import { applyWorkflowTemplate, - createWorkflowTemplateFromWorkflow, - normalizeWorkflowTemplateDefinition, type WorkflowTemplateCategory, type WorkflowTemplateDefinition, } from '@shared/domain/workflowTemplate'; @@ -79,15 +75,11 @@ import { import { applyDefaultToolApprovalPolicy, approvalPolicyRequiresCheckpoint, - dequeuePendingApprovalState, - enqueuePendingApprovalState, listPendingApprovals, normalizeApprovalPolicy, normalizeSessionApprovalSettings, pruneApprovalPolicyTools, pruneSessionApprovalSettings, - resolveApprovalToolKey, - resolvePendingApproval, type ApprovalDecision, type PendingApprovalMessageRecord, type PendingApprovalRecord, @@ -180,7 +172,22 @@ import { import { getStoredToken } from '@main/services/mcpTokenStore'; import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService'; import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber'; +import { + DiscoveredToolingSyncService, + type DiscoveredToolingResolution, +} from '@main/services/discoveredToolingSyncService'; +import { + ApprovalCoordinator, +} from '@main/services/approvalCoordinator'; +import { + CheckpointRecoveryManager, + type WorkflowCheckpointRecoveryState, +} from '@main/services/checkpointRecoveryManager'; +import { GitContextManager } from '@main/services/gitContextManager'; +import { McpProbeManager } from '@main/services/mcpProbeManager'; import { PtyManager } from '@main/services/ptyManager'; +import { SessionTurnExecutor } from '@main/services/sessionTurnExecutor'; +import { WorkflowManager } from '@main/services/workflowManager'; const { dialog, shell } = electron; @@ -191,29 +198,18 @@ type AppServiceEvents = { 'terminal-exit': [TerminalExitInfo]; }; -type PendingApprovalHandle = { - sessionId: string; - requestId: string; - resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise; +export type AppServiceDeps = { + workspaceRepository: WorkspaceRepository; + sidecar: SidecarClient; + secretStore: SecretStore; + gitService: GitService; + configScanner: ConfigScannerRegistry; + customizationScanner: ProjectCustomizationScanner; + projectCustomizationWatcher: ProjectCustomizationWatcher; + probeMcpServers: typeof probeServers; + ptyManager: PtyManager; }; -type PendingUserInputHandle = { - sessionId: string; - requestId: string; - resolve: (answer: string, wasFreeform: boolean) => void | Promise; -}; - -type WorkflowCheckpointRecoveryState = { - workflowSessionId: string; - checkpointId: string; - storePath: string; - stepNumber: number; - sessionMessages: ChatMessageRecord[]; - runEvents: RunTimelineEventRecord[]; -}; - -type DiscoveredToolingResolution = 'accept' | 'dismiss'; - function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean { const normalizedLeft = left ?? []; const normalizedRight = right ?? []; @@ -292,19 +288,23 @@ const GIT_REFRESH_DEBOUNCE_MS = 750; const GIT_REFRESH_INTERVAL_MS = 60_000; export class AryxAppService extends EventEmitter { - private readonly workspaceRepository = new WorkspaceRepository(); - private readonly sidecar = new SidecarClient(); - private readonly secretStore = new SecretStore(); - private readonly gitService = new GitService(); - private readonly configScanner = new ConfigScannerRegistry(); - private readonly customizationScanner = new ProjectCustomizationScanner(); - private readonly projectCustomizationWatcher = new ProjectCustomizationWatcher((projectId) => - this.handleProjectCustomizationWatcherChange(projectId)); - private readonly probeMcpServers = probeServers; - private readonly ptyManager = new PtyManager(); - private readonly pendingApprovalHandles = new Map(); - private readonly pendingUserInputHandles = new Map(); - private readonly workflowCheckpointRecoveries = new Map(); + private readonly workspaceRepository: WorkspaceRepository; + private readonly sidecar: SidecarClient; + private readonly secretStore: SecretStore; + private readonly gitService: GitService; + private readonly configScanner: ConfigScannerRegistry; + private readonly customizationScanner: ProjectCustomizationScanner; + private readonly projectCustomizationWatcher: ProjectCustomizationWatcher; + private readonly probeMcpServers: typeof probeServers; + private readonly ptyManager: PtyManager; + private readonly workflowManager: WorkflowManager; + private readonly mcpProbeManager: McpProbeManager; + private readonly discoveredToolingSyncService: DiscoveredToolingSyncService; + private readonly gitContextManager: GitContextManager; + private readonly approvalCoordinator: ApprovalCoordinator; + private readonly checkpointRecoveryManager: CheckpointRecoveryManager; + private readonly sessionTurnExecutor: SessionTurnExecutor; + private readonly workflowCheckpointRecoveries: Map; private workspace?: WorkspaceState; private sidecarCapabilities?: SidecarCapabilities; private sidecarCapabilitiesPromise?: Promise; @@ -318,9 +318,138 @@ export class AryxAppService extends EventEmitter { private runningProjectGitRefresh?: Promise; private customizationWatcherUpdateQueue = Promise.resolve(); - constructor() { + constructor(deps: Partial = {}) { super(); + this.workspaceRepository = deps.workspaceRepository ?? new WorkspaceRepository(); + this.sidecar = deps.sidecar ?? new SidecarClient(); + this.secretStore = deps.secretStore ?? new SecretStore(); + this.gitService = deps.gitService ?? new GitService(); + this.configScanner = deps.configScanner ?? new ConfigScannerRegistry(); + this.customizationScanner = deps.customizationScanner ?? new ProjectCustomizationScanner(); + this.projectCustomizationWatcher = deps.projectCustomizationWatcher + ?? new ProjectCustomizationWatcher((projectId) => this.handleProjectCustomizationWatcherChange(projectId)); + this.probeMcpServers = deps.probeMcpServers ?? probeServers; + this.ptyManager = deps.ptyManager ?? new PtyManager(); + this.workflowManager = new WorkflowManager(); + this.mcpProbeManager = new McpProbeManager({ + loadWorkspace: () => this.loadWorkspace(), + persistWorkspace: async (workspace) => { + await this.persistAndBroadcast(workspace); + }, + probeMcpServers: this.probeMcpServers, + tokenLookup: (serverUrl) => getStoredToken(serverUrl)?.accessToken, + performMcpOAuthFlow, + requiresOAuth, + }); + this.discoveredToolingSyncService = new DiscoveredToolingSyncService({ + configScanner: this.configScanner, + customizationScanner: this.customizationScanner, + projectCustomizationWatcher: this.projectCustomizationWatcher, + loadWorkspace: () => this.loadWorkspace(), + persistWorkspace: async (workspace) => { + await this.persistAndBroadcast(workspace); + }, + }); + this.gitContextManager = new GitContextManager({ + gitService: this.gitService, + loadWorkspace: () => this.loadWorkspace(), + persistWorkspace: (workspace) => this.persistAndBroadcast(workspace), + requireProject: (workspace, projectId) => this.requireProject(workspace, projectId), + requireSession: (workspace, sessionId) => this.requireSession(workspace, sessionId), + requireSessionRun: (session, runId) => this.requireSessionRun(session, runId), + syncProjectDiscoveredTooling: (workspace, project) => this.syncProjectDiscoveredTooling(workspace, project), + syncProjectCustomization: (project) => this.syncProjectCustomization(project), + pruneUnavailableSessionToolingSelections: (workspace) => this.pruneUnavailableSessionToolingSelections(workspace), + pruneUnavailableApprovalTools: (workspace) => this.pruneUnavailableApprovalTools(workspace), + updateSessionRun: (session, requestId, updater) => this.updateSessionRun(session, requestId, updater), + emitRunUpdated: (sessionId, occurredAt, run) => this.emitRunUpdated(sessionId, occurredAt, run), + }); + this.approvalCoordinator = new ApprovalCoordinator({ + requireSession: (workspace, sessionId) => this.requireSession(workspace, sessionId), + persistWorkspace: (workspace) => this.persistAndBroadcast(workspace), + updateSessionRun: (session, requestId, updater) => this.updateSessionRun(session, requestId, updater), + emitRunUpdated: (sessionId, occurredAt, run) => this.emitRunUpdated(sessionId, occurredAt, run), + emitSessionEvent: (event) => this.emitSessionEvent(event), + failSessionRunRecord: (run, failedAt, error) => failSessionRunRecord(run, failedAt, error), + upsertRunApprovalEvent: (run, approval) => upsertRunApprovalEvent(run, approval), + }); + this.checkpointRecoveryManager = new CheckpointRecoveryManager({ + persistWorkspace: async (workspace) => { + await this.persistAndBroadcast(workspace); + }, + emitRunUpdated: (sessionId, occurredAt, run) => this.emitRunUpdated(sessionId, occurredAt, run), + updateSessionRun: (session, requestId, updater) => this.updateSessionRun(session, requestId, updater), + setSessionPendingApprovalState: (session, state) => this.approvalCoordinator.setSessionPendingApprovalState(session, state), + pendingApprovalHandles: this.approvalCoordinator.pendingApprovalHandles, + pendingUserInputHandles: this.approvalCoordinator.pendingUserInputHandles, + }); + this.sessionTurnExecutor = new SessionTurnExecutor({ + saveWorkspace: async (workspace) => { + await this.workspaceRepository.save(workspace); + }, + persistWorkspace: (workspace) => this.persistAndBroadcast(workspace), + requireSession: (workspace, sessionId) => this.requireSession(workspace, sessionId), + resolveSessionWorkflow: (workspace, session) => this.resolveSessionWorkflow(workspace, session), + updateSessionRun: (session, requestId, updater) => this.updateSessionRun(session, requestId, updater), + emitRunUpdated: (sessionId, occurredAt, run) => this.emitRunUpdated(sessionId, occurredAt, run), + emitSessionEvent: (event) => this.emitSessionEvent(event), + rejectPendingApprovals: (session, failedAt, error) => this.rejectPendingApprovals(session, failedAt, error), + buildRunTurnToolingConfig: (workspace, session) => this.buildRunTurnToolingConfig(workspace, session), + runSidecarTurnWithCheckpointRecovery: ( + workspace, + session, + requestId, + createCommand, + onDelta, + onActivity, + onApproval, + onUserInput, + onMcpOAuthRequired, + onExitPlanMode, + onMessageReclassified, + onTurnScopedEvent, + ) => this.runSidecarTurnWithCheckpointRecovery( + workspace, + session, + requestId, + createCommand, + onDelta, + onActivity, + onApproval, + onUserInput, + onMcpOAuthRequired, + onExitPlanMode, + onMessageReclassified, + onTurnScopedEvent, + ), + handleApprovalRequested: (workspace, sessionId, requestId, approval, resolve) => + this.handleApprovalRequested(workspace, sessionId, requestId, approval, resolve), + handleUserInputRequested: (workspace, sessionId, requestId, event, resolve) => + this.handleUserInputRequested(workspace, sessionId, requestId, event, resolve), + handleMcpOAuthRequired: (workspace, sessionId, event) => + this.handleMcpOAuthRequired(workspace, sessionId, event), + handleExitPlanModeRequested: (workspace, sessionId, event) => + this.handleExitPlanModeRequested(workspace, sessionId, event), + handleTurnScopedEvent: (workspace, sessionId, event) => + this.handleTurnScopedEvent(workspace, sessionId, event), + sidecarResolveApproval: (approvalId, decision, alwaysApprove) => + this.sidecar.resolveApproval(approvalId, decision, alwaysApprove), + sidecarResolveUserInput: (userInputId, answer, wasFreeform) => + this.sidecar.resolveUserInput(userInputId, answer, wasFreeform), + captureWorkingTreeSnapshot: (projectPath, scannedAt) => + this.gitService.captureWorkingTreeSnapshot(projectPath, scannedAt), + captureWorkingTreeBaseline: (projectPath, snapshot) => + this.gitService.captureWorkingTreeBaseline(projectPath, snapshot), + refreshSessionRunGitSummary: (session, project, requestId, occurredAt) => + this.refreshSessionRunGitSummary(session, project, requestId, occurredAt), + cleanupWorkflowCheckpointRecovery: (requestId) => + this.cleanupWorkflowCheckpointRecovery(requestId), + scheduleProjectGitRefresh: (projectId) => this.scheduleProjectGitRefresh(projectId), + loadAvailableModelCatalog: () => this.loadAvailableModelCatalog(), + }); + this.workflowCheckpointRecoveries = this.checkpointRecoveryManager.recoveries; + this.ptyManager.on('data', (data) => { this.emit('terminal-data', data); }); @@ -390,14 +519,7 @@ export class AryxAppService extends EventEmitter { } async dispose(): Promise { - if (this.projectGitRefreshTimer) { - clearTimeout(this.projectGitRefreshTimer); - this.projectGitRefreshTimer = undefined; - } - if (this.periodicProjectGitRefreshTimer) { - clearInterval(this.periodicProjectGitRefreshTimer); - this.periodicProjectGitRefreshTimer = undefined; - } + this.gitContextManager.dispose(); this.projectCustomizationWatcher.dispose(); this.ptyManager.dispose(); await this.sidecar.dispose(); @@ -409,22 +531,7 @@ export class AryxAppService extends EventEmitter { } scheduleProjectGitRefresh(projectId?: string): void { - if (projectId) { - this.pendingProjectGitRefreshIds.add(projectId); - } else { - this.pendingRefreshAllProjects = true; - this.pendingProjectGitRefreshIds.clear(); - } - - if (this.projectGitRefreshTimer) { - clearTimeout(this.projectGitRefreshTimer); - } - - this.projectGitRefreshTimer = setTimeout(() => { - this.projectGitRefreshTimer = undefined; - void this.flushScheduledProjectGitRefresh(); - }, GIT_REFRESH_DEBOUNCE_MS); - this.projectGitRefreshTimer.unref?.(); + this.gitContextManager.scheduleProjectGitRefresh(projectId); } async openAppDataFolder(): Promise { @@ -523,11 +630,7 @@ export class AryxAppService extends EventEmitter { resolution: DiscoveredToolingResolution, ): Promise { const workspace = await this.loadWorkspace(); - workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus( - workspace.settings.discoveredUserTooling, - serverIds, - this.resolveDiscoveredToolingStatus(resolution), - ); + this.discoveredToolingSyncService.resolveWorkspaceDiscoveredTooling(workspace, serverIds, resolution); this.pruneUnavailableSessionToolingSelections(workspace); await this.pruneUnavailableApprovalTools(workspace); @@ -545,7 +648,7 @@ export class AryxAppService extends EventEmitter { async rescanProjectConfigs(projectId: string): Promise { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); - await this.syncProjectDiscoveredTooling(workspace, project); + await this.discoveredToolingSyncService.syncProjectDiscoveredTooling(workspace, project); this.pruneUnavailableSessionToolingSelections(workspace); await this.pruneUnavailableApprovalTools(workspace); const result = await this.persistAndBroadcast(workspace); @@ -560,8 +663,8 @@ export class AryxAppService extends EventEmitter { async rescanProjectCustomization(projectId: string): Promise { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); - await this.syncProjectCustomization(project); - await this.syncProjectCustomizationWatchers(workspace); + await this.discoveredToolingSyncService.syncProjectCustomization(project); + await this.discoveredToolingSyncService.syncProjectCustomizationWatchers(workspace); return this.persistAndBroadcast(workspace); } @@ -587,11 +690,7 @@ export class AryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); - project.discoveredTooling = applyDiscoveredMcpServerStatus( - project.discoveredTooling, - serverIds, - this.resolveDiscoveredToolingStatus(resolution), - ); + this.discoveredToolingSyncService.resolveProjectDiscoveredTooling(project, serverIds, resolution); this.pruneUnavailableSessionToolingSelections(workspace); await this.pruneUnavailableApprovalTools(workspace); @@ -608,28 +707,7 @@ export class AryxAppService extends EventEmitter { async saveWorkflow(workflow: WorkflowDefinition): Promise { const workspace = await this.loadWorkspace(); - const normalizedWorkflow = normalizeWorkflowDefinition(workflow); - const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error'); - if (issues.length > 0) { - throw new Error(issues[0].message); - } - - const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id); - const candidate: WorkflowDefinition = { - ...normalizedWorkflow, - isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite, - createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(), - updatedAt: nowIso(), - }; - this.validateWorkflowReferences(workspace, candidate); - - if (existingIndex >= 0) { - workspace.workflows[existingIndex] = candidate; - } else { - workspace.workflows.push(candidate); - } - - workspace.selectedWorkflowId = candidate.id; + this.workflowManager.saveWorkflow(workspace, workflow); return this.persistAndBroadcast(workspace); } @@ -643,26 +721,7 @@ export class AryxAppService extends EventEmitter { }, ): Promise { 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); - } - + this.workflowManager.saveWorkflowTemplate(workspace, workflowId, options); return this.persistAndBroadcast(workspace); } @@ -675,21 +734,13 @@ export class AryxAppService extends EventEmitter { }, ): Promise { 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); + this.workflowManager.createWorkflowFromTemplate(workspace, templateId, options); + return this.persistAndBroadcast(workspace); } async exportWorkflow(workflowId: string, format: WorkflowExportFormat): Promise { const workspace = await this.loadWorkspace(); - const workflow = this.requireWorkflow(workspace, workflowId); - return exportWorkflowDefinition(workflow, format); + return this.workflowManager.exportWorkflow(workspace, workflowId, format); } async importWorkflow( @@ -697,15 +748,17 @@ export class AryxAppService extends EventEmitter { format: 'yaml' | 'json', options?: { save?: boolean }, ): Promise<{ workflow: WorkflowDefinition; workspace?: WorkspaceState }> { - const workflow = importWorkflowDefinition(content, format); + const workflow = this.workflowManager.importWorkflow(content, format); if (!options?.save) { return { workflow }; } - const workspace = await this.saveWorkflow(workflow); + const workspace = await this.loadWorkspace(); + this.workflowManager.saveWorkflow(workspace, workflow); + const persistedWorkspace = await this.persistAndBroadcast(workspace); return { workflow, - workspace, + workspace: persistedWorkspace, }; } @@ -789,29 +842,13 @@ export class AryxAppService extends EventEmitter { async deleteWorkflow(workflowId: string): Promise { 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) { - workspace.selectedWorkflowId = workspace.workflows[0]?.id; - } - + this.workflowManager.deleteWorkflow(workspace, workflowId); return this.persistAndBroadcast(workspace); } async listWorkflowReferences(workflowId: string): Promise { const workspace = await this.loadWorkspace(); - this.requireWorkflow(workspace, workflowId); - return this.listWorkflowReferencesInWorkspace(workspace, workflowId); + return this.workflowManager.listWorkflowReferences(workspace, workflowId); } async saveMcpServer(server: McpServerDefinition): Promise { @@ -1284,103 +1321,13 @@ export class AryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - const approval = session.pendingApproval; - if (!approval || approval.id !== approvalId) { - const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId); - if (queuedApproval) { - throw new Error( - approval - ? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.` - : `Approval "${approvalId}" is queued but not active for session "${sessionId}".`, - ); - } - - throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`); - } - - const handle = this.pendingApprovalHandles.get(approvalId); - if (!handle || handle.sessionId !== sessionId) { - throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`); - } - - const resolvedAt = nowIso(); - const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt); - this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); - session.updatedAt = resolvedAt; - - const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); - if (decision === 'approved' && alwaysApprove && approvalKey) { - const existing = session.approvalSettings?.autoApprovedToolNames ?? []; - if (!existing.includes(approvalKey)) { - session.approvalSettings = { autoApprovedToolNames: [...existing, approvalKey] }; - } - } - - const updatedRun = this.updateSessionRun(session, handle.requestId, (run) => - upsertRunApprovalEvent(run, resolvedApproval)); - - // Auto-resolve queued approvals that share the same category key. - // When the user approves "read", all pending view/grep/glob calls resolve too. - const cascadeHandles: PendingApprovalHandle[] = []; - if (decision === 'approved' && approvalKey && approval.kind === 'tool-call') { - for (const queued of listPendingApprovals(session)) { - if (queued.id === approvalId) continue; - const queuedKey = resolveApprovalToolKey(queued.toolName, queued.permissionKind); - if (queuedKey !== approvalKey) continue; - - const queuedHandle = this.pendingApprovalHandles.get(queued.id); - if (!queuedHandle || queuedHandle.sessionId !== sessionId) continue; - - const cascadeResolved = resolvePendingApproval(queued, 'approved', resolvedAt); - this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, queued.id)); - this.updateSessionRun(session, queuedHandle.requestId, (run) => - upsertRunApprovalEvent(run, cascadeResolved)); - this.pendingApprovalHandles.delete(queued.id); - cascadeHandles.push(queuedHandle); - } - } - - const result = await this.persistAndBroadcast(workspace); - if (updatedRun) { - this.emitRunUpdated(sessionId, resolvedAt, updatedRun); - } - - this.pendingApprovalHandles.delete(approvalId); - - try { - await Promise.resolve(handle.resolve(decision, alwaysApprove)); - for (const cascaded of cascadeHandles) { - await Promise.resolve(cascaded.resolve('approved', alwaysApprove)); - } - } catch (error) { - const failedAt = nowIso(); - this.rejectPendingApprovals( - session, - failedAt, - 'Queued approval was cancelled because the run failed before it could resume.', - ); - session.status = 'error'; - session.lastError = error instanceof Error ? error.message : String(error); - session.updatedAt = failedAt; - - const failedRun = this.updateSessionRun(session, handle.requestId, (run) => - failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.')); - - this.emitSessionEvent({ - sessionId, - kind: 'error', - occurredAt: failedAt, - error: session.lastError, - }); - if (failedRun) { - this.emitRunUpdated(sessionId, failedAt, failedRun); - } - - await this.persistAndBroadcast(workspace); - throw error; - } - - return result; + return this.approvalCoordinator.resolveSessionApproval( + workspace, + session.id, + approvalId, + decision, + alwaysApprove, + ); } async resolveSessionUserInput( @@ -1390,50 +1337,13 @@ export class AryxAppService extends EventEmitter { wasFreeform: boolean, ): Promise { const workspace = await this.loadWorkspace(); - const session = this.requireSession(workspace, sessionId); - const pending = session.pendingUserInput; - if (!pending || pending.id !== userInputId) { - throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`); - } - - const handle = this.pendingUserInputHandles.get(userInputId); - if (!handle || handle.sessionId !== sessionId) { - throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`); - } - - const answeredAt = nowIso(); - session.pendingUserInput = { - ...pending, - status: 'answered', + return this.approvalCoordinator.resolveSessionUserInput( + workspace, + sessionId, + userInputId, answer, - answeredAt, - }; - session.updatedAt = answeredAt; - - const result = await this.persistAndBroadcast(workspace); - this.pendingUserInputHandles.delete(userInputId); - - try { - await Promise.resolve(handle.resolve(answer, wasFreeform)); - session.pendingUserInput = undefined; - await this.persistAndBroadcast(workspace); - } catch (error) { - session.status = 'error'; - session.lastError = error instanceof Error ? error.message : String(error); - session.updatedAt = nowIso(); - - this.emitSessionEvent({ - sessionId, - kind: 'error', - occurredAt: session.updatedAt, - error: session.lastError, - }); - - await this.persistAndBroadcast(workspace); - throw error; - } - - return result; + wasFreeform, + ); } async updateSessionModelConfig( @@ -1561,47 +1471,8 @@ export class AryxAppService extends EventEmitter { tooling: WorkspaceToolingSettings, selection: SessionToolingSelection, ): Promise { - const httpServers = selection.enabledMcpServerIds - .map((id) => tooling.mcpServers.find((s) => s.id === id)) - .filter((s): s is McpServerDefinition => !!s && s.transport !== 'local') - .filter((s) => s.transport === 'http' || s.transport === 'sse'); - - if (httpServers.length === 0) { - return; - } - - console.log(`[aryx oauth] Probing ${httpServers.length} HTTP MCP server(s) for OAuth requirements…`); - - for (const server of httpServers) { - if (server.transport === 'local') continue; - const existingToken = getStoredToken(server.url); - if (existingToken) { - console.log(`[aryx oauth] Skipping ${server.name} — token already stored`); - continue; - } - - try { - const needsAuth = await requiresOAuth(server.url); - if (!needsAuth) { - console.log(`[aryx oauth] ${server.name} does not require OAuth`); - continue; - } - - console.log(`[aryx oauth] ${server.name} requires OAuth — starting flow…`); - const result = await performMcpOAuthFlow({ serverUrl: server.url }); - - if (result.success) { - console.log(`[aryx oauth] ${server.name} authenticated successfully`); - void this.reprobeServerByUrl(server.url).catch((error) => { - console.error('[aryx mcp-probe] re-probe after auth failed:', error); - }); - } else { - console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`); - } - } catch (err) { - console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, err); - } - } + void sessionId; + await this.mcpProbeManager.probeAndAuthenticateHttpMcpServers(tooling, selection); } private async runPreparedSessionTurn( @@ -1618,168 +1489,14 @@ export class AryxAppService extends EventEmitter { attachments?: ChatMessageAttachment[]; }, ): Promise { - const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; - const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options; - const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId); - const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation); - const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation) - ? 'plan' - : session.interactionMode ?? 'interactive'; - const runWorkingDirectory = session.cwd ?? project.path; - const preRunGitSnapshot = workspaceKind === 'project' - ? await this.gitService.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt) - : undefined; - const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot - ? await this.gitService.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot) - : undefined; - if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) { - console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`); - } - - session.title = resolveSessionTitle(session, workflowForTurn, session.messages); - session.status = 'running'; - session.lastError = undefined; - session.pendingPlanReview = undefined; - session.pendingMcpAuth = undefined; - session.updatedAt = occurredAt; - session.runs = [ - createSessionRunRecord({ - requestId, - project, - workingDirectory: runWorkingDirectory, - workspaceKind, - workflow: workflowForTurn, - triggerMessageId, - startedAt: occurredAt, - preRunGitSnapshot, - preRunGitBaselineFiles, - }), - ...session.runs, - ]; - - await this.persistAndBroadcast(workspace); - this.emitSessionEvent({ - sessionId: session.id, - kind: 'status', - status: 'running', - occurredAt, - }); - - try { - const createRunTurnCommand = ( - resumeFromCheckpoint?: WorkflowCheckpointResume, - ): RunTurnCommand => ({ - type: 'run-turn', - requestId, - sessionId: session.id, - projectPath: runWorkingDirectory, - workspaceKind, - mode: interactionMode, - messageMode, - projectInstructions, - workflow: workflowForTurn, - workflowLibrary: workspace.workflows, - messages: session.messages, - attachments: attachments?.length ? attachments : undefined, - promptInvocation, - tooling: this.buildRunTurnToolingConfig(workspace, session), - resumeFromCheckpoint, - }); - - const responseMessages = await this.runSidecarTurnWithCheckpointRecovery( - workspace, - session, - requestId, - createRunTurnCommand, - async (event) => { - await this.applyTurnDelta(workspace, session.id, requestId, event); - }, - async (event) => { - await this.applyAgentActivity(workspace, session.id, requestId, event); - }, - async (event) => { - await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) => - this.sidecar.resolveApproval(event.approvalId, decision, alwaysApprove)); - }, - async (event) => { - await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) => - this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform)); - }, - async (event) => { - await this.handleMcpOAuthRequired(workspace, session.id, event); - }, - async (event) => { - await this.handleExitPlanModeRequested(workspace, session.id, event); - }, - async (event) => { - await this.applyMessageReclassified(workspace, session.id, event); - }, - async (event) => { - await this.handleTurnScopedEvent(workspace, session.id, event); - }, - ); - - await this.awaitFinalResponseApproval(workspace, session.id, requestId, workflowForTurn, responseMessages); - this.finalizeTurn(workspace, session.id, requestId, responseMessages); - if (workspaceKind === 'project') { - const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso()); - if (completedRun) { - this.emitRunUpdated(session.id, nowIso(), completedRun); - } - } - await this.persistAndBroadcast(workspace); - await this.cleanupWorkflowCheckpointRecovery(requestId); - if (workspaceKind === 'project') { - this.scheduleProjectGitRefresh(project.id); - } - } catch (error) { - if (error instanceof TurnCancelledError) { - this.finalizeCancelledTurn(workspace, session, requestId); - if (workspaceKind === 'project') { - const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso()); - if (cancelledRun) { - this.emitRunUpdated(session.id, nowIso(), cancelledRun); - } - } - await this.persistAndBroadcast(workspace); - await this.cleanupWorkflowCheckpointRecovery(requestId); - if (workspaceKind === 'project') { - this.scheduleProjectGitRefresh(project.id); - } - return; - } - - const failedAt = nowIso(); - session.status = 'error'; - session.lastError = error instanceof Error ? error.message : String(error); - session.updatedAt = failedAt; - - const failedRun = this.updateSessionRun(session, requestId, (run) => - failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.')); - - this.emitSessionEvent({ - sessionId: session.id, - kind: 'error', - occurredAt: failedAt, - error: session.lastError, - }); - if (failedRun) { - this.emitRunUpdated(session.id, failedAt, failedRun); - } - - if (workspaceKind === 'project') { - const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt); - if (summarizedRun) { - this.emitRunUpdated(session.id, failedAt, summarizedRun); - } - } - - await this.persistAndBroadcast(workspace); - await this.cleanupWorkflowCheckpointRecovery(requestId); - if (workspaceKind === 'project') { - this.scheduleProjectGitRefresh(project.id); - } - } + await this.sessionTurnExecutor.runPreparedSessionTurn( + workspace, + session, + project, + effectiveWorkflow, + projectInstructions, + options, + ); } async updateSessionTooling( @@ -1863,22 +1580,18 @@ export class AryxAppService extends EventEmitter { } async refreshProjectGitContext(projectId?: string): Promise { - return this.refreshProjectGitContexts(projectId ? [projectId] : undefined); + return this.gitContextManager.refreshProjectGitContext(projectId); } async getProjectGitDetails(projectId: string, commitLimit = 20): Promise { - const workspace = await this.loadWorkspace(); - const project = this.requireProject(workspace, projectId); - return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit); + return this.gitContextManager.getProjectGitDetails(projectId, commitLimit); } async getProjectGitFilePreview( projectId: string, file: ProjectGitFileReference, ): Promise { - const workspace = await this.loadWorkspace(); - const project = this.requireProject(workspace, projectId); - return this.gitService.getWorkingTreeFilePreview(project.path, file); + return this.gitContextManager.getProjectGitFilePreview(projectId, file); } async discardSessionRunGitChanges( @@ -1886,52 +1599,17 @@ export class AryxAppService extends EventEmitter { runId: string, files?: ProjectGitFileReference[], ): Promise { - const workspace = await this.loadWorkspace(); - const session = this.requireSession(workspace, sessionId); - const project = this.requireProject(workspace, session.projectId); - const run = this.requireSessionRun(session, runId); - if (run.workspaceKind !== 'project') { - throw new Error('Run change review is only available for project-backed sessions.'); - } - - if (!run.postRunGitSummary) { - throw new Error('This run does not have any tracked git changes to discard.'); - } - - await this.gitService.discardRunChanges( - this.resolveRunWorkingDirectory(session, project, run), - { - summary: run.postRunGitSummary, - preRunBaselineFiles: run.preRunGitBaselineFiles, - files, - }, - ); - - await this.refreshProjectGitContexts([project.id]); - const refreshedWorkspace = await this.loadWorkspace(); - const refreshedSession = this.requireSession(refreshedWorkspace, sessionId); - const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId); - const nextRun = await this.refreshSessionRunGitSummary( - refreshedSession, - refreshedProject, - run.requestId, - nowIso(), - ); - if (nextRun) { - this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun); - } - - return this.persistAndBroadcast(refreshedWorkspace); + return this.gitContextManager.discardSessionRunGitChanges(sessionId, runId, files); } async stageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.stageFiles(project.path, files); }); } async unstageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.unstageFiles(project.path, files); }); } @@ -1964,7 +1642,7 @@ export class AryxAppService extends EventEmitter { files?: ProjectGitFileReference[], push = false, ): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { if (files && files.length > 0) { await this.gitService.stageFiles(project.path, files); } @@ -1977,19 +1655,19 @@ export class AryxAppService extends EventEmitter { } async pushProjectGit(projectId: string): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.push(project.path); }); } async fetchProjectGit(projectId: string): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.fetch(project.path); }); } async pullProjectGit(projectId: string, rebase = false): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.pull(project.path, rebase); }); } @@ -2000,54 +1678,25 @@ export class AryxAppService extends EventEmitter { startPoint?: string, checkout = true, ): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.createBranch(project.path, name, startPoint, checkout); }); } async switchProjectGitBranch(projectId: string, name: string): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.switchBranch(project.path, name); }); } async deleteProjectGitBranch(projectId: string, name: string, force = false): Promise { - return this.runProjectGitMutation(projectId, async (project) => { + return this.gitContextManager.runProjectGitMutation(projectId, async (project) => { await this.gitService.deleteBranch(project.path, name, force); }); } private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise { - const workspace = await this.loadWorkspace(); - const projects = projectIds?.length - ? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId)) - : workspace.projects; - - let didRefreshGit = false; - let didSyncProjectTooling = false; - let didSyncProjectCustomization = false; - for (const project of projects) { - didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit; - didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling; - didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization; - } - - const didPruneSelections = didSyncProjectTooling - ? this.pruneUnavailableSessionToolingSelections(workspace) - : false; - const didPruneApprovalTools = didSyncProjectTooling - ? await this.pruneUnavailableApprovalTools(workspace) - : false; - - return ( - didRefreshGit - || didSyncProjectTooling - || didSyncProjectCustomization - || didPruneSelections - || didPruneApprovalTools - ) - ? this.persistAndBroadcast(workspace) - : workspace; + return this.gitContextManager.refreshProjectGitContexts(projectIds); } async selectProject(projectId?: string): Promise { @@ -2109,7 +1758,7 @@ export class AryxAppService extends EventEmitter { project: ProjectRecord, run: SessionRunRecord, ): string { - return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path; + return this.gitContextManager.resolveRunWorkingDirectory(session, project, run); } private async refreshSessionRunGitSummary( @@ -2118,36 +1767,14 @@ export class AryxAppService extends EventEmitter { requestId: string, occurredAt: string, ): Promise { - const run = session.runs.find((candidate) => candidate.requestId === requestId); - if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) { - return undefined; - } - - const summary = await this.gitService.computeRunChangeSummary( - this.resolveRunWorkingDirectory(session, project, run), - { - generatedAt: occurredAt, - preRunSnapshot: run.preRunGitSnapshot, - preRunBaselineFiles: run.preRunGitBaselineFiles, - }, - ); - - return this.updateSessionRun(session, requestId, (currentRun) => - setSessionRunGitSummary(currentRun, summary)); + return this.gitContextManager.refreshSessionRunGitSummary(session, project, requestId, occurredAt); } private async runProjectGitMutation( projectId: string, mutation: (project: ProjectRecord) => Promise, ): Promise { - const workspace = await this.loadWorkspace(); - const project = this.requireProject(workspace, projectId); - if (isScratchpadProject(project)) { - throw new Error('Git operations are not available for the Scratchpad project.'); - } - - await mutation(project); - return this.refreshProjectGitContexts([project.id]); + return this.gitContextManager.runProjectGitMutation(projectId, mutation); } private resolveTerminalWorkingDirectory(workspace: WorkspaceState): string { @@ -2170,224 +1797,69 @@ export class AryxAppService extends EventEmitter { } private async refreshGitContextForProject(project: ProjectRecord): Promise { - if (isScratchpadProject(project)) { - if (!project.git) { - return false; - } - - project.git = undefined; - return true; - } - - project.git = await this.gitService.describeProject(project.path); - return true; + const beforeGit = JSON.stringify(project.git); + await this.gitContextManager.refreshProjectGitContext(project.id); + return JSON.stringify(project.git) !== beforeGit; } private startPeriodicProjectGitRefresh(): void { - if (this.didStartPeriodicProjectGitRefresh) { - return; - } - - this.didStartPeriodicProjectGitRefresh = true; - this.periodicProjectGitRefreshTimer = setInterval(() => { - this.scheduleProjectGitRefresh(); - }, GIT_REFRESH_INTERVAL_MS); - this.periodicProjectGitRefreshTimer.unref?.(); + this.gitContextManager.startPeriodicProjectGitRefresh(); } private stopPeriodicProjectGitRefresh(): void { - if (this.periodicProjectGitRefreshTimer) { - clearInterval(this.periodicProjectGitRefreshTimer); - this.periodicProjectGitRefreshTimer = undefined; - } - this.didStartPeriodicProjectGitRefresh = false; + this.gitContextManager.stopPeriodicProjectGitRefresh(); } private async flushScheduledProjectGitRefresh(): Promise { - if (this.runningProjectGitRefresh) { - return; - } - - const projectIds = this.pendingRefreshAllProjects - ? undefined - : [...this.pendingProjectGitRefreshIds]; - this.pendingRefreshAllProjects = false; - this.pendingProjectGitRefreshIds.clear(); - - this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then( - () => undefined, - (error) => { - console.error('[aryx git]', error); - }, - ); - - try { - await this.runningProjectGitRefresh; - } finally { - this.runningProjectGitRefresh = undefined; - if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) { - this.scheduleProjectGitRefresh(); - } - } + await this.gitContextManager.flushScheduledProjectGitRefresh(); } 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; + return this.workflowManager.requireWorkflowTemplate(workspace, templateId); } private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition { - const workflow = workspace.workflows.find((current) => current.id === workflowId); - if (!workflow) { - throw new Error(`Workflow "${workflowId}" was not found.`); - } - - return workflow; + return this.workflowManager.requireWorkflow(workspace, workflowId); } 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}`; + return this.workflowManager.createUniqueWorkflowId(workspace, sourceId); } private normalizeIdentifier(value: string, fallbackPrefix: string): string { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return normalized || createId(fallbackPrefix); + return this.workflowManager.normalizeIdentifier(value, fallbackPrefix); } private resolveSessionWorkflow( workspace: WorkspaceState, session: SessionRecord, ): WorkflowDefinition { - return this.requireWorkflow(workspace, session.workflowId); + return this.workflowManager.resolveSessionWorkflow(workspace, session); } private buildResolvedExecutionWorkflow( workspace: WorkspaceState, workflow: WorkflowDefinition, ): WorkflowDefinition { - return normalizeWorkflowDefinition({ - ...workflow, - settings: { - ...workflow.settings, - approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy), - }, - }); + return this.workflowManager.buildResolvedExecutionWorkflow(workspace, workflow); } private createWorkflowResolutionOptions(workspace: WorkspaceState) { - return { - resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId), - }; + return this.workflowManager.createWorkflowResolutionOptions(workspace); } private validateWorkflowReferences( workspace: WorkspaceState, workflow: WorkflowDefinition, ): void { - const workflowLibrary = new Map(); - 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, - ): 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()); + this.workflowManager.validateWorkflowReferences(workspace, workflow); } private listWorkflowReferencesInWorkspace( workspace: WorkspaceState, workflowId: string, ): WorkflowReference[] { - const references: WorkflowReference[] = []; - - const visitWorkflow = ( - referencingWorkflow: WorkflowDefinition, - currentWorkflow: WorkflowDefinition, - visitedInlineWorkflows: Set, - ): 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()); - } - - return references; + return this.workflowManager.listWorkflowReferencesInWorkspace(workspace, workflowId); } private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord { @@ -2739,57 +2211,17 @@ export class AryxAppService extends EventEmitter { approval: ApprovalRequestedEvent | PendingApprovalRecord, resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise, ): Promise { - const session = this.requireSession(workspace, sessionId); - const pendingApproval = - 'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval; - - this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval)); - session.updatedAt = pendingApproval.requestedAt; - - const updatedRun = this.updateSessionRun(session, requestId, (run) => - upsertRunApprovalEvent(run, pendingApproval)); - - this.pendingApprovalHandles.set(pendingApproval.id, { - sessionId, - requestId, - resolve, - }); - - await this.persistAndBroadcast(workspace); - if (updatedRun) { - this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun); - } + await this.approvalCoordinator.handleApprovalRequested(workspace, sessionId, requestId, approval, resolve); } private async handleUserInputRequested( workspace: WorkspaceState, sessionId: string, - _requestId: string, + requestId: string, event: UserInputRequestedEvent, resolve: (answer: string, wasFreeform: boolean) => void | Promise, ): Promise { - const session = this.requireSession(workspace, sessionId); - const requestedAt = nowIso(); - - session.pendingUserInput = { - id: event.userInputId, - status: 'pending', - agentId: event.agentId, - agentName: event.agentName, - question: event.question, - choices: event.choices, - allowFreeform: event.allowFreeform ?? true, - requestedAt, - }; - session.updatedAt = requestedAt; - - this.pendingUserInputHandles.set(event.userInputId, { - sessionId, - requestId: _requestId, - resolve, - }); - - await this.persistAndBroadcast(workspace); + await this.approvalCoordinator.handleUserInputRequested(workspace, sessionId, requestId, event, resolve); } private async handleExitPlanModeRequested( @@ -2797,23 +2229,7 @@ export class AryxAppService extends EventEmitter { sessionId: string, event: ExitPlanModeRequestedEvent, ): Promise { - const session = this.requireSession(workspace, sessionId); - const requestedAt = nowIso(); - - session.pendingPlanReview = { - id: event.exitPlanId, - status: 'pending', - agentId: event.agentId, - agentName: event.agentName, - summary: event.summary, - planContent: event.planContent, - actions: event.actions, - recommendedAction: event.recommendedAction, - requestedAt, - }; - session.updatedAt = requestedAt; - - await this.persistAndBroadcast(workspace); + await this.approvalCoordinator.handleExitPlanModeRequested(workspace, sessionId, event); } private async handleMcpOAuthRequired( @@ -2821,24 +2237,7 @@ export class AryxAppService extends EventEmitter { sessionId: string, event: McpOauthRequiredEvent, ): Promise { - const session = this.requireSession(workspace, sessionId); - const requestedAt = nowIso(); - - session.pendingMcpAuth = { - id: event.oauthRequestId, - status: 'pending', - agentId: event.agentId, - agentName: event.agentName, - serverName: event.serverName, - serverUrl: event.serverUrl, - staticClientConfig: event.staticClientConfig - ? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient } - : undefined, - requestedAt, - }; - session.updatedAt = requestedAt; - - await this.persistAndBroadcast(workspace); + await this.approvalCoordinator.handleMcpOAuthRequired(workspace, sessionId, event); } private handleTurnScopedEvent( @@ -2984,38 +2383,23 @@ export class AryxAppService extends EventEmitter { onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise, onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise, ): Promise { - const invokeTurn = (resumeFromCheckpoint?: WorkflowCheckpointResume) => this.sidecar.runTurn( - createCommand(resumeFromCheckpoint), - onDelta, - onActivity, - onApproval, - onUserInput, - onMcpOAuthRequired, - onExitPlanMode, - onMessageReclassified, - onTurnScopedEvent, + return this.checkpointRecoveryManager.runSidecarTurnWithCheckpointRecovery( + workspace, + session, + requestId, + (resumeFromCheckpoint?: WorkflowCheckpointResume) => this.sidecar.runTurn( + createCommand(resumeFromCheckpoint), + onDelta, + onActivity, + onApproval, + onUserInput, + onMcpOAuthRequired, + onExitPlanMode, + onMessageReclassified, + onTurnScopedEvent, + ), + isUnexpectedSidecarTerminationError, ); - - try { - return await invokeTurn(); - } catch (error) { - const recovery = this.workflowCheckpointRecoveries.get(requestId); - if (!isUnexpectedSidecarTerminationError(error) || !recovery) { - throw error; - } - - const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery); - await this.persistAndBroadcast(workspace); - if (restoredRun) { - this.emitRunUpdated(session.id, session.updatedAt, restoredRun); - } - - return invokeTurn({ - workflowSessionId: recovery.workflowSessionId, - checkpointId: recovery.checkpointId, - storePath: recovery.storePath, - }); - } } private recordWorkflowCheckpointRecovery( @@ -3023,14 +2407,7 @@ export class AryxAppService extends EventEmitter { run: SessionRunRecord, event: WorkflowCheckpointSavedEvent, ): void { - this.workflowCheckpointRecoveries.set(event.requestId, { - workflowSessionId: event.workflowSessionId, - checkpointId: event.checkpointId, - storePath: event.storePath, - stepNumber: event.stepNumber, - sessionMessages: structuredClone(session.messages), - runEvents: structuredClone(run.events), - }); + this.checkpointRecoveryManager.recordWorkflowCheckpointRecovery(session, run, event); } private restoreWorkflowCheckpointRecovery( @@ -3038,65 +2415,19 @@ export class AryxAppService extends EventEmitter { requestId: string, recovery: WorkflowCheckpointRecoveryState, ): SessionRunRecord | undefined { - session.messages = structuredClone(recovery.sessionMessages); - session.status = 'running'; - session.lastError = undefined; - session.updatedAt = nowIso(); - this.clearPendingRunState(session, requestId); - - return this.updateSessionRun(session, requestId, (run) => ({ - ...run, - events: structuredClone(recovery.runEvents), - })); + return this.checkpointRecoveryManager.restoreWorkflowCheckpointRecovery(session, requestId, recovery); } private clearPendingRunState(session: SessionRecord, requestId: string): void { - this.setSessionPendingApprovalState(session, {}); - session.pendingUserInput = undefined; - session.pendingPlanReview = undefined; - session.pendingMcpAuth = undefined; - - for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) { - if (handle.sessionId === session.id && handle.requestId === requestId) { - this.pendingApprovalHandles.delete(approvalId); - } - } - - for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) { - if (handle.sessionId === session.id && handle.requestId === requestId) { - this.pendingUserInputHandles.delete(userInputId); - } - } + this.checkpointRecoveryManager.clearPendingRunState(session, requestId); } private async cleanupWorkflowCheckpointRecovery(requestId: string): Promise { - const recovery = this.workflowCheckpointRecoveries.get(requestId); - this.workflowCheckpointRecoveries.delete(requestId); - if (!recovery) { - return; - } - - try { - await rm(recovery.storePath, { recursive: true, force: true }); - } catch (error) { - console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error); - } + await this.checkpointRecoveryManager.cleanupWorkflowCheckpointRecovery(requestId); } private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord { - return { - id: event.approvalId, - kind: event.approvalKind, - status: 'pending', - requestedAt: nowIso(), - agentId: event.agentId, - agentName: event.agentName, - toolName: event.toolName, - permissionKind: event.permissionKind, - title: event.title, - detail: event.detail, - permissionDetail: event.permissionDetail, - }; + return this.approvalCoordinator.createPendingApprovalFromSidecarEvent(event); } private setSessionPendingApprovalState( @@ -3106,8 +2437,7 @@ export class AryxAppService extends EventEmitter { pendingApprovalQueue?: PendingApprovalRecord[]; }, ): void { - session.pendingApproval = state.pendingApproval; - session.pendingApprovalQueue = state.pendingApprovalQueue; + this.approvalCoordinator.setSessionPendingApprovalState(session, state); } private rejectPendingApprovals( @@ -3115,23 +2445,7 @@ export class AryxAppService extends EventEmitter { failedAt: string, error: string, ): string[] { - const requestIds = new Set(); - - for (const pendingApproval of listPendingApprovals(session)) { - const requestId = this.findApprovalRequestId(session, pendingApproval.id); - const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error); - - if (requestId) { - requestIds.add(requestId); - this.updateSessionRun(session, requestId, (run) => - upsertRunApprovalEvent(run, rejectedApproval)); - } - - this.pendingApprovalHandles.delete(pendingApproval.id); - } - - this.setSessionPendingApprovalState(session, {}); - return [...requestIds]; + return this.approvalCoordinator.rejectPendingApprovals(session, failedAt, error); } private async awaitFinalResponseApproval( @@ -3235,151 +2549,31 @@ export class AryxAppService extends EventEmitter { session: SessionRecord, workspaceAgents: ReadonlyArray, ): Promise { - const resolvedWorkflow = resolveWorkspaceWorkflowAgents(workflow, workspaceAgents); - const workflowWithSessionConfig = session.sessionModelConfig - ? applySessionModelConfig(resolvedWorkflow, session) - : resolvedWorkflow; - const workflowWithApprovalSettings = applySessionApprovalSettings(workflowWithSessionConfig, session); - - const modelCatalog = await this.loadAvailableModelCatalog(); - return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog); + return this.sessionTurnExecutor.buildEffectiveWorkflow(workflow, session, workspaceAgents); } private async applyPromptInvocationToWorkflow( workflow: WorkflowDefinition, promptInvocation?: ProjectPromptInvocation, ): Promise { - const requestedModel = promptInvocation?.model?.trim(); - if (!requestedModel) { - return workflow; - } - - const modelCatalog = await this.loadAvailableModelCatalog(); - const resolvedModel = findModelByReference(requestedModel, modelCatalog); - const effectiveModelId = resolvedModel?.id ?? requestedModel; - - let didChange = false; - const nodes = workflow.graph.nodes.map((node) => { - if (node.kind !== 'agent' || node.config.kind !== 'agent') { - return node; - } - - const agent = node.config; - // When overriding the model, re-normalize reasoning effort for the target model. - // If the target model's reasoning capabilities are unknown (supportedReasoningEfforts - // is undefined — common for dynamically-discovered models), strip reasoning effort - // entirely to avoid sending it to a model that may not support it. - let reasoningEffort: ReasoningEffort | undefined; - if (resolvedModel?.supportedReasoningEfforts) { - reasoningEffort = resolveReasoningEffort(resolvedModel, agent.reasoningEffort); - } else { - reasoningEffort = undefined; - } - - if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) { - return node; - } - - didChange = true; - return { - ...node, - config: { - ...agent, - model: effectiveModelId, - reasoningEffort, - }, - }; - }); - - return didChange - ? { - ...workflow, - graph: { - ...workflow.graph, - nodes, - }, - } - : workflow; + return this.sessionTurnExecutor.applyPromptInvocationToWorkflow(workflow, promptInvocation); } private applyProjectCustomizationToWorkflow( workflow: WorkflowDefinition, project: ProjectRecord, ): WorkflowDefinition { - if (isScratchpadProject(project)) { - return workflow; - } - - const projectCustomAgents = this.buildProjectCustomAgents(project.customization); - if (projectCustomAgents.length === 0) { - return workflow; - } - - const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0]; - if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') { - return workflow; - } - - const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? []; - const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase())); - const mergedCustomAgents = [ - ...existingCustomAgents, - ...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())), - ]; - - return { - ...workflow, - graph: { - ...workflow.graph, - nodes: workflow.graph.nodes.map((node) => { - if (node.id !== primaryAgentNode.id || node.kind !== 'agent' || node.config.kind !== 'agent') { - return node; - } - - return { - ...node, - config: { - ...node.config, - copilot: { - ...node.config.copilot, - customAgents: mergedCustomAgents, - }, - }, - }; - }), - }, - }; + return this.sessionTurnExecutor.applyProjectCustomizationToWorkflow(workflow, project); } private buildProjectCustomAgents( customization?: ProjectCustomizationState, ): RunTurnCustomAgentConfig[] { - return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile)); + return this.sessionTurnExecutor.buildProjectCustomAgents(customization); } private mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig { - const customAgent: RunTurnCustomAgentConfig = { - name: profile.name, - prompt: profile.prompt, - }; - - if (profile.displayName) { - customAgent.displayName = profile.displayName; - } - - if (profile.description) { - customAgent.description = profile.description; - } - - if (profile.tools) { - customAgent.tools = profile.tools; - } - - if (profile.infer !== undefined) { - customAgent.infer = profile.infer; - } - - return customAgent; + return this.sessionTurnExecutor.mapProjectAgentProfile(profile); } private async listKnownApprovalToolNames( @@ -3451,128 +2645,48 @@ export class AryxAppService extends EventEmitter { } private async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise { - const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling); - if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) { - return false; - } - - workspace.settings.discoveredUserTooling = nextState; - return true; + return this.discoveredToolingSyncService.syncUserDiscoveredTooling(workspace); } private async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise { - await this.projectCustomizationWatcher.syncProjects( - workspace.projects - .filter((project) => !isScratchpadProject(project)) - .map((project) => ({ - id: project.id, - path: project.path, - })), - ); + await this.discoveredToolingSyncService.syncProjectCustomizationWatchers(workspace); } private resolveRunTurnPromptInvocation( session: SessionRecord, triggerMessageId: string, ): ProjectPromptInvocation | undefined { - const triggerMessage = session.messages.find((message) => message.id === triggerMessageId); - return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation); + return this.sessionTurnExecutor.resolveRunTurnPromptInvocation(session, triggerMessageId); } private async handleProjectCustomizationWatcherChange(projectId: string): Promise { - 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.persistAndBroadcast(workspace); - } - }); + await this.discoveredToolingSyncService.handleProjectCustomizationWatcherChange(projectId); } private enqueueCustomizationWatcherUpdate(task: () => Promise): Promise { - const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task); - this.customizationWatcherUpdateQueue = scheduledTask.then( - () => undefined, - () => undefined, - ); - return scheduledTask; + return task(); } private async syncProjectCustomization(project: ProjectRecord): Promise { - 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; + return this.discoveredToolingSyncService.syncProjectCustomization(project); } private async syncProjectDiscoveredTooling( workspace: WorkspaceState, project: ProjectRecord, ): Promise { - 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; + return this.discoveredToolingSyncService.syncProjectDiscoveredTooling(workspace, project); } private async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise { - const targets = [ - ...this.listAcceptedDiscoveredServerDefinitions( - workspace, - (server) => !server.probedTools || server.probedTools.length === 0, - ), - ...workspace.settings.tooling.mcpServers.filter( - (server) => server.tools.length === 0 && (!server.probedTools || server.probedTools.length === 0), - ), - ]; - - await this.probeWorkspaceMcpServers(workspace, targets); + await this.mcpProbeManager.probeAllAcceptedMcpServers(workspace); } private async probeDiscoveredMcpServersFromState( workspace: WorkspaceState, state?: DiscoveredToolingState, ): Promise { - const targets = listAcceptedDiscoveredMcpServers(state) - .filter((server) => !server.probedTools || server.probedTools.length === 0) - .map((server) => this.discoveredServerToDefinition(server)); - await this.probeWorkspaceMcpServers(workspace, targets); + await this.mcpProbeManager.probeDiscoveredMcpServersFromState(workspace, state); } private async probeDiscoveredMcpServers( @@ -3580,46 +2694,14 @@ export class AryxAppService extends EventEmitter { state: DiscoveredToolingState | undefined, serverIds: ReadonlyArray, ): Promise { - const targets = listAcceptedDiscoveredMcpServers(state) - .filter((server) => serverIds.includes(server.id)) - .map((server) => this.discoveredServerToDefinition(server)); - await this.probeWorkspaceMcpServers(workspace, targets); + await this.mcpProbeManager.probeDiscoveredMcpServers(workspace, state, serverIds); } private async probeWorkspaceMcpServers( workspace: WorkspaceState, targets: ReadonlyArray, ): Promise { - const uniqueTargets = [...new Map(targets.map((server) => [server.id, server])).values()]; - if (uniqueTargets.length === 0) { - return; - } - - const targetIds = uniqueTargets.map((server) => server.id); - const tokenLookup = (url: string) => getStoredToken(url)?.accessToken; - await this.enqueueMcpProbeUpdate(async () => { - if (this.addMcpProbingServerIds(workspace, targetIds)) { - await this.persistAndBroadcast(workspace); - } - }); - - try { - await this.probeMcpServers(uniqueTargets, tokenLookup, (result) => - this.enqueueMcpProbeUpdate(async () => { - const didUpdateProbing = this.removeMcpProbingServerIds(workspace, [result.serverId]); - const didApplyResult = this.applyMcpProbeResult(workspace, result); - if (didUpdateProbing || didApplyResult) { - await this.persistAndBroadcast(workspace); - } - }), - ); - } finally { - await this.enqueueMcpProbeUpdate(async () => { - if (this.removeMcpProbingServerIds(workspace, targetIds)) { - await this.persistAndBroadcast(workspace); - } - }); - } + await this.mcpProbeManager.probeWorkspaceMcpServers(workspace, targets); } /** @@ -3627,61 +2709,26 @@ export class AryxAppService extends EventEmitter { * succeeds, so their tools appear in the approval pill without restart. */ private async reprobeServerByUrl(serverUrl: string): Promise { - const workspace = await this.loadWorkspace(); - - // Collect matching servers from manual config and discovered tooling - const targets: McpServerDefinition[] = []; - - for (const server of workspace.settings.tooling.mcpServers) { - if (server.transport !== 'local' && server.url === serverUrl) { - targets.push(server); - } - } - - const allDiscovered = [ - ...(workspace.settings.discoveredUserTooling?.mcpServers ?? []), - ...workspace.projects.flatMap((p) => p.discoveredTooling?.mcpServers ?? []), - ]; - for (const server of allDiscovered) { - if (server.status === 'accepted' && server.transport !== 'local' && server.url === serverUrl) { - targets.push(this.discoveredServerToDefinition(server)); - } - } - - await this.probeWorkspaceMcpServers(workspace, targets); + await this.mcpProbeManager.reprobeServerByUrl(serverUrl); } private listAcceptedDiscoveredServerDefinitions( workspace: WorkspaceState, predicate?: (server: DiscoveredMcpServer) => boolean, ): McpServerDefinition[] { - const definitions: McpServerDefinition[] = []; - - for (const state of this.listDiscoveredToolingStates(workspace)) { - for (const server of listAcceptedDiscoveredMcpServers(state)) { - if (predicate && !predicate(server)) { - continue; - } - definitions.push(this.discoveredServerToDefinition(server)); - } - } - - return definitions; + return this.mcpProbeManager.listAcceptedDiscoveredServerDefinitions(workspace, predicate); } private listDiscoveredToolingStates(workspace: WorkspaceState): Array { - return [ - workspace.settings.discoveredUserTooling, - ...workspace.projects.map((project) => project.discoveredTooling), - ]; + return this.mcpProbeManager.listDiscoveredToolingStates(workspace); } private addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray): boolean { - return this.updateMcpProbingServerIds(workspace, serverIds, 'add'); + return this.mcpProbeManager.addMcpProbingServerIds(workspace, serverIds); } private removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray): boolean { - return this.updateMcpProbingServerIds(workspace, serverIds, 'remove'); + return this.mcpProbeManager.removeMcpProbingServerIds(workspace, serverIds); } private updateMcpProbingServerIds( @@ -3689,92 +2736,19 @@ export class AryxAppService extends EventEmitter { serverIds: ReadonlyArray, operation: 'add' | 'remove', ): boolean { - const next = new Set(workspace.mcpProbingServerIds ?? []); - const before = next.size; - - for (const serverId of serverIds) { - if (operation === 'add') { - next.add(serverId); - } else { - next.delete(serverId); - } - } - - if (next.size === before) { - return false; - } - - if (next.size === 0) { - delete workspace.mcpProbingServerIds; - } else { - workspace.mcpProbingServerIds = [...next]; - } - - return true; + return this.mcpProbeManager.updateMcpProbingServerIds(workspace, serverIds, operation); } private applyMcpProbeResult(workspace: WorkspaceState, result: McpProbeResult): boolean { - if (result.status !== 'success' || result.tools.length === 0) { - return false; - } - - let changed = false; - - for (const server of workspace.settings.tooling.mcpServers) { - if (server.id !== result.serverId) { - continue; - } - server.probedTools = result.tools; - changed = true; - } - - for (const state of this.listDiscoveredToolingStates(workspace)) { - for (const server of state?.mcpServers ?? []) { - if (server.id !== result.serverId) { - continue; - } - server.probedTools = result.tools; - changed = true; - } - } - - return changed; + return this.mcpProbeManager.applyMcpProbeResult(workspace, result); } private async enqueueMcpProbeUpdate(update: () => Promise): Promise { - const next = this.mcpProbeUpdateQueue.then(update, update); - this.mcpProbeUpdateQueue = next.catch(() => undefined); - await next; + await update(); } private discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition { - if (server.transport === 'local') { - return { - id: server.id, - name: server.name, - transport: 'local', - command: server.command, - args: [...server.args], - cwd: server.cwd, - env: server.env ? { ...server.env } : undefined, - tools: [...server.tools], - timeoutMs: server.timeoutMs, - createdAt: nowIso(), - updatedAt: nowIso(), - }; - } - - return { - id: server.id, - name: server.name, - transport: server.transport, - url: server.url, - headers: server.headers ? { ...server.headers } : undefined, - tools: [...server.tools], - timeoutMs: server.timeoutMs, - createdAt: nowIso(), - updatedAt: nowIso(), - }; + return this.mcpProbeManager.discoveredServerToDefinition(server); } private pruneUnavailableSessionToolingSelections(workspace: WorkspaceState): boolean { @@ -3808,34 +2782,21 @@ export class AryxAppService extends EventEmitter { private resolveDiscoveredToolingStatus( resolution: DiscoveredToolingResolution, ): Exclude { - return resolution === 'accept' ? 'accepted' : 'dismissed'; + return this.discoveredToolingSyncService.resolveDiscoveredToolingStatus(resolution); } private 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)); + return this.discoveredToolingSyncService.equalDiscoveredToolingState(left, right); } private 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, - }); + return this.discoveredToolingSyncService.equalProjectCustomizationState(left, right); } private updateSessionRun( @@ -3894,7 +2855,7 @@ export class AryxAppService extends EventEmitter { } if (session.pendingUserInput) { - this.pendingUserInputHandles.delete(session.pendingUserInput.id); + this.approvalCoordinator.pendingUserInputHandles.delete(session.pendingUserInput.id); session.pendingUserInput = undefined; } @@ -3912,13 +2873,7 @@ export class AryxAppService extends EventEmitter { } private findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined { - const matchingRun = session.runs.find((run) => - run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId)); - if (matchingRun) { - return matchingRun.requestId; - } - - return session.runs.find((run) => run.status === 'running')?.requestId; + return this.approvalCoordinator.findApprovalRequestId(session, approvalId); } private async loadSidecarCapabilities(forceRefresh = false): Promise { diff --git a/src/main/services/approvalCoordinator.ts b/src/main/services/approvalCoordinator.ts new file mode 100644 index 0000000..58e11ca --- /dev/null +++ b/src/main/services/approvalCoordinator.ts @@ -0,0 +1,414 @@ +import type { + ApprovalRequestedEvent, + ExitPlanModeRequestedEvent, + McpOauthRequiredEvent, + UserInputRequestedEvent, +} from '@shared/contracts/sidecar'; +import { + dequeuePendingApprovalState, + enqueuePendingApprovalState, + listPendingApprovals, + resolvePendingApproval, + resolveApprovalToolKey, + type ApprovalDecision, + type PendingApprovalRecord, +} from '@shared/domain/approval'; +import type { SessionRecord } from '@shared/domain/session'; +import type { SessionEventRecord } from '@shared/domain/event'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; +import type { WorkspaceState } from '@shared/domain/workspace'; +import { nowIso } from '@shared/utils/ids'; + +type PendingApprovalHandle = { + sessionId: string; + requestId: string; + resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise; +}; + +type PendingUserInputHandle = { + sessionId: string; + requestId: string; + resolve: (answer: string, wasFreeform: boolean) => void | Promise; +}; + +type ApprovalCoordinatorDeps = { + requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord; + persistWorkspace: (workspace: WorkspaceState) => Promise; + updateSessionRun: ( + session: SessionRecord, + requestId: string, + updater: (run: SessionRunRecord) => SessionRunRecord, + ) => SessionRunRecord | undefined; + emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; + emitSessionEvent: (event: SessionEventRecord) => void; + failSessionRunRecord: (run: SessionRunRecord, failedAt: string, error: string) => SessionRunRecord; + upsertRunApprovalEvent: ( + run: SessionRunRecord, + approval: PendingApprovalRecord, + ) => SessionRunRecord; +}; + +export class ApprovalCoordinator { + readonly pendingApprovalHandles = new Map(); + + readonly pendingUserInputHandles = new Map(); + + private readonly requireSession: ApprovalCoordinatorDeps['requireSession']; + + private readonly persistWorkspace: ApprovalCoordinatorDeps['persistWorkspace']; + + private readonly updateSessionRun: ApprovalCoordinatorDeps['updateSessionRun']; + + private readonly emitRunUpdated: ApprovalCoordinatorDeps['emitRunUpdated']; + + private readonly emitSessionEvent: ApprovalCoordinatorDeps['emitSessionEvent']; + + private readonly failSessionRunRecord: ApprovalCoordinatorDeps['failSessionRunRecord']; + + private readonly upsertRunApprovalEvent: ApprovalCoordinatorDeps['upsertRunApprovalEvent']; + + constructor(deps: ApprovalCoordinatorDeps) { + this.requireSession = deps.requireSession; + this.persistWorkspace = deps.persistWorkspace; + this.updateSessionRun = deps.updateSessionRun; + this.emitRunUpdated = deps.emitRunUpdated; + this.emitSessionEvent = deps.emitSessionEvent; + this.failSessionRunRecord = deps.failSessionRunRecord; + this.upsertRunApprovalEvent = deps.upsertRunApprovalEvent; + } + + async resolveSessionApproval( + workspace: WorkspaceState, + sessionId: string, + approvalId: string, + decision: ApprovalDecision, + alwaysApprove?: boolean, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const approval = session.pendingApproval; + if (!approval || approval.id !== approvalId) { + const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId); + if (queuedApproval) { + throw new Error( + approval + ? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.` + : `Approval "${approvalId}" is queued but not active for session "${sessionId}".`, + ); + } + + throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`); + } + + const handle = this.pendingApprovalHandles.get(approvalId); + if (!handle || handle.sessionId !== sessionId) { + throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`); + } + + const resolvedAt = nowIso(); + const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt); + this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); + session.updatedAt = resolvedAt; + + const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); + if (decision === 'approved' && alwaysApprove && approvalKey) { + const existing = session.approvalSettings?.autoApprovedToolNames ?? []; + if (!existing.includes(approvalKey)) { + session.approvalSettings = { autoApprovedToolNames: [...existing, approvalKey] }; + } + } + + const updatedRun = this.updateSessionRun(session, handle.requestId, (run) => + this.upsertRunApprovalEvent(run, resolvedApproval)); + + const cascadeHandles: PendingApprovalHandle[] = []; + if (decision === 'approved' && approvalKey && approval.kind === 'tool-call') { + for (const queued of listPendingApprovals(session)) { + if (queued.id === approvalId) { + continue; + } + + const queuedKey = resolveApprovalToolKey(queued.toolName, queued.permissionKind); + if (queuedKey !== approvalKey) { + continue; + } + + const queuedHandle = this.pendingApprovalHandles.get(queued.id); + if (!queuedHandle || queuedHandle.sessionId !== sessionId) { + continue; + } + + const cascadeResolved = resolvePendingApproval(queued, 'approved', resolvedAt); + this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, queued.id)); + this.updateSessionRun(session, queuedHandle.requestId, (run) => + this.upsertRunApprovalEvent(run, cascadeResolved)); + this.pendingApprovalHandles.delete(queued.id); + cascadeHandles.push(queuedHandle); + } + } + + const result = await this.persistWorkspace(workspace); + if (updatedRun) { + this.emitRunUpdated(sessionId, resolvedAt, updatedRun); + } + + this.pendingApprovalHandles.delete(approvalId); + + try { + await Promise.resolve(handle.resolve(decision, alwaysApprove)); + for (const cascaded of cascadeHandles) { + await Promise.resolve(cascaded.resolve('approved', alwaysApprove)); + } + } catch (error) { + const failedAt = nowIso(); + this.rejectPendingApprovals( + session, + failedAt, + 'Queued approval was cancelled because the run failed before it could resume.', + ); + session.status = 'error'; + session.lastError = error instanceof Error ? error.message : String(error); + session.updatedAt = failedAt; + + const failedRun = this.updateSessionRun(session, handle.requestId, (run) => + this.failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.')); + + this.emitSessionEvent({ + sessionId, + kind: 'error', + occurredAt: failedAt, + error: session.lastError, + }); + if (failedRun) { + this.emitRunUpdated(sessionId, failedAt, failedRun); + } + + await this.persistWorkspace(workspace); + throw error; + } + + return result; + } + + async resolveSessionUserInput( + workspace: WorkspaceState, + sessionId: string, + userInputId: string, + answer: string, + wasFreeform: boolean, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const pending = session.pendingUserInput; + if (!pending || pending.id !== userInputId) { + throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`); + } + + const handle = this.pendingUserInputHandles.get(userInputId); + if (!handle || handle.sessionId !== sessionId) { + throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`); + } + + const answeredAt = nowIso(); + session.pendingUserInput = { + ...pending, + status: 'answered', + answer, + answeredAt, + }; + session.updatedAt = answeredAt; + + const result = await this.persistWorkspace(workspace); + this.pendingUserInputHandles.delete(userInputId); + + try { + await Promise.resolve(handle.resolve(answer, wasFreeform)); + session.pendingUserInput = undefined; + await this.persistWorkspace(workspace); + } catch (error) { + session.status = 'error'; + session.lastError = error instanceof Error ? error.message : String(error); + session.updatedAt = nowIso(); + + this.emitSessionEvent({ + sessionId, + kind: 'error', + occurredAt: session.updatedAt, + error: session.lastError, + }); + + await this.persistWorkspace(workspace); + throw error; + } + + return result; + } + + async handleApprovalRequested( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + approval: ApprovalRequestedEvent | PendingApprovalRecord, + resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const pendingApproval = + 'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval; + + this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval)); + session.updatedAt = pendingApproval.requestedAt; + + const updatedRun = this.updateSessionRun(session, requestId, (run) => + this.upsertRunApprovalEvent(run, pendingApproval)); + + this.pendingApprovalHandles.set(pendingApproval.id, { + sessionId, + requestId, + resolve, + }); + + await this.persistWorkspace(workspace); + if (updatedRun) { + this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun); + } + } + + async handleUserInputRequested( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: UserInputRequestedEvent, + resolve: (answer: string, wasFreeform: boolean) => void | Promise, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const requestedAt = nowIso(); + + session.pendingUserInput = { + id: event.userInputId, + status: 'pending', + agentId: event.agentId, + agentName: event.agentName, + question: event.question, + choices: event.choices, + allowFreeform: event.allowFreeform ?? true, + requestedAt, + }; + session.updatedAt = requestedAt; + + this.pendingUserInputHandles.set(event.userInputId, { + sessionId, + requestId, + resolve, + }); + + await this.persistWorkspace(workspace); + } + + async handleExitPlanModeRequested( + workspace: WorkspaceState, + sessionId: string, + event: ExitPlanModeRequestedEvent, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const requestedAt = nowIso(); + + session.pendingPlanReview = { + id: event.exitPlanId, + status: 'pending', + agentId: event.agentId, + agentName: event.agentName, + summary: event.summary, + planContent: event.planContent, + actions: event.actions, + recommendedAction: event.recommendedAction, + requestedAt, + }; + session.updatedAt = requestedAt; + + await this.persistWorkspace(workspace); + } + + async handleMcpOAuthRequired( + workspace: WorkspaceState, + sessionId: string, + event: McpOauthRequiredEvent, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const requestedAt = nowIso(); + + session.pendingMcpAuth = { + id: event.oauthRequestId, + status: 'pending', + agentId: event.agentId, + agentName: event.agentName, + serverName: event.serverName, + serverUrl: event.serverUrl, + staticClientConfig: event.staticClientConfig + ? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient } + : undefined, + requestedAt, + }; + session.updatedAt = requestedAt; + + await this.persistWorkspace(workspace); + } + + createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord { + return { + id: event.approvalId, + kind: event.approvalKind, + status: 'pending', + requestedAt: nowIso(), + agentId: event.agentId, + agentName: event.agentName, + toolName: event.toolName, + permissionKind: event.permissionKind, + title: event.title, + detail: event.detail, + permissionDetail: event.permissionDetail, + }; + } + + setSessionPendingApprovalState( + session: SessionRecord, + state: { + pendingApproval?: PendingApprovalRecord; + pendingApprovalQueue?: PendingApprovalRecord[]; + }, + ): void { + session.pendingApproval = state.pendingApproval; + session.pendingApprovalQueue = state.pendingApprovalQueue; + } + + rejectPendingApprovals( + session: SessionRecord, + failedAt: string, + error: string, + ): string[] { + const requestIds = new Set(); + + for (const pendingApproval of listPendingApprovals(session)) { + const requestId = this.findApprovalRequestId(session, pendingApproval.id); + const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error); + + if (requestId) { + requestIds.add(requestId); + this.updateSessionRun(session, requestId, (run) => + this.upsertRunApprovalEvent(run, rejectedApproval)); + } + + this.pendingApprovalHandles.delete(pendingApproval.id); + } + + this.setSessionPendingApprovalState(session, {}); + return [...requestIds]; + } + + findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined { + const matchingRun = session.runs.find((run) => + run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId)); + if (matchingRun) { + return matchingRun.requestId; + } + + return session.runs.find((run) => run.status === 'running')?.requestId; + } +} diff --git a/src/main/services/checkpointRecoveryManager.ts b/src/main/services/checkpointRecoveryManager.ts new file mode 100644 index 0000000..b0a6178 --- /dev/null +++ b/src/main/services/checkpointRecoveryManager.ts @@ -0,0 +1,171 @@ +import { rm } from 'node:fs/promises'; + +import type { + AgentActivityEvent, + ApprovalRequestedEvent, + ExitPlanModeRequestedEvent, + McpOauthRequiredEvent, + MessageReclassifiedEvent, + RunTurnCommand, + UserInputRequestedEvent, + TurnDeltaEvent, + WorkflowCheckpointResume, + WorkflowCheckpointSavedEvent, +} from '@shared/contracts/sidecar'; +import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; +import { nowIso } from '@shared/utils/ids'; + +import type { TurnScopedEvent } from '@main/sidecar/runTurnPending'; + +export type PendingApprovalHandleLike = { + sessionId: string; + requestId: string; +}; + +export type PendingUserInputHandleLike = { + sessionId: string; + requestId: string; +}; + +export type WorkflowCheckpointRecoveryState = { + workflowSessionId: string; + checkpointId: string; + storePath: string; + stepNumber: number; + sessionMessages: ChatMessageRecord[]; + runEvents: import('@shared/domain/runTimeline').RunTimelineEventRecord[]; +}; + +type CheckpointRecoveryManagerDeps = { + persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise; + emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; + updateSessionRun: ( + session: SessionRecord, + requestId: string, + updater: (run: SessionRunRecord) => SessionRunRecord, + ) => SessionRunRecord | undefined; + setSessionPendingApprovalState: ( + session: SessionRecord, + state: { + pendingApproval?: import('@shared/domain/approval').PendingApprovalRecord; + pendingApprovalQueue?: import('@shared/domain/approval').PendingApprovalRecord[]; + }, + ) => void; + pendingApprovalHandles: Map; + pendingUserInputHandles: Map; +}; + +export class CheckpointRecoveryManager { + readonly recoveries = new Map(); + + private readonly persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise; + private readonly emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; + private readonly updateSessionRun: CheckpointRecoveryManagerDeps['updateSessionRun']; + private readonly setSessionPendingApprovalState: CheckpointRecoveryManagerDeps['setSessionPendingApprovalState']; + private readonly pendingApprovalHandles: Map; + private readonly pendingUserInputHandles: Map; + + constructor(deps: CheckpointRecoveryManagerDeps) { + this.persistWorkspace = deps.persistWorkspace; + this.emitRunUpdated = deps.emitRunUpdated; + this.updateSessionRun = deps.updateSessionRun; + this.setSessionPendingApprovalState = deps.setSessionPendingApprovalState; + this.pendingApprovalHandles = deps.pendingApprovalHandles; + this.pendingUserInputHandles = deps.pendingUserInputHandles; + } + + async runSidecarTurnWithCheckpointRecovery( + workspace: import('@shared/domain/workspace').WorkspaceState, + session: SessionRecord, + requestId: string, + invokeTurn: (resumeFromCheckpoint?: WorkflowCheckpointResume) => Promise, + isUnexpectedSidecarTerminationError: (error: unknown) => boolean, + ): Promise { + try { + return await invokeTurn(); + } catch (error) { + const recovery = this.recoveries.get(requestId); + if (!isUnexpectedSidecarTerminationError(error) || !recovery) { + throw error; + } + + const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery); + await this.persistWorkspace(workspace); + if (restoredRun) { + this.emitRunUpdated(session.id, session.updatedAt, restoredRun); + } + + return invokeTurn({ + workflowSessionId: recovery.workflowSessionId, + checkpointId: recovery.checkpointId, + storePath: recovery.storePath, + }); + } + } + + recordWorkflowCheckpointRecovery( + session: SessionRecord, + run: SessionRunRecord, + event: WorkflowCheckpointSavedEvent, + ): void { + this.recoveries.set(event.requestId, { + workflowSessionId: event.workflowSessionId, + checkpointId: event.checkpointId, + storePath: event.storePath, + stepNumber: event.stepNumber, + sessionMessages: structuredClone(session.messages), + runEvents: structuredClone(run.events), + }); + } + + restoreWorkflowCheckpointRecovery( + session: SessionRecord, + requestId: string, + recovery: WorkflowCheckpointRecoveryState, + ): SessionRunRecord | undefined { + session.messages = structuredClone(recovery.sessionMessages); + session.status = 'running'; + session.lastError = undefined; + session.updatedAt = nowIso(); + this.clearPendingRunState(session, requestId); + + return this.updateSessionRun(session, requestId, (run) => ({ + ...run, + events: structuredClone(recovery.runEvents), + })); + } + + clearPendingRunState(session: SessionRecord, requestId: string): void { + this.setSessionPendingApprovalState(session, {}); + session.pendingUserInput = undefined; + session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; + + for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) { + if (handle.sessionId === session.id && handle.requestId === requestId) { + this.pendingApprovalHandles.delete(approvalId); + } + } + + for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) { + if (handle.sessionId === session.id && handle.requestId === requestId) { + this.pendingUserInputHandles.delete(userInputId); + } + } + } + + async cleanupWorkflowCheckpointRecovery(requestId: string): Promise { + const recovery = this.recoveries.get(requestId); + this.recoveries.delete(requestId); + if (!recovery) { + return; + } + + try { + await rm(recovery.storePath, { recursive: true, force: true }); + } catch (error) { + console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error); + } + } +} diff --git a/src/main/services/discoveredToolingSyncService.ts b/src/main/services/discoveredToolingSyncService.ts new file mode 100644 index 0000000..5df6c54 --- /dev/null +++ b/src/main/services/discoveredToolingSyncService.ts @@ -0,0 +1,195 @@ +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; + persistWorkspace: (workspace: WorkspaceState) => Promise; +}; + +export class DiscoveredToolingSyncService { + private customizationWatcherUpdateQueue = Promise.resolve(); + + private readonly configScanner: ConfigScannerRegistry; + private readonly customizationScanner: ProjectCustomizationScanner; + private readonly projectCustomizationWatcher: ProjectCustomizationWatcher; + private readonly loadWorkspace: () => Promise; + private readonly persistWorkspace: (workspace: WorkspaceState) => Promise; + + 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 { + 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 { + await this.projectCustomizationWatcher.syncProjects( + workspace.projects + .filter((project) => !isScratchpadProject(project)) + .map((project) => ({ + id: project.id, + path: project.path, + })), + ); + } + + async handleProjectCustomizationWatcherChange(projectId: string): Promise { + 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 { + 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 { + 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 { + 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): Promise { + const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task); + this.customizationWatcherUpdateQueue = scheduledTask.then( + () => undefined, + () => undefined, + ); + return scheduledTask; + } +} diff --git a/src/main/services/gitContextManager.ts b/src/main/services/gitContextManager.ts new file mode 100644 index 0000000..203122e --- /dev/null +++ b/src/main/services/gitContextManager.ts @@ -0,0 +1,306 @@ +import { + isScratchpadProject, + type ProjectGitDetails, + type ProjectGitDiffPreview, + type ProjectGitFileReference, + type ProjectRecord, +} from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import { setSessionRunGitSummary, type SessionRunRecord } from '@shared/domain/runTimeline'; +import type { WorkspaceState } from '@shared/domain/workspace'; +import { nowIso } from '@shared/utils/ids'; + +import { GitService } from '@main/git/gitService'; + +const GIT_REFRESH_DEBOUNCE_MS = 750; +const GIT_REFRESH_INTERVAL_MS = 60_000; + +type GitContextManagerDeps = { + gitService: GitService; + loadWorkspace: () => Promise; + persistWorkspace: (workspace: WorkspaceState) => Promise; + requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord; + requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord; + requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord; + syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise; + syncProjectCustomization: (project: ProjectRecord) => Promise; + pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean; + pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise; + updateSessionRun: ( + session: SessionRecord, + requestId: string, + updater: (run: SessionRunRecord) => SessionRunRecord, + ) => SessionRunRecord | undefined; + emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; +}; + +export class GitContextManager { + private readonly gitService: GitService; + private readonly loadWorkspace: () => Promise; + private readonly persistWorkspace: (workspace: WorkspaceState) => Promise; + private readonly requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord; + private readonly requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord; + private readonly requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord; + private readonly syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise; + private readonly syncProjectCustomization: (project: ProjectRecord) => Promise; + private readonly pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean; + private readonly pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise; + private readonly updateSessionRun: GitContextManagerDeps['updateSessionRun']; + private readonly emitRunUpdated: GitContextManagerDeps['emitRunUpdated']; + + private didStartPeriodicProjectGitRefresh = false; + private pendingProjectGitRefreshIds = new Set(); + private pendingRefreshAllProjects = false; + private projectGitRefreshTimer?: ReturnType; + private periodicProjectGitRefreshTimer?: ReturnType; + private runningProjectGitRefresh?: Promise; + + constructor(deps: GitContextManagerDeps) { + this.gitService = deps.gitService; + this.loadWorkspace = deps.loadWorkspace; + this.persistWorkspace = deps.persistWorkspace; + this.requireProject = deps.requireProject; + this.requireSession = deps.requireSession; + this.requireSessionRun = deps.requireSessionRun; + this.syncProjectDiscoveredTooling = deps.syncProjectDiscoveredTooling; + this.syncProjectCustomization = deps.syncProjectCustomization; + this.pruneUnavailableSessionToolingSelections = deps.pruneUnavailableSessionToolingSelections; + this.pruneUnavailableApprovalTools = deps.pruneUnavailableApprovalTools; + this.updateSessionRun = deps.updateSessionRun; + this.emitRunUpdated = deps.emitRunUpdated; + } + + dispose(): void { + if (this.projectGitRefreshTimer) { + clearTimeout(this.projectGitRefreshTimer); + this.projectGitRefreshTimer = undefined; + } + if (this.periodicProjectGitRefreshTimer) { + clearInterval(this.periodicProjectGitRefreshTimer); + this.periodicProjectGitRefreshTimer = undefined; + } + this.didStartPeriodicProjectGitRefresh = false; + } + + scheduleProjectGitRefresh(projectId?: string): void { + if (projectId) { + this.pendingProjectGitRefreshIds.add(projectId); + } else { + this.pendingRefreshAllProjects = true; + this.pendingProjectGitRefreshIds.clear(); + } + + if (this.projectGitRefreshTimer) { + clearTimeout(this.projectGitRefreshTimer); + } + + this.projectGitRefreshTimer = setTimeout(() => { + this.projectGitRefreshTimer = undefined; + void this.flushScheduledProjectGitRefresh(); + }, GIT_REFRESH_DEBOUNCE_MS); + this.projectGitRefreshTimer.unref?.(); + } + + async refreshProjectGitContext(projectId?: string): Promise { + return this.refreshProjectGitContexts(projectId ? [projectId] : undefined); + } + + async getProjectGitDetails(projectId: string, commitLimit = 20): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit); + } + + async getProjectGitFilePreview( + projectId: string, + file: ProjectGitFileReference, + ): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + return this.gitService.getWorkingTreeFilePreview(project.path, file); + } + + async discardSessionRunGitChanges( + sessionId: string, + runId: string, + files?: ProjectGitFileReference[], + ): Promise { + const workspace = await this.loadWorkspace(); + const session = this.requireSession(workspace, sessionId); + const project = this.requireProject(workspace, session.projectId); + const run = this.requireSessionRun(session, runId); + if (run.workspaceKind !== 'project') { + throw new Error('Run change review is only available for project-backed sessions.'); + } + + if (!run.postRunGitSummary) { + throw new Error('This run does not have any tracked git changes to discard.'); + } + + await this.gitService.discardRunChanges( + this.resolveRunWorkingDirectory(session, project, run), + { + summary: run.postRunGitSummary, + preRunBaselineFiles: run.preRunGitBaselineFiles, + files, + }, + ); + + await this.refreshProjectGitContexts([project.id]); + const refreshedWorkspace = await this.loadWorkspace(); + const refreshedSession = this.requireSession(refreshedWorkspace, sessionId); + const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId); + const nextRun = await this.refreshSessionRunGitSummary( + refreshedSession, + refreshedProject, + run.requestId, + nowIso(), + ); + if (nextRun) { + this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun); + } + + return this.persistWorkspace(refreshedWorkspace); + } + + async runProjectGitMutation( + projectId: string, + mutation: (project: ProjectRecord) => Promise, + ): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + if (isScratchpadProject(project)) { + throw new Error('Git operations are not available for the Scratchpad project.'); + } + + await mutation(project); + return this.refreshProjectGitContexts([project.id]); + } + + resolveRunWorkingDirectory( + session: SessionRecord, + project: ProjectRecord, + run: SessionRunRecord, + ): string { + return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path; + } + + async refreshSessionRunGitSummary( + session: SessionRecord, + project: ProjectRecord, + requestId: string, + occurredAt: string, + ): Promise { + const run = session.runs.find((candidate) => candidate.requestId === requestId); + if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) { + return undefined; + } + + const summary = await this.gitService.computeRunChangeSummary( + this.resolveRunWorkingDirectory(session, project, run), + { + generatedAt: occurredAt, + preRunSnapshot: run.preRunGitSnapshot, + preRunBaselineFiles: run.preRunGitBaselineFiles, + }, + ); + + return this.updateSessionRun(session, requestId, (currentRun) => + setSessionRunGitSummary(currentRun, summary)); + } + + async refreshProjectGitContexts(projectIds?: readonly string[]): Promise { + const workspace = await this.loadWorkspace(); + const projects = projectIds?.length + ? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId)) + : workspace.projects; + + let didRefreshGit = false; + let didSyncProjectTooling = false; + let didSyncProjectCustomization = false; + for (const project of projects) { + didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit; + didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling; + didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization; + } + + const didPruneSelections = didSyncProjectTooling + ? this.pruneUnavailableSessionToolingSelections(workspace) + : false; + const didPruneApprovalTools = didSyncProjectTooling + ? await this.pruneUnavailableApprovalTools(workspace) + : false; + + return ( + didRefreshGit + || didSyncProjectTooling + || didSyncProjectCustomization + || didPruneSelections + || didPruneApprovalTools + ) + ? this.persistWorkspace(workspace) + : workspace; + } + + startPeriodicProjectGitRefresh(): void { + if (this.didStartPeriodicProjectGitRefresh) { + return; + } + + this.didStartPeriodicProjectGitRefresh = true; + this.periodicProjectGitRefreshTimer = setInterval(() => { + this.scheduleProjectGitRefresh(); + }, GIT_REFRESH_INTERVAL_MS); + this.periodicProjectGitRefreshTimer.unref?.(); + } + + stopPeriodicProjectGitRefresh(): void { + if (this.periodicProjectGitRefreshTimer) { + clearInterval(this.periodicProjectGitRefreshTimer); + this.periodicProjectGitRefreshTimer = undefined; + } + this.didStartPeriodicProjectGitRefresh = false; + } + + async flushScheduledProjectGitRefresh(): Promise { + if (this.runningProjectGitRefresh) { + return; + } + + const projectIds = this.pendingRefreshAllProjects + ? undefined + : [...this.pendingProjectGitRefreshIds]; + this.pendingRefreshAllProjects = false; + this.pendingProjectGitRefreshIds.clear(); + + this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then( + () => undefined, + (error) => { + console.error('[aryx git]', error); + }, + ); + + try { + await this.runningProjectGitRefresh; + } finally { + this.runningProjectGitRefresh = undefined; + if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) { + this.scheduleProjectGitRefresh(); + } + } + } + + private async refreshGitContextForProject(project: ProjectRecord): Promise { + if (isScratchpadProject(project)) { + if (!project.git) { + return false; + } + + project.git = undefined; + return true; + } + + project.git = await this.gitService.describeProject(project.path); + return true; + } +} diff --git a/src/main/services/mcpProbeManager.ts b/src/main/services/mcpProbeManager.ts new file mode 100644 index 0000000..56415f9 --- /dev/null +++ b/src/main/services/mcpProbeManager.ts @@ -0,0 +1,309 @@ +import type { SessionToolingSelection, WorkspaceToolingSettings, McpServerDefinition } from '@shared/domain/tooling'; +import { + listAcceptedDiscoveredMcpServers, + type DiscoveredMcpServer, + type DiscoveredToolingState, +} from '@shared/domain/discoveredTooling'; +import type { WorkspaceState } from '@shared/domain/workspace'; +import { nowIso } from '@shared/utils/ids'; + +import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber'; +import { getStoredToken } from '@main/services/mcpTokenStore'; +import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService'; + +type McpProbeManagerDeps = { + loadWorkspace: () => Promise; + persistWorkspace: (workspace: WorkspaceState) => Promise; + probeMcpServers?: typeof probeServers; + tokenLookup?: (serverUrl: string) => string | undefined; + performMcpOAuthFlow?: typeof performMcpOAuthFlow; + requiresOAuth?: typeof requiresOAuth; +}; + +export class McpProbeManager { + private mcpProbeUpdateQueue = Promise.resolve(); + + private readonly loadWorkspace: () => Promise; + private readonly persistWorkspace: (workspace: WorkspaceState) => Promise; + private readonly probeMcpServers: typeof probeServers; + private readonly tokenLookup: (serverUrl: string) => string | undefined; + private readonly performMcpOAuthFlow: typeof performMcpOAuthFlow; + private readonly requiresOAuth: typeof requiresOAuth; + + constructor(deps: McpProbeManagerDeps) { + this.loadWorkspace = deps.loadWorkspace; + this.persistWorkspace = deps.persistWorkspace; + this.probeMcpServers = deps.probeMcpServers ?? probeServers; + this.tokenLookup = deps.tokenLookup ?? ((serverUrl) => getStoredToken(serverUrl)?.accessToken); + this.performMcpOAuthFlow = deps.performMcpOAuthFlow ?? performMcpOAuthFlow; + this.requiresOAuth = deps.requiresOAuth ?? requiresOAuth; + } + + async probeAndAuthenticateHttpMcpServers( + tooling: WorkspaceToolingSettings, + selection: SessionToolingSelection, + ): Promise { + const httpServers = selection.enabledMcpServerIds + .map((id) => tooling.mcpServers.find((server) => server.id === id)) + .filter((server): server is McpServerDefinition => !!server && server.transport !== 'local') + .filter((server) => server.transport === 'http' || server.transport === 'sse'); + + if (httpServers.length === 0) { + return; + } + + console.log(`[aryx oauth] Probing ${httpServers.length} HTTP MCP server(s) for OAuth requirements…`); + + for (const server of httpServers) { + if (server.transport === 'local') { + continue; + } + + const existingToken = this.tokenLookup(server.url); + if (existingToken) { + console.log(`[aryx oauth] Skipping ${server.name} — token already stored`); + continue; + } + + try { + const needsAuth = await this.requiresOAuth(server.url); + if (!needsAuth) { + console.log(`[aryx oauth] ${server.name} does not require OAuth`); + continue; + } + + console.log(`[aryx oauth] ${server.name} requires OAuth — starting flow…`); + const result = await this.performMcpOAuthFlow({ serverUrl: server.url }); + if (result.success) { + console.log(`[aryx oauth] ${server.name} authenticated successfully`); + void this.reprobeServerByUrl(server.url).catch((error) => { + console.error('[aryx mcp-probe] re-probe after auth failed:', error); + }); + } else { + console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`); + } + } catch (error) { + console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, error); + } + } + } + + async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise { + const targets = [ + ...this.listAcceptedDiscoveredServerDefinitions( + workspace, + (server) => !server.probedTools || server.probedTools.length === 0, + ), + ...workspace.settings.tooling.mcpServers.filter( + (server) => server.tools.length === 0 && (!server.probedTools || server.probedTools.length === 0), + ), + ]; + + await this.probeWorkspaceMcpServers(workspace, targets); + } + + async probeDiscoveredMcpServersFromState( + workspace: WorkspaceState, + state?: DiscoveredToolingState, + ): Promise { + const targets = listAcceptedDiscoveredMcpServers(state) + .filter((server) => !server.probedTools || server.probedTools.length === 0) + .map((server) => this.discoveredServerToDefinition(server)); + await this.probeWorkspaceMcpServers(workspace, targets); + } + + async probeDiscoveredMcpServers( + workspace: WorkspaceState, + state: DiscoveredToolingState | undefined, + serverIds: ReadonlyArray, + ): Promise { + const targets = listAcceptedDiscoveredMcpServers(state) + .filter((server) => serverIds.includes(server.id)) + .map((server) => this.discoveredServerToDefinition(server)); + await this.probeWorkspaceMcpServers(workspace, targets); + } + + async probeWorkspaceMcpServers( + workspace: WorkspaceState, + targets: ReadonlyArray, + ): Promise { + const uniqueTargets = [...new Map(targets.map((server) => [server.id, server])).values()]; + if (uniqueTargets.length === 0) { + return; + } + + const targetIds = uniqueTargets.map((server) => server.id); + await this.enqueueMcpProbeUpdate(async () => { + if (this.addMcpProbingServerIds(workspace, targetIds)) { + await this.persistWorkspace(workspace); + } + }); + + try { + await this.probeMcpServers(uniqueTargets, this.tokenLookup, (result) => + this.enqueueMcpProbeUpdate(async () => { + const didUpdateProbing = this.removeMcpProbingServerIds(workspace, [result.serverId]); + const didApplyResult = this.applyMcpProbeResult(workspace, result); + if (didUpdateProbing || didApplyResult) { + await this.persistWorkspace(workspace); + } + })); + } finally { + await this.enqueueMcpProbeUpdate(async () => { + if (this.removeMcpProbingServerIds(workspace, targetIds)) { + await this.persistWorkspace(workspace); + } + }); + } + } + + async reprobeServerByUrl(serverUrl: string): Promise { + const workspace = await this.loadWorkspace(); + const targets: McpServerDefinition[] = []; + + for (const server of workspace.settings.tooling.mcpServers) { + if (server.transport !== 'local' && server.url === serverUrl) { + targets.push(server); + } + } + + const allDiscovered = [ + ...(workspace.settings.discoveredUserTooling?.mcpServers ?? []), + ...workspace.projects.flatMap((project) => project.discoveredTooling?.mcpServers ?? []), + ]; + + for (const server of allDiscovered) { + if (server.status === 'accepted' && server.transport !== 'local' && server.url === serverUrl) { + targets.push(this.discoveredServerToDefinition(server)); + } + } + + await this.probeWorkspaceMcpServers(workspace, targets); + } + + listAcceptedDiscoveredServerDefinitions( + workspace: WorkspaceState, + predicate?: (server: DiscoveredMcpServer) => boolean, + ): McpServerDefinition[] { + const definitions: McpServerDefinition[] = []; + + for (const state of this.listDiscoveredToolingStates(workspace)) { + for (const server of listAcceptedDiscoveredMcpServers(state)) { + if (predicate && !predicate(server)) { + continue; + } + definitions.push(this.discoveredServerToDefinition(server)); + } + } + + return definitions; + } + + listDiscoveredToolingStates(workspace: WorkspaceState): Array { + return [ + workspace.settings.discoveredUserTooling, + ...workspace.projects.map((project) => project.discoveredTooling), + ]; + } + + addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray): boolean { + return this.updateMcpProbingServerIds(workspace, serverIds, 'add'); + } + + removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray): boolean { + return this.updateMcpProbingServerIds(workspace, serverIds, 'remove'); + } + + updateMcpProbingServerIds( + workspace: WorkspaceState, + serverIds: ReadonlyArray, + operation: 'add' | 'remove', + ): boolean { + const next = new Set(workspace.mcpProbingServerIds ?? []); + const before = next.size; + + for (const serverId of serverIds) { + if (operation === 'add') { + next.add(serverId); + } else { + next.delete(serverId); + } + } + + if (next.size === before) { + return false; + } + + if (next.size === 0) { + delete workspace.mcpProbingServerIds; + } else { + workspace.mcpProbingServerIds = [...next]; + } + + return true; + } + + applyMcpProbeResult(workspace: WorkspaceState, result: McpProbeResult): boolean { + if (result.status !== 'success' || result.tools.length === 0) { + return false; + } + + let changed = false; + + for (const server of workspace.settings.tooling.mcpServers) { + if (server.id !== result.serverId) { + continue; + } + server.probedTools = result.tools; + changed = true; + } + + for (const state of this.listDiscoveredToolingStates(workspace)) { + for (const server of state?.mcpServers ?? []) { + if (server.id !== result.serverId) { + continue; + } + server.probedTools = result.tools; + changed = true; + } + } + + return changed; + } + + discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition { + if (server.transport === 'local') { + return { + id: server.id, + name: server.name, + transport: 'local', + command: server.command, + args: [...server.args], + cwd: server.cwd, + env: server.env ? { ...server.env } : undefined, + tools: [...server.tools], + timeoutMs: server.timeoutMs, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + } + + return { + id: server.id, + name: server.name, + transport: server.transport, + url: server.url, + headers: server.headers ? { ...server.headers } : undefined, + tools: [...server.tools], + timeoutMs: server.timeoutMs, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + } + + private async enqueueMcpProbeUpdate(update: () => Promise): Promise { + const next = this.mcpProbeUpdateQueue.then(update, update); + this.mcpProbeUpdateQueue = next.catch(() => undefined); + await next; + } +} diff --git a/src/main/services/sessionTurnExecutor.ts b/src/main/services/sessionTurnExecutor.ts new file mode 100644 index 0000000..d092c5e --- /dev/null +++ b/src/main/services/sessionTurnExecutor.ts @@ -0,0 +1,952 @@ +import type { + AgentActivityEvent, + ApprovalRequestedEvent, + ExitPlanModeRequestedEvent, + InteractionMode, + McpOauthRequiredEvent, + MessageMode, + MessageReclassifiedEvent, + RunTurnCommand, + RunTurnCustomAgentConfig, + RunTurnToolingConfig, + TurnDeltaEvent, + UserInputRequestedEvent, + WorkflowCheckpointResume, +} from '@shared/contracts/sidecar'; +import { + buildAvailableModelCatalog, + findModelByReference, + normalizeWorkflowModels, + resolveReasoningEffort, +} from '@shared/domain/models'; +import { + approvalPolicyRequiresCheckpoint, + type ApprovalDecision, + type PendingApprovalMessageRecord, + type PendingApprovalRecord, +} from '@shared/domain/approval'; +import { + listEnabledProjectAgentProfiles, + normalizeProjectPromptInvocation, + type ProjectAgentProfile, + type ProjectPromptInvocation, + type ProjectCustomizationState, +} from '@shared/domain/projectCustomization'; +import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; +import { + applySessionApprovalSettings, + applySessionModelConfig, + resolveSessionTitle, + type ChatMessageRecord, + type SessionRecord, +} from '@shared/domain/session'; +import { + appendRunActivityEvent, + cancelSessionRunRecord, + completeSessionRunRecord, + createSessionRunRecord, + failSessionRunRecord, + upsertRunMessageEvent, + type SessionRunRecord, +} from '@shared/domain/runTimeline'; +import type { ChatMessageAttachment } from '@shared/domain/attachment'; +import type { SessionEventRecord } from '@shared/domain/event'; +import type { WorkspaceState } from '@shared/domain/workspace'; +import { + resolveWorkflowAgentNodes, + type ReasoningEffort, + type WorkflowDefinition, +} from '@shared/domain/workflow'; +import { + resolveWorkflowAgents as resolveWorkspaceWorkflowAgents, + type WorkspaceAgentDefinition, +} from '@shared/domain/workspaceAgent'; +import { nowIso } from '@shared/utils/ids'; +import { mergeStreamingText } from '@shared/utils/streamingText'; + +import type { TurnScopedEvent } from '@main/sidecar/runTurnPending'; +import { TurnCancelledError } from '@main/sidecar/turnCancelledError'; + +function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean { + return promptInvocation?.agent?.trim().toLowerCase() === 'plan'; +} + +type SessionTurnExecutorDeps = { + saveWorkspace: (workspace: WorkspaceState) => Promise; + persistWorkspace: (workspace: WorkspaceState) => Promise; + requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord; + resolveSessionWorkflow: (workspace: WorkspaceState, session: SessionRecord) => WorkflowDefinition; + updateSessionRun: ( + session: SessionRecord, + requestId: string, + updater: (run: SessionRunRecord) => SessionRunRecord, + ) => SessionRunRecord | undefined; + emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; + emitSessionEvent: (event: SessionEventRecord) => void; + rejectPendingApprovals: (session: SessionRecord, failedAt: string, error: string) => string[]; + buildRunTurnToolingConfig: ( + workspace: WorkspaceState, + session: SessionRecord, + ) => RunTurnToolingConfig | undefined; + runSidecarTurnWithCheckpointRecovery: ( + workspace: WorkspaceState, + session: SessionRecord, + requestId: string, + createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand, + onDelta: (event: TurnDeltaEvent) => void | Promise, + onActivity: (event: AgentActivityEvent) => void | Promise, + onApproval: (event: ApprovalRequestedEvent) => void | Promise, + onUserInput: (event: UserInputRequestedEvent) => void | Promise, + onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise, + onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise, + onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise, + onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise, + ) => Promise; + handleApprovalRequested: ( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + approval: ApprovalRequestedEvent | PendingApprovalRecord, + resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise, + ) => Promise; + handleUserInputRequested: ( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: UserInputRequestedEvent, + resolve: (answer: string, wasFreeform: boolean) => void | Promise, + ) => Promise; + handleMcpOAuthRequired: ( + workspace: WorkspaceState, + sessionId: string, + event: McpOauthRequiredEvent, + ) => Promise; + handleExitPlanModeRequested: ( + workspace: WorkspaceState, + sessionId: string, + event: ExitPlanModeRequestedEvent, + ) => Promise; + handleTurnScopedEvent: ( + workspace: WorkspaceState, + sessionId: string, + event: TurnScopedEvent, + ) => void | Promise; + sidecarResolveApproval: ( + approvalId: string, + decision: ApprovalDecision, + alwaysApprove?: boolean, + ) => Promise; + sidecarResolveUserInput: ( + userInputId: string, + answer: string, + wasFreeform: boolean, + ) => Promise; + captureWorkingTreeSnapshot: ( + projectPath: string, + scannedAt: string, + ) => Promise; + captureWorkingTreeBaseline: ( + projectPath: string, + snapshot: import('@shared/domain/project').ProjectGitWorkingTreeSnapshot, + ) => Promise; + refreshSessionRunGitSummary: ( + session: SessionRecord, + project: ProjectRecord, + requestId: string, + occurredAt: string, + ) => Promise; + cleanupWorkflowCheckpointRecovery: (requestId: string) => Promise; + scheduleProjectGitRefresh: (projectId: string) => void; + loadAvailableModelCatalog: () => Promise>; +}; + +export class SessionTurnExecutor { + private readonly saveWorkspace: SessionTurnExecutorDeps['saveWorkspace']; + + private readonly persistWorkspace: SessionTurnExecutorDeps['persistWorkspace']; + + private readonly requireSession: SessionTurnExecutorDeps['requireSession']; + + private readonly resolveSessionWorkflow: SessionTurnExecutorDeps['resolveSessionWorkflow']; + + private readonly updateSessionRun: SessionTurnExecutorDeps['updateSessionRun']; + + private readonly emitRunUpdated: SessionTurnExecutorDeps['emitRunUpdated']; + + private readonly emitSessionEvent: SessionTurnExecutorDeps['emitSessionEvent']; + + private readonly rejectPendingApprovals: SessionTurnExecutorDeps['rejectPendingApprovals']; + + private readonly buildRunTurnToolingConfig: SessionTurnExecutorDeps['buildRunTurnToolingConfig']; + + private readonly runSidecarTurnWithCheckpointRecovery: SessionTurnExecutorDeps['runSidecarTurnWithCheckpointRecovery']; + + private readonly handleApprovalRequested: SessionTurnExecutorDeps['handleApprovalRequested']; + + private readonly handleUserInputRequested: SessionTurnExecutorDeps['handleUserInputRequested']; + + private readonly handleMcpOAuthRequired: SessionTurnExecutorDeps['handleMcpOAuthRequired']; + + private readonly handleExitPlanModeRequested: SessionTurnExecutorDeps['handleExitPlanModeRequested']; + + private readonly handleTurnScopedEvent: SessionTurnExecutorDeps['handleTurnScopedEvent']; + + private readonly sidecarResolveApproval: SessionTurnExecutorDeps['sidecarResolveApproval']; + + private readonly sidecarResolveUserInput: SessionTurnExecutorDeps['sidecarResolveUserInput']; + + private readonly captureWorkingTreeSnapshot: SessionTurnExecutorDeps['captureWorkingTreeSnapshot']; + + private readonly captureWorkingTreeBaseline: SessionTurnExecutorDeps['captureWorkingTreeBaseline']; + + private readonly refreshSessionRunGitSummary: SessionTurnExecutorDeps['refreshSessionRunGitSummary']; + + private readonly cleanupWorkflowCheckpointRecovery: SessionTurnExecutorDeps['cleanupWorkflowCheckpointRecovery']; + + private readonly scheduleProjectGitRefresh: SessionTurnExecutorDeps['scheduleProjectGitRefresh']; + + private readonly loadAvailableModelCatalog: SessionTurnExecutorDeps['loadAvailableModelCatalog']; + + constructor(deps: SessionTurnExecutorDeps) { + this.saveWorkspace = deps.saveWorkspace; + this.persistWorkspace = deps.persistWorkspace; + this.requireSession = deps.requireSession; + this.resolveSessionWorkflow = deps.resolveSessionWorkflow; + this.updateSessionRun = deps.updateSessionRun; + this.emitRunUpdated = deps.emitRunUpdated; + this.emitSessionEvent = deps.emitSessionEvent; + this.rejectPendingApprovals = deps.rejectPendingApprovals; + this.buildRunTurnToolingConfig = deps.buildRunTurnToolingConfig; + this.runSidecarTurnWithCheckpointRecovery = deps.runSidecarTurnWithCheckpointRecovery; + this.handleApprovalRequested = deps.handleApprovalRequested; + this.handleUserInputRequested = deps.handleUserInputRequested; + this.handleMcpOAuthRequired = deps.handleMcpOAuthRequired; + this.handleExitPlanModeRequested = deps.handleExitPlanModeRequested; + this.handleTurnScopedEvent = deps.handleTurnScopedEvent; + this.sidecarResolveApproval = deps.sidecarResolveApproval; + this.sidecarResolveUserInput = deps.sidecarResolveUserInput; + this.captureWorkingTreeSnapshot = deps.captureWorkingTreeSnapshot; + this.captureWorkingTreeBaseline = deps.captureWorkingTreeBaseline; + this.refreshSessionRunGitSummary = deps.refreshSessionRunGitSummary; + this.cleanupWorkflowCheckpointRecovery = deps.cleanupWorkflowCheckpointRecovery; + this.scheduleProjectGitRefresh = deps.scheduleProjectGitRefresh; + this.loadAvailableModelCatalog = deps.loadAvailableModelCatalog; + } + + async runPreparedSessionTurn( + workspace: WorkspaceState, + session: SessionRecord, + project: ProjectRecord, + effectiveWorkflow: WorkflowDefinition, + projectInstructions: string | undefined, + options: { + occurredAt: string; + requestId: string; + triggerMessageId: string; + messageMode?: MessageMode; + attachments?: ChatMessageAttachment[]; + }, + ): Promise { + const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; + const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options; + const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId); + const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation); + const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation) + ? 'plan' + : session.interactionMode ?? 'interactive'; + const runWorkingDirectory = session.cwd ?? project.path; + const preRunGitSnapshot = workspaceKind === 'project' + ? await this.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt) + : undefined; + const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot + ? await this.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot) + : undefined; + if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) { + console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`); + } + + session.title = resolveSessionTitle(session, workflowForTurn, session.messages); + session.status = 'running'; + session.lastError = undefined; + session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; + session.updatedAt = occurredAt; + session.runs = [ + createSessionRunRecord({ + requestId, + project, + workingDirectory: runWorkingDirectory, + workspaceKind, + workflow: workflowForTurn, + triggerMessageId, + startedAt: occurredAt, + preRunGitSnapshot, + preRunGitBaselineFiles, + }), + ...session.runs, + ]; + + await this.persistWorkspace(workspace); + this.emitSessionEvent({ + sessionId: session.id, + kind: 'status', + status: 'running', + occurredAt, + }); + + try { + const createRunTurnCommand = ( + resumeFromCheckpoint?: WorkflowCheckpointResume, + ): RunTurnCommand => ({ + type: 'run-turn', + requestId, + sessionId: session.id, + projectPath: runWorkingDirectory, + workspaceKind, + mode: interactionMode, + messageMode, + projectInstructions, + workflow: workflowForTurn, + workflowLibrary: workspace.workflows, + messages: session.messages, + attachments: attachments?.length ? attachments : undefined, + promptInvocation, + tooling: this.buildRunTurnToolingConfig(workspace, session), + resumeFromCheckpoint, + }); + + const responseMessages = await this.runSidecarTurnWithCheckpointRecovery( + workspace, + session, + requestId, + createRunTurnCommand, + async (event) => { + await this.applyTurnDelta(workspace, session.id, requestId, event); + }, + async (event) => { + await this.applyAgentActivity(workspace, session.id, requestId, event); + }, + async (event) => { + await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) => + this.sidecarResolveApproval(event.approvalId, decision, alwaysApprove)); + }, + async (event) => { + await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) => + this.sidecarResolveUserInput(event.userInputId, answer, wasFreeform)); + }, + async (event) => { + await this.handleMcpOAuthRequired(workspace, session.id, event); + }, + async (event) => { + await this.handleExitPlanModeRequested(workspace, session.id, event); + }, + async (event) => { + await this.applyMessageReclassified(workspace, session.id, event); + }, + async (event) => { + await this.handleTurnScopedEvent(workspace, session.id, event); + }, + ); + + await this.awaitFinalResponseApproval(workspace, session.id, requestId, workflowForTurn, responseMessages); + this.finalizeTurn(workspace, session.id, requestId, responseMessages); + if (workspaceKind === 'project') { + const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso()); + if (completedRun) { + this.emitRunUpdated(session.id, nowIso(), completedRun); + } + } + await this.persistWorkspace(workspace); + await this.cleanupWorkflowCheckpointRecovery(requestId); + if (workspaceKind === 'project') { + this.scheduleProjectGitRefresh(project.id); + } + } catch (error) { + if (error instanceof TurnCancelledError) { + this.finalizeCancelledTurn(session, requestId); + if (workspaceKind === 'project') { + const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso()); + if (cancelledRun) { + this.emitRunUpdated(session.id, nowIso(), cancelledRun); + } + } + await this.persistWorkspace(workspace); + await this.cleanupWorkflowCheckpointRecovery(requestId); + if (workspaceKind === 'project') { + this.scheduleProjectGitRefresh(project.id); + } + return; + } + + const failedAt = nowIso(); + session.status = 'error'; + session.lastError = error instanceof Error ? error.message : String(error); + session.updatedAt = failedAt; + + const failedRun = this.updateSessionRun(session, requestId, (run) => + failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.')); + + this.emitSessionEvent({ + sessionId: session.id, + kind: 'error', + occurredAt: failedAt, + error: session.lastError, + }); + if (failedRun) { + this.emitRunUpdated(session.id, failedAt, failedRun); + } + + if (workspaceKind === 'project') { + const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt); + if (summarizedRun) { + this.emitRunUpdated(session.id, failedAt, summarizedRun); + } + } + + await this.persistWorkspace(workspace); + await this.cleanupWorkflowCheckpointRecovery(requestId); + if (workspaceKind === 'project') { + this.scheduleProjectGitRefresh(project.id); + } + } + } + + async buildEffectiveWorkflow( + workflow: WorkflowDefinition, + session: SessionRecord, + workspaceAgents: ReadonlyArray, + ): Promise { + const resolvedWorkflow = resolveWorkspaceWorkflowAgents(workflow, workspaceAgents); + const workflowWithSessionConfig = session.sessionModelConfig + ? applySessionModelConfig(resolvedWorkflow, session) + : resolvedWorkflow; + const workflowWithApprovalSettings = applySessionApprovalSettings(workflowWithSessionConfig, session); + + const modelCatalog = await this.loadAvailableModelCatalog(); + return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog); + } + + applyProjectCustomizationToWorkflow( + workflow: WorkflowDefinition, + project: ProjectRecord, + ): WorkflowDefinition { + if (isScratchpadProject(project)) { + return workflow; + } + + const projectCustomAgents = this.buildProjectCustomAgents(project.customization); + if (projectCustomAgents.length === 0) { + return workflow; + } + + const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0]; + if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') { + return workflow; + } + + const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? []; + const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase())); + const mergedCustomAgents = [ + ...existingCustomAgents, + ...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())), + ]; + + return { + ...workflow, + graph: { + ...workflow.graph, + nodes: workflow.graph.nodes.map((node) => { + if (node.id !== primaryAgentNode.id || node.kind !== 'agent' || node.config.kind !== 'agent') { + return node; + } + + return { + ...node, + config: { + ...node.config, + copilot: { + ...node.config.copilot, + customAgents: mergedCustomAgents, + }, + }, + }; + }), + }, + }; + } + + resolveRunTurnPromptInvocation( + session: SessionRecord, + triggerMessageId: string, + ): ProjectPromptInvocation | undefined { + const triggerMessage = session.messages.find((message) => message.id === triggerMessageId); + return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation); + } + + async applyPromptInvocationToWorkflow( + workflow: WorkflowDefinition, + promptInvocation?: ProjectPromptInvocation, + ): Promise { + const requestedModel = promptInvocation?.model?.trim(); + if (!requestedModel) { + return workflow; + } + + const modelCatalog = await this.loadAvailableModelCatalog(); + const resolvedModel = findModelByReference(requestedModel, modelCatalog); + const effectiveModelId = resolvedModel?.id ?? requestedModel; + + let didChange = false; + const nodes = workflow.graph.nodes.map((node) => { + if (node.kind !== 'agent' || node.config.kind !== 'agent') { + return node; + } + + const agent = node.config; + const reasoningEffort: ReasoningEffort | undefined = resolvedModel?.supportedReasoningEfforts + ? resolveReasoningEffort(resolvedModel, agent.reasoningEffort) + : undefined; + + if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) { + return node; + } + + didChange = true; + return { + ...node, + config: { + ...agent, + model: effectiveModelId, + reasoningEffort, + }, + }; + }); + + return didChange + ? { + ...workflow, + graph: { + ...workflow.graph, + nodes, + }, + } + : workflow; + } + + buildProjectCustomAgents( + customization?: ProjectCustomizationState, + ): RunTurnCustomAgentConfig[] { + return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile)); + } + + mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig { + const customAgent: RunTurnCustomAgentConfig = { + name: profile.name, + prompt: profile.prompt, + }; + + if (profile.displayName) { + customAgent.displayName = profile.displayName; + } + + if (profile.description) { + customAgent.description = profile.description; + } + + if (profile.tools) { + customAgent.tools = profile.tools; + } + + if (profile.infer !== undefined) { + customAgent.infer = profile.infer; + } + + return customAgent; + } + + private async applyTurnDelta( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: TurnDeltaEvent, + ): Promise { + if (event.content === undefined && event.contentDelta === undefined) { + return; + } + + const occurredAt = nowIso(); + const session = this.requireSession(workspace, sessionId); + const existing = session.messages.find((message) => message.id === event.messageId); + const content = + existing && event.content === undefined + ? mergeStreamingText(existing.content, event.contentDelta) + : (event.content ?? event.contentDelta); + + const completedMessages: ChatMessageRecord[] = []; + if (existing) { + existing.content = content; + existing.pending = true; + existing.authorName = event.authorName; + } else { + for (const message of session.messages) { + if (message.pending && message.role === 'assistant') { + message.pending = false; + completedMessages.push(message); + } + } + session.messages.push({ + id: event.messageId, + role: 'assistant', + authorName: event.authorName, + content, + createdAt: occurredAt, + pending: true, + }); + } + + const nextRun = this.updateSessionRun(session, requestId, (run) => + upsertRunMessageEvent(run, { + messageId: event.messageId, + occurredAt, + authorName: event.authorName, + content, + status: 'running', + })); + + session.updatedAt = occurredAt; + await this.saveWorkspace(workspace); + + for (const completed of completedMessages) { + this.emitSessionEvent({ + sessionId, + kind: 'message-complete', + occurredAt, + messageId: completed.id, + authorName: completed.authorName, + content: completed.content, + }); + } + this.emitSessionEvent({ + sessionId, + kind: 'message-delta', + occurredAt, + messageId: event.messageId, + authorName: event.authorName, + contentDelta: event.contentDelta, + content: event.content, + }); + if (nextRun) { + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } + } + + private async applyMessageReclassified( + workspace: WorkspaceState, + sessionId: string, + event: MessageReclassifiedEvent, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const message = session.messages.find((m) => m.id === event.messageId); + if (!message || message.messageKind === 'thinking') { + return; + } + + message.messageKind = 'thinking'; + const occurredAt = nowIso(); + session.updatedAt = occurredAt; + await this.saveWorkspace(workspace); + + this.emitSessionEvent({ + sessionId, + kind: 'message-reclassified', + occurredAt, + messageId: event.messageId, + messageKind: 'thinking', + }); + } + + private async applyAgentActivity( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: AgentActivityEvent, + ): Promise { + const occurredAt = nowIso(); + const session = this.requireSession(workspace, sessionId); + const activityType = event.activityType; + let nextRun: SessionRunRecord | undefined; + if (activityType !== 'completed') { + nextRun = this.updateSessionRun(session, requestId, (run) => + appendRunActivityEvent(run, { + activityType, + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + sourceAgentId: event.sourceAgentId, + sourceAgentName: event.sourceAgentName, + toolName: event.toolName, + toolCallId: event.toolCallId, + toolArguments: event.toolArguments, + fileChanges: event.fileChanges, + })); + } + if (nextRun) { + session.updatedAt = occurredAt; + await this.saveWorkspace(workspace); + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } + + this.emitSessionEvent({ + sessionId, + kind: 'agent-activity', + occurredAt, + activityType: event.activityType, + agentId: event.agentId, + agentName: event.agentName, + sourceAgentId: event.sourceAgentId, + sourceAgentName: event.sourceAgentName, + toolName: event.toolName, + toolCallId: event.toolCallId, + toolArguments: event.toolArguments, + fileChanges: event.fileChanges, + }); + } + + private emitCompletedActivity( + sessionId: string, + workflow: WorkflowDefinition, + message: ChatMessageRecord, + ): void { + if (message.role !== 'assistant') { + return; + } + + const agentNode = resolveWorkflowAgentNodes(workflow) + .find((candidate) => + candidate.config.kind === 'agent' + && (candidate.config.id === message.authorName || candidate.config.name === message.authorName)) + ; + const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined; + if (!agent) { + return; + } + + this.emitSessionEvent({ + sessionId, + kind: 'agent-activity', + occurredAt: nowIso(), + activityType: 'completed', + agentId: agent.id, + agentName: agent.name, + }); + } + + private finalizeTurn( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + messages: ChatMessageRecord[], + ): void { + const session = this.requireSession(workspace, sessionId); + const workflow = this.resolveSessionWorkflow(workspace, session); + const incomingIds = new Set(messages.map((message) => message.id)); + const existingIds = new Set(session.messages.map((message) => message.id)); + const hasVisibleResponse = session.messages.some( + (message) => message.role === 'assistant' && message.messageKind !== 'thinking', + ); + + for (const message of messages) { + const occurredAt = nowIso(); + const existing = session.messages.find((current) => current.id === message.id); + if (existing) { + existing.authorName = message.authorName; + existing.content = message.content; + existing.pending = false; + } else { + const isUnstreamedIntermediate = + message.role === 'assistant' + && hasVisibleResponse + && !message.messageKind; + session.messages.push({ + ...message, + pending: false, + messageKind: message.messageKind ?? (isUnstreamedIntermediate ? 'thinking' : undefined), + }); + } + + const reclassifiedAsThinking = + !existingIds.has(message.id) + && (message.messageKind === 'thinking' + || (message.role === 'assistant' && hasVisibleResponse && !message.messageKind)); + + const nextRun = this.updateSessionRun(session, requestId, (run) => + upsertRunMessageEvent(run, { + messageId: message.id, + occurredAt, + authorName: message.authorName, + content: message.content, + status: 'completed', + })); + this.emitSessionEvent({ + sessionId, + kind: 'message-complete', + occurredAt, + messageId: message.id, + authorName: message.authorName, + content: message.content, + }); + if (reclassifiedAsThinking) { + this.emitSessionEvent({ + sessionId, + kind: 'message-reclassified', + occurredAt, + messageId: message.id, + messageKind: 'thinking', + }); + } + if (nextRun) { + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } + + this.emitCompletedActivity(sessionId, workflow, message); + } + + for (const message of session.messages) { + if (message.pending && incomingIds.has(message.id)) { + message.pending = false; + } + } + + const completedAt = nowIso(); + session.status = 'idle'; + session.lastError = undefined; + session.pendingUserInput = undefined; + session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; + session.updatedAt = completedAt; + const completedRun = this.updateSessionRun(session, requestId, (run) => + completeSessionRunRecord(run, completedAt)); + this.emitSessionEvent({ + sessionId, + kind: 'status', + occurredAt: completedAt, + status: 'idle', + }); + if (completedRun) { + this.emitRunUpdated(sessionId, completedAt, completedRun); + } + } + + private finalizeCancelledTurn( + session: SessionRecord, + requestId: string, + ): void { + for (const message of session.messages) { + if (message.pending) { + message.pending = false; + } + } + + this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.'); + + const cancelledAt = nowIso(); + session.status = 'idle'; + session.lastError = undefined; + session.pendingUserInput = undefined; + session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; + session.updatedAt = cancelledAt; + const cancelledRun = this.updateSessionRun(session, requestId, (run) => + cancelSessionRunRecord(run, cancelledAt)); + this.emitSessionEvent({ + sessionId: session.id, + kind: 'status', + occurredAt: cancelledAt, + status: 'idle', + }); + if (cancelledRun) { + this.emitRunUpdated(session.id, cancelledAt, cancelledRun); + } + } + + private async awaitFinalResponseApproval( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + workflow: WorkflowDefinition, + messages: ChatMessageRecord[], + ): Promise { + const pendingApproval = this.buildFinalResponseApproval(workflow, messages); + if (!pendingApproval) { + return; + } + + let resolveDecision: ((decision: ApprovalDecision) => void) | undefined; + const decisionPromise = new Promise((resolve) => { + resolveDecision = resolve; + }); + + await this.handleApprovalRequested( + workspace, + sessionId, + requestId, + pendingApproval, + (decision) => { + resolveDecision?.(decision); + }, + ); + + const decision = await decisionPromise; + if (decision === 'rejected') { + throw new Error('Final response approval was rejected.'); + } + } + + private buildFinalResponseApproval( + workflow: WorkflowDefinition, + messages: ChatMessageRecord[], + ): PendingApprovalRecord | undefined { + const assistantMessages = messages.filter((message) => message.role === 'assistant'); + if (assistantMessages.length === 0) { + return undefined; + } + + const previewMessages: PendingApprovalMessageRecord[] = assistantMessages.map((message) => ({ + id: message.id, + authorName: message.authorName, + content: message.content, + })); + + for (let index = assistantMessages.length - 1; index >= 0; index -= 1) { + const message = assistantMessages[index]; + if (!message) { + continue; + } + + const agentNode = resolveWorkflowAgentNodes(workflow) + .find((candidate) => + candidate.config.kind === 'agent' + && (candidate.config.id === message.authorName || candidate.config.name === message.authorName)) + ; + const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined; + if (!approvalPolicyRequiresCheckpoint(workflow.settings.approvalPolicy, 'final-response', agent?.id)) { + continue; + } + + const agentName = agent?.name ?? message.authorName; + return { + id: `approval-${crypto.randomUUID()}`, + kind: 'final-response', + status: 'pending', + requestedAt: nowIso(), + agentId: agent?.id, + agentName, + title: agentName ? `Approve final response from ${agentName}` : 'Approve final response', + detail: 'Review the pending assistant response before it is added to the session transcript.', + messages: previewMessages, + }; + } + + return undefined; + } +} diff --git a/src/main/services/workflowManager.ts b/src/main/services/workflowManager.ts new file mode 100644 index 0000000..3dcd362 --- /dev/null +++ b/src/main/services/workflowManager.ts @@ -0,0 +1,299 @@ +import { + buildWorkflowExecutionDefinition, + normalizeWorkflowDefinition, + resolveWorkflowAgentNodes, + validateWorkflowDefinition, + type WorkflowDefinition, + type WorkflowReference, +} from '@shared/domain/workflow'; +import { + exportWorkflowDefinition, + importWorkflowDefinition, + type WorkflowExportFormat, + type WorkflowExportResult, +} from '@shared/domain/workflowSerialization'; +import { + applyWorkflowTemplate, + createWorkflowTemplateFromWorkflow, + normalizeWorkflowTemplateDefinition, + type WorkflowTemplateCategory, + type WorkflowTemplateDefinition, +} from '@shared/domain/workflowTemplate'; +import { applyDefaultToolApprovalPolicy } from '@shared/domain/approval'; +import type { SessionRecord } from '@shared/domain/session'; +import type { WorkspaceState } from '@shared/domain/workspace'; +import { createId, nowIso } from '@shared/utils/ids'; + +export class WorkflowManager { + saveWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkspaceState { + const normalizedWorkflow = normalizeWorkflowDefinition(workflow); + const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error'); + if (issues.length > 0) { + throw new Error(issues[0].message); + } + + const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id); + const candidate: WorkflowDefinition = { + ...normalizedWorkflow, + isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite, + createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(), + updatedAt: nowIso(), + }; + this.validateWorkflowReferences(workspace, candidate); + + if (existingIndex >= 0) { + workspace.workflows[existingIndex] = candidate; + } else { + workspace.workflows.push(candidate); + } + + workspace.selectedWorkflowId = candidate.id; + return workspace; + } + + saveWorkflowTemplate( + workspace: WorkspaceState, + workflowId: string, + options?: { + templateId?: string; + name?: string; + description?: string; + category?: WorkflowTemplateCategory; + }, + ): WorkspaceState { + 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 workspace; + } + + createWorkflowFromTemplate( + workspace: WorkspaceState, + templateId: string, + options?: { + workflowId?: string; + name?: string; + description?: string; + }, + ): WorkspaceState { + 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(workspace, workflow); + } + + deleteWorkflow(workspace: WorkspaceState, workflowId: string): WorkspaceState { + 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((candidate) => candidate.id !== workflowId); + + if (workspace.selectedWorkflowId === workflowId) { + workspace.selectedWorkflowId = workspace.workflows[0]?.id; + } + + return workspace; + } + + listWorkflowReferences(workspace: WorkspaceState, workflowId: string): WorkflowReference[] { + this.requireWorkflow(workspace, workflowId); + return this.listWorkflowReferencesInWorkspace(workspace, workflowId); + } + + exportWorkflow(workspace: WorkspaceState, workflowId: string, format: WorkflowExportFormat): WorkflowExportResult { + const workflow = this.requireWorkflow(workspace, workflowId); + return exportWorkflowDefinition(workflow, format); + } + + importWorkflow(content: string, format: 'yaml' | 'json'): WorkflowDefinition { + return importWorkflowDefinition(content, format); + } + + 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; + } + + requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition { + const workflow = workspace.workflows.find((current) => current.id === workflowId); + if (!workflow) { + throw new Error(`Workflow "${workflowId}" was not found.`); + } + + return workflow; + } + + 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}`; + } + + normalizeIdentifier(value: string, fallbackPrefix: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || createId(fallbackPrefix); + } + + resolveSessionWorkflow(workspace: WorkspaceState, session: SessionRecord): WorkflowDefinition { + return this.requireWorkflow(workspace, session.workflowId); + } + + buildResolvedExecutionWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkflowDefinition { + return normalizeWorkflowDefinition({ + ...workflow, + settings: { + ...workflow.settings, + approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy), + }, + }); + } + + createWorkflowResolutionOptions(workspace: WorkspaceState) { + return { + resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId), + }; + } + + validateWorkflowReferences(workspace: WorkspaceState, workflow: WorkflowDefinition): void { + const workflowLibrary = new Map(); + 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, + ): 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()); + } + + listWorkflowReferencesInWorkspace(workspace: WorkspaceState, workflowId: string): WorkflowReference[] { + const references: WorkflowReference[] = []; + + const visitWorkflow = ( + referencingWorkflow: WorkflowDefinition, + currentWorkflow: WorkflowDefinition, + visitedInlineWorkflows: Set, + ): 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()); + } + + return references; + } + + buildWorkflowExecutionDefinition(workspace: WorkspaceState, workflow: WorkflowDefinition) { + return buildWorkflowExecutionDefinition( + this.buildResolvedExecutionWorkflow(workspace, workflow), + this.createWorkflowResolutionOptions(workspace), + ); + } + + resolveWorkflowAgentNodes(workspace: WorkspaceState, workflow: WorkflowDefinition) { + void workspace; + return resolveWorkflowAgentNodes(this.buildResolvedExecutionWorkflow(workspace, workflow)); + } +} diff --git a/tests/main/appServiceDeps.test.ts b/tests/main/appServiceDeps.test.ts new file mode 100644 index 0000000..abaef74 --- /dev/null +++ b/tests/main/appServiceDeps.test.ts @@ -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); + }); +}); diff --git a/tests/main/appServiceGitRefresh.test.ts b/tests/main/appServiceGitRefresh.test.ts index 32d4068..20f3c3a 100644 --- a/tests/main/appServiceGitRefresh.test.ts +++ b/tests/main/appServiceGitRefresh.test.ts @@ -183,7 +183,19 @@ function createService( runTurn?: (command: RunTurnCommand) => Promise<[]>; }, ): InstanceType { - 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; internals.loadWorkspace = async () => { internals.workspace = workspace; @@ -216,33 +228,11 @@ function createService( computeRunChangeSummary: (projectPath: string) => Promise; }; } - ).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; - captureWorkingTreeBaseline: () => Promise<[]>; - computeRunChangeSummary: (projectPath: string) => Promise; - }; - } - ).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; } diff --git a/tests/main/appServiceMcpProbing.test.ts b/tests/main/appServiceMcpProbing.test.ts index b3415d5..f8d2a9f 100644 --- a/tests/main/appServiceMcpProbing.test.ts +++ b/tests/main/appServiceMcpProbing.test.ts @@ -70,26 +70,27 @@ function createService(workspace: WorkspaceState): { service: InstanceType; snapshots: WorkspaceState[]; } { - const service = new AryxAppService(); - const internals = service as unknown as Record; const snapshots: WorkspaceState[] = []; + const service = new AryxAppService({ + probeMcpServers: (async ( + servers: Array<{ id: string }>, + _tokenLookup?: (serverUrl: string) => string | undefined, + onResult?: (result: MockProbeResult) => void | Promise, + ) => { + 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; 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, - ) => { - probeCalls.push(servers.map((server) => server.id)); - for (const result of probeResults) { - await onResult?.(result); - } - return probeResults; - }; return { service, snapshots }; } diff --git a/tests/main/workflowManager.test.ts b/tests/main/workflowManager.test.ts new file mode 100644 index 0000000..d06de43 --- /dev/null +++ b/tests/main/workflowManager.test.ts @@ -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', + }, + ]); + }); +});