mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
refactor: extract AryxAppService into focused service delegates
Extract seven responsibility clusters from the 3,466-line AryxAppService monolith into focused service classes under src/main/services/: - WorkflowManager: workflow CRUD, templates, validation, resolution - McpProbeManager: MCP server probing, OAuth pre-auth, probe queuing - DiscoveredToolingSyncService: tooling/customization scanning, watchers - GitContextManager: git refresh orchestration, context updates, mutations - CheckpointRecoveryManager: checkpoint retry/recovery state handling - ApprovalCoordinator: approval/user-input/plan-review/OAuth state machine - SessionTurnExecutor: turn execution, streaming deltas, finalization AryxAppService remains the public facade consumed by IPC handlers but now delegates internally via constructor-injected service instances. A new AppServiceDeps type provides a clean dependency injection seam for testing. The facade is reduced from 3,466 to 2,572 lines. All existing tests pass with two test files migrated to constructor DI (appServiceGitRefresh, appServiceMcpProbing). New focused tests added for WorkflowManager and the DI seam itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+304
-1349
File diff suppressed because it is too large
Load Diff
@@ -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<void>;
|
||||
};
|
||||
|
||||
type PendingUserInputHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ApprovalCoordinatorDeps = {
|
||||
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
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<string, PendingApprovalHandle>();
|
||||
|
||||
readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
|
||||
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<WorkspaceState> {
|
||||
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<WorkspaceState> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<string, PendingApprovalHandleLike>;
|
||||
pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
|
||||
};
|
||||
|
||||
export class CheckpointRecoveryManager {
|
||||
readonly recoveries = new Map<string, WorkflowCheckpointRecoveryState>();
|
||||
|
||||
private readonly persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
|
||||
private readonly emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
private readonly updateSessionRun: CheckpointRecoveryManagerDeps['updateSessionRun'];
|
||||
private readonly setSessionPendingApprovalState: CheckpointRecoveryManagerDeps['setSessionPendingApprovalState'];
|
||||
private readonly pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
|
||||
private readonly pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
|
||||
|
||||
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<ChatMessageRecord[]>,
|
||||
isUnexpectedSidecarTerminationError: (error: unknown) => boolean,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
};
|
||||
|
||||
export class DiscoveredToolingSyncService {
|
||||
private customizationWatcherUpdateQueue = Promise.resolve();
|
||||
|
||||
private readonly configScanner: ConfigScannerRegistry;
|
||||
private readonly customizationScanner: ProjectCustomizationScanner;
|
||||
private readonly projectCustomizationWatcher: ProjectCustomizationWatcher;
|
||||
private readonly loadWorkspace: () => Promise<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
|
||||
constructor(deps: DiscoveredToolingSyncServiceDeps) {
|
||||
this.configScanner = deps.configScanner;
|
||||
this.customizationScanner = deps.customizationScanner;
|
||||
this.projectCustomizationWatcher = deps.projectCustomizationWatcher;
|
||||
this.loadWorkspace = deps.loadWorkspace;
|
||||
this.persistWorkspace = deps.persistWorkspace;
|
||||
}
|
||||
|
||||
async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
|
||||
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
|
||||
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
workspace.settings.discoveredUserTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
|
||||
await this.projectCustomizationWatcher.syncProjects(
|
||||
workspace.projects
|
||||
.filter((project) => !isScratchpadProject(project))
|
||||
.map((project) => ({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
|
||||
await this.enqueueCustomizationWatcherUpdate(async () => {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = workspace.projects.find((candidate) => candidate.id === projectId);
|
||||
await this.syncProjectCustomizationWatchers(workspace);
|
||||
|
||||
if (!project || isScratchpadProject(project)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
|
||||
await this.syncProjectCustomizationWatchers(workspace);
|
||||
if (didSyncProjectCustomization) {
|
||||
await this.persistWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
|
||||
if (this.equalProjectCustomizationState(project.customization, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncProjectDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
project: ProjectRecord,
|
||||
): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.configScanner.scanProject(
|
||||
project.id,
|
||||
project.path,
|
||||
project.discoveredTooling,
|
||||
);
|
||||
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
resolveDiscoveredToolingStatus(
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): Exclude<DiscoveredToolingStatus, 'pending'> {
|
||||
return resolution === 'accept' ? 'accepted' : 'dismissed';
|
||||
}
|
||||
|
||||
resolveWorkspaceDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): void {
|
||||
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
|
||||
workspace.settings.discoveredUserTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
}
|
||||
|
||||
resolveProjectDiscoveredTooling(
|
||||
project: ProjectRecord,
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): void {
|
||||
project.discoveredTooling = applyDiscoveredMcpServerStatus(
|
||||
project.discoveredTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
}
|
||||
|
||||
equalDiscoveredToolingState(
|
||||
left?: DiscoveredToolingState,
|
||||
right?: DiscoveredToolingState,
|
||||
): boolean {
|
||||
const stripRuntime = (servers: DiscoveredMcpServer[]) =>
|
||||
servers.map(({ probedTools: _, ...rest }) => rest);
|
||||
return JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(left).mcpServers))
|
||||
=== JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(right).mcpServers));
|
||||
}
|
||||
|
||||
equalProjectCustomizationState(
|
||||
left?: ProjectCustomizationState,
|
||||
right?: ProjectCustomizationState,
|
||||
): boolean {
|
||||
const normalizedLeft = normalizeProjectCustomizationState(left);
|
||||
const normalizedRight = normalizeProjectCustomizationState(right);
|
||||
return JSON.stringify({
|
||||
instructions: normalizedLeft.instructions,
|
||||
agentProfiles: normalizedLeft.agentProfiles,
|
||||
promptFiles: normalizedLeft.promptFiles,
|
||||
}) === JSON.stringify({
|
||||
instructions: normalizedRight.instructions,
|
||||
agentProfiles: normalizedRight.agentProfiles,
|
||||
promptFiles: normalizedRight.promptFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
|
||||
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
|
||||
this.customizationWatcherUpdateQueue = scheduledTask.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return scheduledTask;
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
|
||||
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
|
||||
requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
|
||||
syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
|
||||
syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
|
||||
pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
|
||||
pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
|
||||
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<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
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<boolean>;
|
||||
private readonly syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
|
||||
private readonly pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
|
||||
private readonly pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
|
||||
private readonly updateSessionRun: GitContextManagerDeps['updateSessionRun'];
|
||||
private readonly emitRunUpdated: GitContextManagerDeps['emitRunUpdated'];
|
||||
|
||||
private didStartPeriodicProjectGitRefresh = false;
|
||||
private pendingProjectGitRefreshIds = new Set<string>();
|
||||
private pendingRefreshAllProjects = false;
|
||||
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
private runningProjectGitRefresh?: Promise<void>;
|
||||
|
||||
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<WorkspaceState> {
|
||||
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
|
||||
}
|
||||
|
||||
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
|
||||
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<ProjectGitDiffPreview | undefined> {
|
||||
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<WorkspaceState> {
|
||||
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<void>,
|
||||
): Promise<WorkspaceState> {
|
||||
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<SessionRunRecord | undefined> {
|
||||
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<WorkspaceState> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.git) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.git = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
project.git = await this.gitService.describeProject(project.path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceState>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
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<WorkspaceState>;
|
||||
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>,
|
||||
): Promise<void> {
|
||||
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<McpServerDefinition>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<DiscoveredToolingState | undefined> {
|
||||
return [
|
||||
workspace.settings.discoveredUserTooling,
|
||||
...workspace.projects.map((project) => project.discoveredTooling),
|
||||
];
|
||||
}
|
||||
|
||||
addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
|
||||
return this.updateMcpProbingServerIds(workspace, serverIds, 'add');
|
||||
}
|
||||
|
||||
removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
|
||||
return this.updateMcpProbingServerIds(workspace, serverIds, 'remove');
|
||||
}
|
||||
|
||||
updateMcpProbingServerIds(
|
||||
workspace: WorkspaceState,
|
||||
serverIds: ReadonlyArray<string>,
|
||||
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<void>): Promise<void> {
|
||||
const next = this.mcpProbeUpdateQueue.then(update, update);
|
||||
this.mcpProbeUpdateQueue = next.catch(() => undefined);
|
||||
await next;
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
|
||||
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<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
handleApprovalRequested: (
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
approval: ApprovalRequestedEvent | PendingApprovalRecord,
|
||||
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
|
||||
) => Promise<void>;
|
||||
handleUserInputRequested: (
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
event: UserInputRequestedEvent,
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
|
||||
) => Promise<void>;
|
||||
handleMcpOAuthRequired: (
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: McpOauthRequiredEvent,
|
||||
) => Promise<void>;
|
||||
handleExitPlanModeRequested: (
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: ExitPlanModeRequestedEvent,
|
||||
) => Promise<void>;
|
||||
handleTurnScopedEvent: (
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnScopedEvent,
|
||||
) => void | Promise<void>;
|
||||
sidecarResolveApproval: (
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
alwaysApprove?: boolean,
|
||||
) => Promise<void>;
|
||||
sidecarResolveUserInput: (
|
||||
userInputId: string,
|
||||
answer: string,
|
||||
wasFreeform: boolean,
|
||||
) => Promise<void>;
|
||||
captureWorkingTreeSnapshot: (
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<import('@shared/domain/project').ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: (
|
||||
projectPath: string,
|
||||
snapshot: import('@shared/domain/project').ProjectGitWorkingTreeSnapshot,
|
||||
) => Promise<import('@shared/domain/project').ProjectGitBaselineFile[]>;
|
||||
refreshSessionRunGitSummary: (
|
||||
session: SessionRecord,
|
||||
project: ProjectRecord,
|
||||
requestId: string,
|
||||
occurredAt: string,
|
||||
) => Promise<SessionRunRecord | undefined>;
|
||||
cleanupWorkflowCheckpointRecovery: (requestId: string) => Promise<void>;
|
||||
scheduleProjectGitRefresh: (projectId: string) => void;
|
||||
loadAvailableModelCatalog: () => Promise<ReturnType<typeof buildAvailableModelCatalog>>;
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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<WorkspaceAgentDefinition>,
|
||||
): Promise<WorkflowDefinition> {
|
||||
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<WorkflowDefinition> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const pendingApproval = this.buildFinalResponseApproval(workflow, messages);
|
||||
if (!pendingApproval) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolveDecision: ((decision: ApprovalDecision) => void) | undefined;
|
||||
const decisionPromise = new Promise<ApprovalDecision>((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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, WorkflowDefinition>();
|
||||
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<WorkflowDefinition>,
|
||||
): 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<WorkflowDefinition>());
|
||||
}
|
||||
|
||||
listWorkflowReferencesInWorkspace(workspace: WorkspaceState, workflowId: string): WorkflowReference[] {
|
||||
const references: WorkflowReference[] = [];
|
||||
|
||||
const visitWorkflow = (
|
||||
referencingWorkflow: WorkflowDefinition,
|
||||
currentWorkflow: WorkflowDefinition,
|
||||
visitedInlineWorkflows: Set<WorkflowDefinition>,
|
||||
): 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<WorkflowDefinition>());
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
describe('AryxAppService dependency injection', () => {
|
||||
test('uses injected sidecar dependencies for capability lookups', async () => {
|
||||
const capabilities: SidecarCapabilities = {
|
||||
runtime: 'dotnet-maf',
|
||||
modes: {
|
||||
single: { available: true },
|
||||
sequential: { available: true },
|
||||
concurrent: { available: true },
|
||||
handoff: { available: true },
|
||||
'group-chat': { available: true },
|
||||
magentic: { available: true },
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
},
|
||||
],
|
||||
runtimeTools: [],
|
||||
connection: {
|
||||
status: 'ready',
|
||||
summary: 'Ready',
|
||||
checkedAt: '2026-04-07T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
const service = new AryxAppService({
|
||||
sidecar: {
|
||||
describeCapabilities: async () => capabilities,
|
||||
dispose: async () => undefined,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await expect(service.describeSidecarCapabilities()).resolves.toEqual(capabilities);
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,19 @@ function createService(
|
||||
runTurn?: (command: RunTurnCommand) => Promise<[]>;
|
||||
},
|
||||
): InstanceType<typeof AryxAppService> {
|
||||
const service = new AryxAppService();
|
||||
const service = new AryxAppService({
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: async (projectPath: string, scannedAt: string) => {
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath: string) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
internals.loadWorkspace = async () => {
|
||||
internals.workspace = workspace;
|
||||
@@ -216,33 +228,11 @@ function createService(
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
).sidecar = {
|
||||
runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [],
|
||||
resolveApproval: async () => undefined,
|
||||
resolveUserInput: async () => undefined,
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: (
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).gitService = {
|
||||
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -70,26 +70,27 @@ function createService(workspace: WorkspaceState): {
|
||||
service: InstanceType<typeof AryxAppService>;
|
||||
snapshots: WorkspaceState[];
|
||||
} {
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
const snapshots: WorkspaceState[] = [];
|
||||
const service = new AryxAppService({
|
||||
probeMcpServers: (async (
|
||||
servers: Array<{ id: string }>,
|
||||
_tokenLookup?: (serverUrl: string) => string | undefined,
|
||||
onResult?: (result: MockProbeResult) => void | Promise<void>,
|
||||
) => {
|
||||
probeCalls.push(servers.map((server) => server.id));
|
||||
for (const result of probeResults) {
|
||||
await onResult?.(result);
|
||||
}
|
||||
return probeResults;
|
||||
}) as never,
|
||||
});
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
|
||||
internals.loadWorkspace = async () => workspace;
|
||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => {
|
||||
snapshots.push(cloneWorkspaceState(nextWorkspace));
|
||||
return nextWorkspace;
|
||||
};
|
||||
internals.probeMcpServers = async (
|
||||
servers: Array<{ id: string }>,
|
||||
_tokenLookup?: (serverUrl: string) => string | undefined,
|
||||
onResult?: (result: MockProbeResult) => void | Promise<void>,
|
||||
) => {
|
||||
probeCalls.push(servers.map((server) => server.id));
|
||||
for (const result of probeResults) {
|
||||
await onResult?.(result);
|
||||
}
|
||||
return probeResults;
|
||||
};
|
||||
|
||||
return { service, snapshots };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { exportWorkflowDefinition } from '@shared/domain/workflowSerialization';
|
||||
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
||||
|
||||
import { WorkflowManager } from '@main/services/workflowManager';
|
||||
|
||||
function createWorkflow(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'workflow-test',
|
||||
name: 'Workflow Test',
|
||||
description: 'Simple workflow',
|
||||
createdAt: '2026-04-05T00:00:00.000Z',
|
||||
updatedAt: '2026-04-05T00:00:00.000Z',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'agent-primary',
|
||||
kind: 'agent',
|
||||
label: 'Primary',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-primary',
|
||||
name: 'Primary',
|
||||
description: 'Main agent',
|
||||
instructions: 'Help the user',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: 'start', target: 'agent-primary', kind: 'direct' },
|
||||
{ id: 'e2', source: 'agent-primary', target: 'end', kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSubWorkflow(
|
||||
id: string,
|
||||
name: string,
|
||||
config: { workflowId?: string; inlineWorkflow?: WorkflowDefinition },
|
||||
): WorkflowDefinition {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
createdAt: '2026-04-05T00:00:00.000Z',
|
||||
updatedAt: '2026-04-05T00:00:00.000Z',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'sub-workflow',
|
||||
kind: 'sub-workflow',
|
||||
label: 'Nested Workflow',
|
||||
position: { x: 200, y: 0 },
|
||||
config: {
|
||||
kind: 'sub-workflow',
|
||||
workflowId: config.workflowId,
|
||||
inlineWorkflow: config.inlineWorkflow,
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' },
|
||||
{ id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowManager', () => {
|
||||
test('saves workflows into workspace state and selects them', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
|
||||
const result = manager.saveWorkflow(workspace, createWorkflow());
|
||||
|
||||
expect(result.workflows.some((workflow) => workflow.id === 'workflow-test')).toBe(true);
|
||||
expect(result.selectedWorkflowId).toBe('workflow-test');
|
||||
});
|
||||
|
||||
test('creates workflow templates and workflows from templates', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
manager.saveWorkflow(workspace, createWorkflow());
|
||||
|
||||
manager.saveWorkflowTemplate(workspace, 'workflow-test', {
|
||||
name: 'Saved Template',
|
||||
description: 'From workflow',
|
||||
category: 'human-in-loop',
|
||||
});
|
||||
|
||||
const template = workspace.workflowTemplates.find((candidate) => candidate.name === 'Saved Template');
|
||||
expect(template).toBeDefined();
|
||||
|
||||
manager.createWorkflowFromTemplate(workspace, template!.id, { name: 'Template Copy' });
|
||||
|
||||
const createdWorkflow = workspace.workflows.find((workflow) => workflow.name === 'Template Copy');
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
expect(workspace.selectedWorkflowId).toBe(createdWorkflow?.id);
|
||||
});
|
||||
|
||||
test('imports exported yaml workflows', () => {
|
||||
const manager = new WorkflowManager();
|
||||
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
|
||||
|
||||
const workflow = manager.importWorkflow(yaml, 'yaml');
|
||||
|
||||
expect(workflow.id).toBe('workflow-test');
|
||||
expect(workflow.name).toBe('Workflow Test');
|
||||
});
|
||||
|
||||
test('rejects missing and circular sub-workflow references', () => {
|
||||
const manager = new WorkflowManager();
|
||||
const missingWorkspace = createWorkspaceSeed();
|
||||
|
||||
expect(() => manager.saveWorkflow(
|
||||
missingWorkspace,
|
||||
createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' }),
|
||||
)).toThrow('references unknown workflow "missing-child"');
|
||||
|
||||
const circularWorkspace = createWorkspaceSeed();
|
||||
circularWorkspace.workflows.push(
|
||||
createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' }),
|
||||
);
|
||||
|
||||
expect(() => manager.saveWorkflow(
|
||||
circularWorkspace,
|
||||
createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' }),
|
||||
)).toThrow('circular sub-workflow reference');
|
||||
});
|
||||
|
||||
test('prevents deleting referenced workflows and lists references through inline workflows', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const manager = new WorkflowManager();
|
||||
|
||||
manager.saveWorkflow(workspace, createWorkflow());
|
||||
manager.saveWorkflow(
|
||||
workspace,
|
||||
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'workflow-test' }),
|
||||
);
|
||||
manager.saveWorkflow(
|
||||
workspace,
|
||||
createSubWorkflow('inline-parent', 'Inline Parent', {
|
||||
inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'workflow-test' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => manager.deleteWorkflow(workspace, 'workflow-test')).toThrow('cannot be deleted');
|
||||
expect(manager.listWorkflowReferences(workspace, 'workflow-test')).toEqual([
|
||||
{
|
||||
referencingWorkflowId: 'parent',
|
||||
referencingWorkflowName: 'Parent Workflow',
|
||||
nodeId: 'sub-workflow',
|
||||
nodeLabel: 'Nested Workflow',
|
||||
},
|
||||
{
|
||||
referencingWorkflowId: 'inline-parent',
|
||||
referencingWorkflowName: 'Inline Parent',
|
||||
nodeId: 'sub-workflow',
|
||||
nodeLabel: 'Nested Workflow',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user