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', + }, + ]); + }); +});