mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
feat(workflows): Phase 1 backend — WorkflowDefinition model, persistence, IPC, sidecar execution
Introduce WorkflowDefinition as a first-class entity alongside PatternDefinition. Shared domain: - src/shared/domain/workflow.ts: new WorkflowDefinition, WorkflowGraph, WorkflowNode/Edge/Config types, normalization, TS-side validation, and buildWorkflowExecutionPattern (synthetic pattern bridge for session plumbing) - workspace.ts: add workflows[] and selectedWorkflowId - session.ts: add optional workflowId to SessionRecord - runTimeline.ts: add optional workflowId/workflowName to run records - sessionLibrary.ts: workflow-aware session search IPC / main process: - contracts/channels.ts, contracts/ipc.ts: workflow CRUD channels - preload/index.ts: expose saveWorkflow, deleteWorkflow, createWorkflowSession - ipc/registerIpcHandlers.ts: register workflow handlers - persistence/workspaceRepository.ts: load/save/normalize workflows - AryxAppService.ts: saveWorkflow, deleteWorkflow, createWorkflowSession, resolveSessionExecutionDefinition, workflow-aware turn/run plumbing Sidecar protocol: - contracts/sidecar.ts: ValidateWorkflowCommand, WorkflowValidationEvent, optional workflow on RunTurnCommand - sidecarProcess.ts: validate-workflow command dispatch and event handling - Contracts/ProtocolModels.cs: workflow DTOs and validate-workflow DTOs - Services/SidecarProtocolHost.cs: validate-workflow command handler - Services/CopilotWorkflowRunner.cs: workflow-aware build and checkpoint path - Services/WorkflowValidator.cs: graph validation (connectivity, start/end, fan-out/in arity, path reachability) - Services/WorkflowRunner.cs: WorkflowDefinitionDto -> Agent Framework Workflow builder (direct, fan-out, fan-in edges; agent executor binding) Project: bump both csproj from net9.0 to net10.0 Tests: - tests/shared/workflow.test.ts: TS workflow validation and pattern synthesis - tests/main/appServiceWorkflow.test.ts: saveWorkflow / createWorkflowSession - tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs: ValidateWorkflowCommand - Fix two stale workspace fixtures missing workflows field Validation: tsc clean, bun test 367/367 pass, dotnet test 244/244 pass, bun run build succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+153
-22
@@ -36,6 +36,12 @@ import {
|
||||
type ReasoningEffort,
|
||||
validatePatternDefinition,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
buildWorkflowExecutionPattern,
|
||||
normalizeWorkflowDefinition,
|
||||
validateWorkflowDefinition,
|
||||
type WorkflowDefinition,
|
||||
} from '@shared/domain/workflow';
|
||||
import {
|
||||
normalizeWorkspaceAgentDefinition,
|
||||
resolvePatternAgents,
|
||||
@@ -623,6 +629,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
|
||||
const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error');
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0].message);
|
||||
}
|
||||
|
||||
const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id);
|
||||
const candidate: WorkflowDefinition = {
|
||||
...normalizedWorkflow,
|
||||
isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite,
|
||||
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.workflows[existingIndex] = candidate;
|
||||
} else {
|
||||
workspace.workflows.push(candidate);
|
||||
}
|
||||
|
||||
workspace.selectedWorkflowId = candidate.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const pattern = this.requirePattern(workspace, patternId);
|
||||
@@ -726,6 +758,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId);
|
||||
|
||||
if (workspace.selectedWorkflowId === workflowId) {
|
||||
workspace.selectedWorkflowId = workspace.workflows[0]?.id;
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||
@@ -881,6 +924,41 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspace.sessions.unshift(session);
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedWorkflowId = undefined;
|
||||
workspace.selectedSessionId = session.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async createWorkflowSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||
const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog);
|
||||
|
||||
const session: SessionRecord = {
|
||||
id: createId('session'),
|
||||
projectId: project.id,
|
||||
patternId: workflow.id,
|
||||
workflowId: workflow.id,
|
||||
title: workflow.name,
|
||||
titleSource: 'auto',
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
sessionModelConfig: executionPattern.agents.length === 1
|
||||
? createSessionModelConfig(executionPattern)
|
||||
: undefined,
|
||||
tooling: createSessionToolingSelection(),
|
||||
runs: [],
|
||||
};
|
||||
|
||||
await this.ensureScratchpadSessionDirectory(session);
|
||||
workspace.sessions.unshift(session);
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = undefined;
|
||||
workspace.selectedWorkflowId = workflow.id;
|
||||
workspace.selectedSessionId = session.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
@@ -993,7 +1071,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||
project,
|
||||
@@ -1022,11 +1100,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
throw new Error('Regenerated session is missing the user message needed to replay the turn.');
|
||||
}
|
||||
|
||||
await this.runPreparedSessionTurn(workspace, regeneratedSession, project, effectivePattern, projectInstructions, {
|
||||
occurredAt,
|
||||
requestId: createId('turn'),
|
||||
triggerMessageId: triggerMessage.id,
|
||||
});
|
||||
await this.runPreparedSessionTurn(
|
||||
workspace,
|
||||
regeneratedSession,
|
||||
project,
|
||||
effectivePattern,
|
||||
projectInstructions,
|
||||
{
|
||||
occurredAt,
|
||||
requestId: createId('turn'),
|
||||
triggerMessageId: triggerMessage.id,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
}
|
||||
|
||||
async editAndResendSessionMessage(
|
||||
@@ -1052,7 +1138,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||
project,
|
||||
@@ -1084,11 +1170,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
throw new Error('Edited session is missing the user message needed to replay the turn.');
|
||||
}
|
||||
|
||||
await this.runPreparedSessionTurn(workspace, editedSession, project, effectivePattern, projectInstructions, {
|
||||
occurredAt,
|
||||
requestId: createId('turn'),
|
||||
triggerMessageId: triggerMessage.id,
|
||||
});
|
||||
await this.runPreparedSessionTurn(
|
||||
workspace,
|
||||
editedSession,
|
||||
project,
|
||||
effectivePattern,
|
||||
projectInstructions,
|
||||
{
|
||||
occurredAt,
|
||||
requestId: createId('turn'),
|
||||
triggerMessageId: triggerMessage.id,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
}
|
||||
|
||||
async sendSessionMessage(
|
||||
@@ -1106,7 +1200,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
||||
}
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||
project,
|
||||
@@ -1135,13 +1229,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
promptInvocation: normalizedPromptInvocation,
|
||||
});
|
||||
await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, {
|
||||
occurredAt,
|
||||
requestId,
|
||||
triggerMessageId: userMessageId,
|
||||
messageMode,
|
||||
attachments,
|
||||
});
|
||||
await this.runPreparedSessionTurn(
|
||||
workspace,
|
||||
session,
|
||||
project,
|
||||
effectivePattern,
|
||||
projectInstructions,
|
||||
{
|
||||
occurredAt,
|
||||
requestId,
|
||||
triggerMessageId: userMessageId,
|
||||
messageMode,
|
||||
attachments,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSessionTurn(sessionId: string): Promise<void> {
|
||||
@@ -1328,7 +1430,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
|
||||
|
||||
if (effectivePattern.agents.length !== 1) {
|
||||
@@ -1497,6 +1599,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
messageMode?: MessageMode;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
},
|
||||
effectiveWorkflow?: WorkflowDefinition,
|
||||
): Promise<void> {
|
||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||
@@ -1529,6 +1632,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workingDirectory: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
pattern: patternForTurn,
|
||||
workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined,
|
||||
triggerMessageId,
|
||||
startedAt: occurredAt,
|
||||
preRunGitSnapshot,
|
||||
@@ -1558,6 +1662,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: patternForTurn,
|
||||
workflow: effectiveWorkflow,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
promptInvocation,
|
||||
@@ -2126,6 +2231,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
|
||||
const workflow = workspace.workflows.find((current) => current.id === workflowId);
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow "${workflowId}" was not found.`);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private resolveSessionExecutionDefinition(
|
||||
workspace: WorkspaceState,
|
||||
session: SessionRecord,
|
||||
): { pattern: PatternDefinition; workflow?: WorkflowDefinition } {
|
||||
if (session.workflowId) {
|
||||
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
||||
return {
|
||||
workflow,
|
||||
pattern: buildWorkflowExecutionPattern(workflow),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pattern: this.requirePattern(workspace, session.patternId),
|
||||
};
|
||||
}
|
||||
|
||||
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||
if (!session) {
|
||||
@@ -2335,7 +2466,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
messages: ChatMessageRecord[],
|
||||
): void {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||
const incomingIds = new Set(messages.map((message) => message.id));
|
||||
|
||||
// Messages that were streamed during the turn already exist in session.messages
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BranchSessionInput,
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
CreateWorkflowSessionInput,
|
||||
CreateProjectGitBranchInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SaveWorkflowInput,
|
||||
SaveWorkspaceAgentInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
@@ -112,6 +114,8 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
||||
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
||||
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||
const result = await service.setTheme(theme);
|
||||
applyTitleBarTheme(window, theme);
|
||||
@@ -180,6 +184,9 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||
service.createSession(input.projectId, input.patternId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
|
||||
service.createWorkflowSession(input.projectId, input.workflowId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||
service.duplicateSession(input.sessionId),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
|
||||
import {
|
||||
applyDefaultToolApprovalPolicy,
|
||||
normalizePendingApprovalState,
|
||||
@@ -120,6 +121,7 @@ export class WorkspaceRepository {
|
||||
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
|
||||
graph: resolvePatternGraph(pattern),
|
||||
})),
|
||||
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
|
||||
projects,
|
||||
sessions,
|
||||
settings,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
McpOauthRequiredEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
ValidateWorkflowCommand,
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
@@ -46,6 +47,12 @@ type PendingCommand =
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'validate-workflow';
|
||||
resolve: (issues: ValidateWorkflowCommand['workflow'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'resolve-approval';
|
||||
@@ -127,6 +134,14 @@ export class SidecarClient {
|
||||
});
|
||||
}
|
||||
|
||||
async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-workflow',
|
||||
requestId: `validate-workflow-${Date.now()}`,
|
||||
workflow,
|
||||
});
|
||||
}
|
||||
|
||||
async runTurn(
|
||||
command: RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
@@ -317,6 +332,13 @@ export class SidecarClient {
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'validate-workflow') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'validate-workflow',
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'resolve-approval') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -413,6 +435,12 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'workflow-validation':
|
||||
if (pending.kind === 'validate-workflow') {
|
||||
pending.resolve(event.issues);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'turn-delta':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event));
|
||||
|
||||
Reference in New Issue
Block a user