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:
David Kaya
2026-04-05 17:29:03 +02:00
co-authored by Copilot
parent 4c3198a550
commit 19a764d297
25 changed files with 1720 additions and 32 deletions
+153 -22
View File
@@ -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
+7
View File
@@ -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,
+28
View File
@@ -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));