refactor: remove legacy patterns system, unify on workflows

Remove the entire patterns domain model, IPC channels, sidecar services,
renderer components, and tests. Sessions now bind exclusively to workflows
via workflowId. Builtin workflows replace builtin patterns.

Backend:
- Make AgentNodeConfig standalone (no longer extends PatternAgentDefinition)
- Add WorkflowOrchestrationMode and WorkflowExecutionDefinition
- Create builtin workflows (single-agent, sequential, concurrent, handoff, group-chat)
- Rewrite session model config helpers for workflow-only
- Remove pattern IPC channels, handlers, and preload bindings
- Merge createSession/createWorkflowSession into single method
- Remove sidecar PatternGraphResolver, PatternValidator, pattern DTOs
- Add workspace migration for legacy sessions (patternId -> workflowId)

Frontend:
- Delete PatternEditor, pattern-graph components, patternGraph lib
- Delete NewSessionModal (session creation uses workflows directly)
- Remove PatternsSection from SettingsPanel
- Update App.tsx, ChatPane, ActivityPanel, Sidebar, RunTimeline,
  AgentConfigFields, InlinePills, sessionActivity to use workflow types
- Delete pattern.ts domain module

78 files changed across backend and frontend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-06 22:37:00 +02:00
co-authored by Copilot
parent 69d0804161
commit 805e369b67
78 changed files with 2303 additions and 4441 deletions
+157 -267
View File
@@ -26,20 +26,16 @@ import {
buildAvailableModelCatalog,
findModel,
findModelByReference,
normalizePatternModels,
normalizeWorkflowModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import {
buildWorkflowExecutionDefinition,
isReasoningEffort,
syncPatternGraph,
type PatternDefinition,
type ReasoningEffort,
validatePatternDefinition,
} from '@shared/domain/pattern';
import {
buildWorkflowExecutionPattern,
normalizeWorkflowDefinition,
resolveWorkflowAgentNodes,
validateWorkflowDefinition,
type ReasoningEffort,
type WorkflowDefinition,
type WorkflowReference,
} from '@shared/domain/workflow';
@@ -51,7 +47,6 @@ import {
} from '@shared/domain/workflowSerialization';
import {
applyWorkflowTemplate,
buildWorkflowFromPattern,
createWorkflowTemplateFromWorkflow,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateCategory,
@@ -59,7 +54,7 @@ import {
} from '@shared/domain/workflowTemplate';
import {
normalizeWorkspaceAgentDefinition,
resolvePatternAgents,
resolveWorkflowAgents as resolveWorkspaceWorkflowAgents,
type WorkspaceAgentDefinition,
} from '@shared/domain/workspaceAgent';
import {
@@ -82,6 +77,7 @@ import {
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import {
applyDefaultToolApprovalPolicy,
approvalPolicyRequiresCheckpoint,
dequeuePendingApprovalState,
enqueuePendingApprovalState,
@@ -138,6 +134,7 @@ import {
upsertRunApprovalEvent,
upsertRunMessageEvent,
upsertSessionRunRecord,
type CreateSessionRunRecordInput,
type RunTimelineEventRecord,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
@@ -217,10 +214,6 @@ type WorkflowCheckpointRecoveryState = {
type DiscoveredToolingResolution = 'accept' | 'dismiss';
function isBuiltinPattern(patternId: string): boolean {
return patternId.startsWith('pattern-');
}
function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean {
const normalizedLeft = left ?? [];
const normalizedRight = right ?? [];
@@ -613,37 +606,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return result;
}
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
const synchronizedPattern = pattern.graph ? pattern : syncPatternGraph(pattern);
const issues = validatePatternDefinition(
synchronizedPattern,
knownApprovalToolNames,
).filter((issue) => issue.level === 'error');
if (issues.length > 0) {
throw new Error(issues[0].message);
}
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
const candidate: PatternDefinition = {
...synchronizedPattern,
approvalPolicy: normalizeApprovalPolicy(synchronizedPattern.approvalPolicy),
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
updatedAt: nowIso(),
};
if (existingIndex >= 0) {
workspace.patterns[existingIndex] = candidate;
} else {
workspace.patterns.push(candidate);
}
workspace.selectedPatternId = candidate.id;
return this.persistAndBroadcast(workspace);
}
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
@@ -747,48 +709,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
};
}
async upgradePatternToWorkflow(
patternId: string,
options?: {
workflowId?: string;
name?: string;
description?: string;
save?: boolean;
},
): Promise<{ workflow: WorkflowDefinition; workspace?: WorkspaceState }> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
const baseWorkflow = buildWorkflowFromPattern(pattern);
const workflowId = options?.workflowId?.trim()
|| this.createUniqueWorkflowId(workspace, baseWorkflow.id);
const workflow = normalizeWorkflowDefinition({
...baseWorkflow,
id: workflowId,
name: options?.name?.trim() || baseWorkflow.name,
description: options?.description?.trim() ?? baseWorkflow.description,
createdAt: nowIso(),
updatedAt: nowIso(),
});
if (!options?.save) {
return { workflow };
}
const savedWorkspace = await this.saveWorkflow(workflow);
return {
workflow,
workspace: savedWorkspace,
};
}
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
pattern.isFavorite = isFavorite;
pattern.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async setTheme(theme: AppearanceTheme): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.theme = normalizeTheme(theme);
@@ -867,23 +787,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.ptyManager.resize(cols, rows);
}
async deletePattern(patternId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.patterns = workspace.patterns.filter((pattern) => pattern.id !== patternId);
if (isBuiltinPattern(patternId)) {
const deletedIds = new Set(workspace.deletedBuiltinPatternIds ?? []);
deletedIds.add(patternId);
workspace.deletedBuiltinPatternIds = [...deletedIds];
}
if (workspace.selectedPatternId === patternId) {
workspace.selectedPatternId = workspace.patterns[0]?.id;
}
return this.persistAndBroadcast(workspace);
}
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const workflow = this.requireWorkflow(workspace, workflowId);
@@ -1038,50 +941,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
const pattern = this.requirePattern(workspace, patternId);
const modelCatalog = await this.loadAvailableModelCatalog();
const normalizedPattern = normalizePatternModels(pattern, modelCatalog);
const session: SessionRecord = {
id: createId('session'),
projectId: project.id,
patternId: pattern.id,
title: pattern.name,
titleSource: 'auto',
createdAt: nowIso(),
updatedAt: nowIso(),
status: 'idle',
messages: [],
sessionModelConfig: normalizedPattern.agents.length === 1
? createSessionModelConfig(normalizedPattern)
: undefined,
tooling: createSessionToolingSelection(),
runs: [],
};
await this.ensureScratchpadSessionDirectory(session);
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> {
async createSession(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(this.buildResolvedWorkflowExecutionPattern(workspace, workflow), modelCatalog);
const executionWorkflow = normalizeWorkflowModels(
this.buildResolvedExecutionWorkflow(workspace, workflow),
modelCatalog,
);
const session: SessionRecord = {
id: createId('session'),
projectId: project.id,
patternId: workflow.id,
workflowId: workflow.id,
title: workflow.name,
titleSource: 'auto',
@@ -1089,9 +961,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
updatedAt: nowIso(),
status: 'idle',
messages: [],
sessionModelConfig: executionPattern.agents.length === 1
? createSessionModelConfig(executionPattern)
: undefined,
sessionModelConfig: createSessionModelConfig(
executionWorkflow,
this.createWorkflowResolutionOptions(workspace),
),
tooling: createSessionToolingSelection(),
runs: [],
};
@@ -1099,12 +972,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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);
}
async createWorkflowSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
return this.createSession(projectId, workflowId);
}
async duplicateSession(sessionId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -1116,7 +992,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.ensureScratchpadSessionDirectory(duplicate);
workspace.sessions.unshift(duplicate);
workspace.selectedProjectId = duplicate.projectId;
workspace.selectedPatternId = duplicate.patternId;
workspace.selectedWorkflowId = duplicate.workflowId;
workspace.selectedSessionId = duplicate.id;
return this.persistAndBroadcast(workspace);
}
@@ -1124,8 +1000,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async branchSession(sessionId: string, messageId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const pattern = this.requirePattern(workspace, session.patternId);
const branch = branchSessionRecord(session, pattern, createId('session'), messageId, nowIso());
const workflow = this.requireWorkflow(workspace, session.workflowId);
const branch = branchSessionRecord(session, workflow, createId('session'), messageId, nowIso());
if (isScratchpadProject(branch.projectId)) {
branch.cwd = undefined;
}
@@ -1133,7 +1009,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.ensureScratchpadSessionDirectory(branch);
workspace.sessions.unshift(branch);
workspace.selectedProjectId = branch.projectId;
workspace.selectedPatternId = branch.patternId;
workspace.selectedWorkflowId = branch.workflowId;
workspace.selectedSessionId = branch.id;
return this.persistAndBroadcast(workspace);
}
@@ -1213,16 +1089,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
const project = this.requireProject(workspace, session.projectId);
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
const workflow = this.resolveSessionWorkflow(workspace, session);
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
const occurredAt = nowIso();
const regeneratedSession = regenerateSessionRecord(
session,
effectivePattern,
effectiveWorkflow,
createId('session'),
messageId,
occurredAt,
@@ -1234,7 +1110,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.ensureScratchpadSessionDirectory(regeneratedSession);
workspace.sessions.unshift(regeneratedSession);
workspace.selectedProjectId = regeneratedSession.projectId;
workspace.selectedPatternId = regeneratedSession.patternId;
workspace.selectedWorkflowId = regeneratedSession.workflowId;
workspace.selectedSessionId = regeneratedSession.id;
const triggerMessage = regeneratedSession.messages.at(-1);
@@ -1246,14 +1122,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace,
regeneratedSession,
project,
effectivePattern,
effectiveWorkflow,
projectInstructions,
{
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
},
workflow,
);
}
@@ -1280,9 +1155,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
const project = this.requireProject(workspace, session.projectId);
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
const workflow = this.resolveSessionWorkflow(workspace, session);
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
@@ -1290,7 +1165,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const nextAttachments = attachments === undefined ? sourceMessage.attachments : attachments;
const editedSession = editAndResendSessionRecord(
session,
effectivePattern,
effectiveWorkflow,
createId('session'),
messageId,
preparedContent,
@@ -1304,7 +1179,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.ensureScratchpadSessionDirectory(editedSession);
workspace.sessions.unshift(editedSession);
workspace.selectedProjectId = editedSession.projectId;
workspace.selectedPatternId = editedSession.patternId;
workspace.selectedWorkflowId = editedSession.workflowId;
workspace.selectedSessionId = editedSession.id;
const triggerMessage = editedSession.messages.at(-1);
@@ -1316,14 +1191,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace,
editedSession,
project,
effectivePattern,
effectiveWorkflow,
projectInstructions,
{
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
},
workflow,
);
}
@@ -1342,9 +1216,9 @@ 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, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
const workflow = this.resolveSessionWorkflow(workspace, session);
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
@@ -1375,7 +1249,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace,
session,
project,
effectivePattern,
effectiveWorkflow,
projectInstructions,
{
occurredAt,
@@ -1384,7 +1258,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
messageMode,
attachments,
},
workflow,
);
}
@@ -1572,10 +1445,13 @@ 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.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
const workflow = normalizeWorkflowModels(
this.buildResolvedExecutionWorkflow(workspace, this.requireWorkflow(workspace, session.workflowId)),
modelCatalog,
);
const agentNodes = resolveWorkflowAgentNodes(workflow);
if (effectivePattern.agents.length !== 1) {
if (agentNodes.length !== 1) {
throw new Error('Model override is only supported for single-agent sessions.');
}
@@ -1732,7 +1608,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace: WorkspaceState,
session: SessionRecord,
project: ProjectRecord,
effectivePattern: PatternDefinition,
effectiveWorkflow: WorkflowDefinition,
projectInstructions: string | undefined,
options: {
occurredAt: string;
@@ -1741,12 +1617,11 @@ 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;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const patternForTurn = await this.applyPromptInvocationToPattern(effectivePattern, promptInvocation);
const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
@@ -1761,7 +1636,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
}
session.title = resolveSessionTitle(session, patternForTurn, session.messages);
session.title = resolveSessionTitle(session, workflowForTurn, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
@@ -1773,8 +1648,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
pattern: patternForTurn,
workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined,
workflow: workflowForTurn,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
@@ -1803,9 +1677,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
mode: interactionMode,
messageMode,
projectInstructions,
pattern: patternForTurn,
workflow: effectiveWorkflow,
workflowLibrary: effectiveWorkflow ? workspace.workflows : undefined,
workflow: workflowForTurn,
workflowLibrary: workspace.workflows,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
@@ -1846,7 +1719,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
},
);
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
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());
@@ -2194,13 +2067,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async selectPattern(patternId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.selectedPatternId = patternId;
workspace.selectedSessionId = workspace.selectedSessionId;
return this.persistAndBroadcast(workspace);
}
async selectSession(sessionId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
if (sessionId) {
@@ -2365,15 +2231,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
}
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
const pattern = workspace.patterns.find((current) => current.id === patternId);
if (!pattern) {
throw new Error(`Pattern "${patternId}" was not found.`);
}
return pattern;
}
private requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
if (!template) {
@@ -2417,32 +2274,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return normalized || createId(fallbackPrefix);
}
private resolveSessionExecutionDefinition(
private resolveSessionWorkflow(
workspace: WorkspaceState,
session: SessionRecord,
): { pattern: PatternDefinition; workflow?: WorkflowDefinition } {
if (session.workflowId) {
const workflow = this.requireWorkflow(workspace, session.workflowId);
return {
workflow,
pattern: this.buildResolvedWorkflowExecutionPattern(workspace, workflow),
};
}
return {
pattern: this.requirePattern(workspace, session.patternId),
};
): WorkflowDefinition {
return this.requireWorkflow(workspace, session.workflowId);
}
private buildResolvedWorkflowExecutionPattern(
private buildResolvedExecutionWorkflow(
workspace: WorkspaceState,
workflow: WorkflowDefinition,
): PatternDefinition {
return buildWorkflowExecutionPattern(workflow, {
resolveWorkflow: (workflowId) => workspace.workflows.find((candidate) => candidate.id === workflowId),
): WorkflowDefinition {
return normalizeWorkflowDefinition({
...workflow,
settings: {
...workflow.settings,
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
},
});
}
private createWorkflowResolutionOptions(workspace: WorkspaceState) {
return {
resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId),
};
}
private validateWorkflowReferences(
workspace: WorkspaceState,
workflow: WorkflowDefinition,
@@ -2712,15 +2569,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private emitCompletedActivity(
sessionId: string,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
message: ChatMessageRecord,
): void {
if (message.role !== 'assistant') {
return;
}
const agent = pattern.agents.find((candidate) =>
candidate.id === message.authorName || candidate.name === message.authorName);
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;
}
@@ -2742,7 +2603,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
messages: ChatMessageRecord[],
): void {
const session = this.requireSession(workspace, sessionId);
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
const workflow = this.resolveSessionWorkflow(workspace, session);
const incomingIds = new Set(messages.map((message) => message.id));
// Messages that were streamed during the turn already exist in session.messages
@@ -2807,7 +2668,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitCompletedActivity(sessionId, pattern, message);
this.emitCompletedActivity(sessionId, workflow, message);
}
for (const message of session.messages) {
@@ -3275,10 +3136,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace: WorkspaceState,
sessionId: string,
requestId: string,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): Promise<void> {
const pendingApproval = this.buildFinalResponseApproval(pattern, messages);
const pendingApproval = this.buildFinalResponseApproval(workflow, messages);
if (!pendingApproval) {
return;
}
@@ -3305,7 +3166,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
private buildFinalResponseApproval(
pattern: PatternDefinition,
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): PendingApprovalRecord | undefined {
const assistantMessages = messages.filter((message) => message.role === 'assistant');
@@ -3325,9 +3186,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
continue;
}
const agent = pattern.agents.find((candidate) =>
candidate.id === message.authorName || candidate.name === message.authorName);
if (!approvalPolicyRequiresCheckpoint(pattern.approvalPolicy, 'final-response', agent?.id)) {
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;
}
@@ -3363,28 +3228,28 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
}
private async buildEffectivePattern(
pattern: PatternDefinition,
private async buildEffectiveWorkflow(
workflow: WorkflowDefinition,
session: SessionRecord,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): Promise<PatternDefinition> {
const resolvedPattern = resolvePatternAgents(pattern, workspaceAgents);
const patternWithSessionConfig = session.sessionModelConfig
? applySessionModelConfig(resolvedPattern, session)
: resolvedPattern;
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
): 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 normalizePatternModels(patternWithApprovalSettings, modelCatalog);
return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog);
}
private async applyPromptInvocationToPattern(
pattern: PatternDefinition,
private async applyPromptInvocationToWorkflow(
workflow: WorkflowDefinition,
promptInvocation?: ProjectPromptInvocation,
): Promise<PatternDefinition> {
): Promise<WorkflowDefinition> {
const requestedModel = promptInvocation?.model?.trim();
if (!requestedModel) {
return pattern;
return workflow;
}
const modelCatalog = await this.loadAvailableModelCatalog();
@@ -3392,7 +3257,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const effectiveModelId = resolvedModel?.id ?? requestedModel;
let didChange = false;
const agents = pattern.agents.map((agent) => {
const nodes = workflow.graph.nodes.map((node) => {
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
const agent = node.config;
// When overriding the model, re-normalize reasoning effort for the target model.
// If the target model's reasoning capabilities are unknown (supportedReasoningEfforts
// is undefined — common for dynamically-discovered models), strip reasoning effort
@@ -3405,39 +3275,50 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
return agent;
return node;
}
didChange = true;
return {
...agent,
model: effectiveModelId,
reasoningEffort,
...node,
config: {
...agent,
model: effectiveModelId,
reasoningEffort,
},
};
});
return didChange ? { ...pattern, agents } : pattern;
return didChange
? {
...workflow,
graph: {
...workflow.graph,
nodes,
},
}
: workflow;
}
private applyProjectCustomizationToPattern(
pattern: PatternDefinition,
private applyProjectCustomizationToWorkflow(
workflow: WorkflowDefinition,
project: ProjectRecord,
): PatternDefinition {
): WorkflowDefinition {
if (isScratchpadProject(project)) {
return pattern;
return workflow;
}
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
if (projectCustomAgents.length === 0) {
return pattern;
return workflow;
}
const [primaryAgent, ...remainingAgents] = pattern.agents;
if (!primaryAgent) {
return pattern;
const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0];
if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') {
return workflow;
}
const existingCustomAgents = primaryAgent.copilot?.customAgents ?? [];
const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? [];
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
const mergedCustomAgents = [
...existingCustomAgents,
@@ -3445,17 +3326,26 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
];
return {
...pattern,
agents: [
{
...primaryAgent,
copilot: {
...primaryAgent.copilot,
customAgents: mergedCustomAgents,
},
},
...remainingAgents,
],
...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,
},
},
};
}),
},
};
}
@@ -3511,13 +3401,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
);
let changed = false;
for (const pattern of workspace.patterns) {
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, workspaceKnownToolNames);
for (const workflow of workspace.workflows) {
const nextPolicy = pruneApprovalPolicyTools(workflow.settings.approvalPolicy, workspaceKnownToolNames);
if (!equalStringArrays(
pattern.approvalPolicy?.autoApprovedToolNames,
workflow.settings.approvalPolicy?.autoApprovedToolNames,
nextPolicy?.autoApprovedToolNames,
)) {
pattern.approvalPolicy = nextPolicy;
workflow.settings.approvalPolicy = nextPolicy;
changed = true;
}
}
+1 -13
View File
@@ -37,12 +37,10 @@ import type {
ResolveSessionUserInputInput,
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkflowInput,
SaveWorkflowTemplateInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetProjectAgentProfileEnabledInput,
SetSessionArchivedInput,
SetSessionInteractionModeInput,
@@ -53,7 +51,6 @@ import type {
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
UpgradePatternToWorkflowInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
@@ -114,11 +111,6 @@ export function registerIpcHandlers(
(_event, input: SetProjectAgentProfileEnabledInput) =>
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
);
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
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.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
service.saveWorkflowTemplate(input.workflowId, input.options),
@@ -136,9 +128,6 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
service.importWorkflow(input.content, input.format, input.options),
);
ipcMain.handle(ipcChannels.upgradePatternToWorkflow, (_event, input: UpgradePatternToWorkflowInput) =>
service.upgradePatternToWorkflow(input.patternId, input.options),
);
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
const result = await service.setTheme(theme);
applyTitleBarTheme(window, theme);
@@ -205,7 +194,7 @@ export function registerIpcHandlers(
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
);
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
service.createSession(input.projectId, input.patternId),
service.createSession(input.projectId, input.workflowId),
);
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
service.createWorkflowSession(input.projectId, input.workflowId),
@@ -321,7 +310,6 @@ export function registerIpcHandlers(
);
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
+86 -54
View File
@@ -1,8 +1,5 @@
import { mkdir } from 'node:fs/promises';
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
@@ -20,13 +17,18 @@ import {
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
import {
createBuiltinWorkflows,
normalizeWorkflowDefinition,
type WorkflowDefinition,
} from '@shared/domain/workflow';
import {
applyDefaultToolApprovalPolicy,
normalizePendingApprovalState,
normalizeSessionApprovalSettings,
} from '@shared/domain/approval';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
import {
@@ -36,31 +38,31 @@ import {
} from '@main/persistence/appPaths';
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
function mergePatterns(existingPatterns: PatternDefinition[], deletedBuiltinIds: string[]): PatternDefinition[] {
const builtinTimestamp = nowIso();
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
const deletedSet = new Set(deletedBuiltinIds);
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
function mergeBuiltinWorkflows(existingWorkflows: WorkflowDefinition[]): WorkflowDefinition[] {
const builtinWorkflows = createBuiltinWorkflows(nowIso());
const builtinIds = new Set(builtinWorkflows.map((workflow) => workflow.id));
const existingMap = new Map(existingWorkflows.map((workflow) => [workflow.id, workflow]));
const mergedBuiltins = builtinPatterns
.filter((builtin) => !deletedSet.has(builtin.id))
.map((builtin) => {
const mergedBuiltins = builtinWorkflows.map((builtin) => {
const existing = existingMap.get(builtin.id);
if (!existing) {
return builtin;
}
return {
return normalizeWorkflowDefinition({
...existing,
availability: builtin.availability,
unavailabilityReason: builtin.unavailabilityReason,
mode: builtin.mode,
};
settings: {
...existing.settings,
orchestrationMode: builtin.settings.orchestrationMode,
},
});
});
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
return [...mergedBuiltins, ...customPatterns];
const customWorkflows = existingWorkflows
.filter((workflow) => !builtinIds.has(workflow.id))
.map(normalizeWorkflowDefinition);
return [...mergedBuiltins, ...customWorkflows];
}
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
@@ -73,6 +75,28 @@ function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[])
return [...builtinTemplates, ...customTemplates];
}
function migrateLegacySessions(
sessions: SessionRecord[],
workflows: WorkflowDefinition[],
): SessionRecord[] {
const workflowIds = new Set(workflows.map((workflow) => workflow.id));
const fallbackWorkflowId = workflows[0]?.id;
return sessions.flatMap((session) => {
const workflowId = session.workflowId && workflowIds.has(session.workflowId)
? session.workflowId
: fallbackWorkflowId;
if (!workflowId) {
return [];
}
return [{
...session,
workflowId,
}];
});
}
export class WorkspaceRepository {
readonly filePath = getWorkspaceFilePath();
readonly scratchpadPath = getScratchpadDirectoryPath();
@@ -80,7 +104,7 @@ export class WorkspaceRepository {
async load(): Promise<WorkspaceState> {
await mkdir(this.scratchpadPath, { recursive: true });
const stored = await readJsonFile<WorkspaceState>(this.filePath);
const stored = await readJsonFile<WorkspaceState & { patterns?: unknown[] }>(this.filePath);
if (!stored) {
const seededBase = createWorkspaceSeed();
const projects = mergeScratchpadProject([], this.scratchpadPath);
@@ -101,50 +125,58 @@ export class WorkspaceRepository {
})),
this.scratchpadPath,
);
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
};
if (!isScratchpadProject(normalizedSession.projectId)) {
return normalizedSession;
}
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
await mkdir(cwd, { recursive: true });
return {
...normalizedSession,
cwd,
};
}));
const workflows = mergeBuiltinWorkflows((stored.workflows ?? []).map(normalizeWorkflowDefinition))
.map((workflow) => ({
...workflow,
settings: {
...workflow.settings,
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
},
}));
const sessions = migrateLegacySessions(
await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
};
if (!isScratchpadProject(normalizedSession.projectId)) {
return normalizedSession;
}
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
await mkdir(cwd, { recursive: true });
return {
...normalizedSession,
cwd,
};
})),
workflows,
);
const settings = normalizeWorkspaceSettings(stored.settings);
const deletedBuiltinPatternIds = stored.deletedBuiltinPatternIds ?? [];
const workspace: WorkspaceState = {
...stored,
patterns: mergePatterns(stored.patterns ?? [], deletedBuiltinPatternIds).map((pattern) => ({
...pattern,
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
graph: resolvePatternGraph(pattern),
})),
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
workflows,
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
projects,
sessions,
settings,
deletedBuiltinPatternIds,
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
? stored.selectedProjectId
: projects[0]?.id,
selectedWorkflowId: workflows.some((workflow) => workflow.id === stored.selectedWorkflowId)
? stored.selectedWorkflowId
: workflows[0]?.id,
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
};
-28
View File
@@ -13,7 +13,6 @@ import type {
UserInputRequestedEvent,
McpOauthRequiredEvent,
ExitPlanModeRequestedEvent,
ValidatePatternCommand,
ValidateWorkflowCommand,
RunTurnCommand,
CopilotSessionListFilter,
@@ -41,12 +40,6 @@ type PendingCommand =
resolve: (capabilities: SidecarCapabilities) => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'validate-pattern';
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'validate-workflow';
@@ -126,14 +119,6 @@ export class SidecarClient {
return command;
}
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
return this.dispatch<unknown>({
type: 'validate-pattern',
requestId: `validate-${Date.now()}`,
pattern,
});
}
async validateWorkflow(
workflow: ValidateWorkflowCommand['workflow'],
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
@@ -329,13 +314,6 @@ export class SidecarClient {
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
errored: false,
});
} else if (command.type === 'validate-pattern') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'validate-pattern',
resolve: resolve as (issues: unknown) => void,
reject,
});
} else if (command.type === 'validate-workflow') {
this.pending.set(command.requestId, {
processId: state.id,
@@ -433,12 +411,6 @@ export class SidecarClient {
this.pending.delete(event.requestId);
}
return;
case 'pattern-validation':
if (pending.kind === 'validate-pattern') {
pending.resolve(event.issues);
this.pending.delete(event.requestId);
}
return;
case 'workflow-validation':
if (pending.kind === 'validate-workflow') {
pending.resolve(event.issues);
-5
View File
@@ -23,9 +23,6 @@ const api: ElectronApi = {
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
setProjectAgentProfileEnabled: (input) =>
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
@@ -33,7 +30,6 @@ const api: ElectronApi = {
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
upgradePatternToWorkflow: (input) => ipcRenderer.invoke(ipcChannels.upgradePatternToWorkflow, input),
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
@@ -94,7 +90,6 @@ const api: ElectronApi = {
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
+1 -1
View File
@@ -556,7 +556,7 @@ export function InlineApprovalPill({
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header: session override / pattern defaults */}
{/* Header: session override / workflow defaults */}
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
<div className="flex items-center gap-2 px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
@@ -3,7 +3,7 @@ import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentCo
import { findModel, type ModelDefinition } from '@shared/domain/models';
import { resolveReasoningEffort } from '@shared/domain/models';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
import { ToolingEditorShell } from './ToolingEditorShell';
import { Link2, Workflow } from 'lucide-react';
@@ -21,7 +21,7 @@ export function WorkspaceAgentEditor({
onSave,
onDelete,
availableModels,
patterns,
workflows,
}: {
agent: WorkspaceAgentDefinition;
onChange: (agent: WorkspaceAgentDefinition) => void;
@@ -29,10 +29,10 @@ export function WorkspaceAgentEditor({
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
}) {
const validationError = validateWorkspaceAgent(agent);
const usages = findWorkspaceAgentUsages(agent.id, patterns);
const usages = findWorkspaceAgentUsages(agent.id, workflows);
return (
<ToolingEditorShell
@@ -117,22 +117,22 @@ export function WorkspaceAgentEditor({
{usages.map((usage) => (
<div
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
key={usage.patternId}
key={usage.workflowId}
>
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[13px] text-[var(--color-text-secondary)]">
{usage.patternName}
{usage.workflowName}
</span>
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
</div>
))}
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
Referenced by {usages.length} pattern{usages.length === 1 ? '' : 's'}.
Changes to this agent will affect all linked patterns.
</p>
</section>
)}
Referenced by {usages.length} workflow{usages.length === 1 ? '' : 's'}.
Changes to this agent will affect all linked workflows.
</p>
</section>
)}
</ToolingEditorShell>
);
}
-5
View File
@@ -12,9 +12,6 @@ export const ipcChannels = {
rescanProjectCustomization: 'project:rescan-customization',
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
saveWorkflow: 'workflows:save',
saveWorkflowTemplate: 'workflows:save-template',
deleteWorkflow: 'workflows:delete',
@@ -22,7 +19,6 @@ export const ipcChannels = {
createWorkflowFromTemplate: 'workflows:create-from-template',
exportWorkflow: 'workflows:export',
importWorkflow: 'workflows:import',
upgradePatternToWorkflow: 'workflows:upgrade-pattern',
createWorkflowSession: 'workflows:create-session',
setTheme: 'settings:set-theme',
setTerminalHeight: 'settings:set-terminal-height',
@@ -77,7 +73,6 @@ export const ipcChannels = {
switchProjectGitBranch: 'git:switch-branch',
deleteProjectGitBranch: 'git:delete-branch',
selectProject: 'selection:project',
selectPattern: 'selection:pattern',
selectSession: 'selection:session',
openAppDataFolder: 'troubleshooting:open-app-data-folder',
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
+3 -31
View File
@@ -1,9 +1,8 @@
import type { ApprovalDecision } from '@shared/domain/approval';
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { WorkflowExportFormat, WorkflowExportResult } from '@shared/domain/workflowSerialization';
import type { WorkflowTemplateCategory } from '@shared/domain/workflowTemplate';
import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
import type { ReasoningEffort, WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
import type {
ProjectGitBranchSummary,
ProjectGitCommitMessageSuggestion,
@@ -27,18 +26,11 @@ import type { ProjectPromptInvocation } from '@shared/domain/projectCustomizatio
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
export interface CreateSessionInput {
projectId: string;
patternId: string;
}
export interface CreateWorkflowSessionInput {
projectId: string;
workflowId: string;
}
export interface SavePatternInput {
pattern: PatternDefinition;
}
export type CreateWorkflowSessionInput = CreateSessionInput;
export interface SaveWorkflowInput {
workflow: WorkflowDefinition;
@@ -76,16 +68,6 @@ export interface ImportWorkflowInput {
};
}
export interface UpgradePatternToWorkflowInput {
patternId: string;
options?: {
workflowId?: string;
name?: string;
description?: string;
save?: boolean;
};
}
export interface ImportWorkflowResult {
workflow: WorkflowDefinition;
workspace?: WorkspaceState;
@@ -165,11 +147,6 @@ export interface SetSessionArchivedInput {
isArchived: boolean;
}
export interface SetPatternFavoriteInput {
patternId: string;
isFavorite: boolean;
}
export interface SaveMcpServerInput {
server: McpServerDefinition;
}
@@ -331,8 +308,6 @@ export interface ElectronApi {
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
@@ -340,7 +315,6 @@ export interface ElectronApi {
createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise<WorkspaceState>;
exportWorkflow(input: ExportWorkflowInput): Promise<WorkflowExportResult>;
importWorkflow(input: ImportWorkflowInput): Promise<ImportWorkflowResult>;
upgradePatternToWorkflow(input: UpgradePatternToWorkflowInput): Promise<ImportWorkflowResult>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
@@ -371,9 +345,7 @@ export interface ElectronApi {
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>;
selectPattern(patternId?: string): Promise<WorkspaceState>;
selectSession(sessionId?: string): Promise<WorkspaceState>;
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
@@ -413,5 +385,5 @@ export interface ElectronApi {
export interface RendererSelectionState {
selectedProject?: ProjectRecord;
selectedPattern?: PatternDefinition;
selectedWorkflow?: WorkflowDefinition;
}
+8 -19
View File
@@ -1,10 +1,14 @@
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import type { WorkflowDefinition, WorkflowValidationIssue } from '@shared/domain/workflow';
import type {
ReasoningEffort,
WorkflowDefinition,
WorkflowOrchestrationMode,
WorkflowValidationIssue,
} from '@shared/domain/workflow';
export interface SidecarModeCapability {
available: boolean;
@@ -54,7 +58,7 @@ export interface SidecarModelCapability {
export interface SidecarCapabilities {
runtime: 'dotnet-maf';
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
modes: Record<WorkflowOrchestrationMode | 'magentic', SidecarModeCapability>;
models: SidecarModelCapability[];
runtimeTools: RuntimeToolDefinition[];
connection: SidecarConnectionDiagnostics;
@@ -65,12 +69,6 @@ export interface DescribeCapabilitiesCommand {
requestId: string;
}
export interface ValidatePatternCommand {
type: 'validate-pattern';
requestId: string;
pattern: PatternDefinition;
}
export interface ValidateWorkflowCommand {
type: 'validate-workflow';
requestId: string;
@@ -96,8 +94,7 @@ export interface RunTurnCommand {
mode?: InteractionMode;
messageMode?: MessageMode;
projectInstructions?: string;
pattern: PatternDefinition;
workflow?: WorkflowDefinition;
workflow: WorkflowDefinition;
workflowLibrary?: WorkflowDefinition[];
messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
@@ -156,7 +153,6 @@ export interface CopilotSessionListFilter {
export type SidecarCommand =
| DescribeCapabilitiesCommand
| ValidatePatternCommand
| ValidateWorkflowCommand
| RunTurnCommand
| CancelTurnCommand
@@ -235,12 +231,6 @@ export interface CapabilitiesEvent {
capabilities: SidecarCapabilities;
}
export interface PatternValidationEvent {
type: 'pattern-validation';
requestId: string;
issues: PatternValidationIssue[];
}
export interface WorkflowValidationEvent {
type: 'workflow-validation';
requestId: string;
@@ -608,7 +598,6 @@ export interface CommandCompleteEvent {
export type SidecarEvent =
| CapabilitiesEvent
| PatternValidationEvent
| WorkflowValidationEvent
| TurnDeltaEvent
| TurnCompleteEvent
+24 -10
View File
@@ -1,5 +1,5 @@
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ReasoningEffort, WorkflowDefinition } from '@shared/domain/workflow';
export type ModelProvider = 'openai' | 'anthropic' | 'google';
@@ -270,17 +270,31 @@ export function resolveReasoningEffort(
return supported[0];
}
export function normalizePatternModels(
pattern: PatternDefinition,
export function normalizeWorkflowModels(
workflow: WorkflowDefinition,
models: ReadonlyArray<ModelDefinition>,
): PatternDefinition {
): WorkflowDefinition {
return {
...pattern,
agents: pattern.agents.map((agent) => {
const model = findModel(agent.model, models);
const reasoningEffort = resolveReasoningEffort(model, agent.reasoningEffort);
...workflow,
graph: {
...workflow.graph,
nodes: workflow.graph.nodes.map((node) => {
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
return reasoningEffort === agent.reasoningEffort ? agent : { ...agent, reasoningEffort };
}),
const model = findModel(node.config.model, models);
const reasoningEffort = resolveReasoningEffort(model, node.config.reasoningEffort);
return reasoningEffort === node.config.reasoningEffort
? node
: {
...node,
config: {
...node.config,
reasoningEffort,
},
};
}),
},
};
}
+44 -21
View File
@@ -4,7 +4,6 @@ import type {
PendingApprovalRecord,
} from '@shared/domain/approval';
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type {
ProjectGitBaselineFile,
ProjectGitChangeSummary,
@@ -17,7 +16,13 @@ import type {
ProjectGitWorkingTreeSnapshot,
ProjectRecord,
} from '@shared/domain/project';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type {
AgentNodeConfig,
ReasoningEffort,
WorkflowDefinition,
WorkflowOrchestrationMode,
} from '@shared/domain/workflow';
import { resolveWorkflowAgents } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
@@ -74,11 +79,9 @@ export interface SessionRunRecord {
projectPath: string;
workingDirectory?: string;
workspaceKind: SessionRunWorkspaceKind;
patternId: string;
patternName: string;
patternMode: PatternDefinition['mode'];
workflowId?: string;
workflowName?: string;
workflowId: string;
workflowName: string;
workflowMode: WorkflowOrchestrationMode;
triggerMessageId: string;
startedAt: string;
completedAt?: string;
@@ -90,13 +93,18 @@ export interface SessionRunRecord {
postRunGitSummary?: ProjectGitRunChangeSummary;
}
export type SessionRunWorkflowInput =
| WorkflowDefinition
| (Pick<WorkflowDefinition, 'id' | 'name' | 'settings'> & {
agents: Pick<AgentNodeConfig, 'id' | 'name' | 'model' | 'reasoningEffort'>[];
});
export interface CreateSessionRunRecordInput {
requestId: string;
project: Pick<ProjectRecord, 'id' | 'path'>;
workingDirectory?: string;
workspaceKind: SessionRunWorkspaceKind;
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
workflow?: Pick<WorkflowDefinition, 'id' | 'name'>;
workflow: SessionRunWorkflowInput;
triggerMessageId: string;
startedAt: string;
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
@@ -603,7 +611,23 @@ function settleOpenMessageEvents(
};
}
function resolveSessionRunWorkflowAgents(
workflow: SessionRunWorkflowInput,
): ReadonlyArray<Pick<AgentNodeConfig, 'id' | 'name' | 'model' | 'reasoningEffort'>> {
if ('graph' in workflow) {
return resolveWorkflowAgents(workflow).map((agent) => ({
id: agent.id,
name: agent.name,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
}));
}
return workflow.agents;
}
export function createSessionRunRecord(input: CreateSessionRunRecordInput): SessionRunRecord {
const agents = resolveSessionRunWorkflowAgents(input.workflow);
return {
id: createId('run'),
requestId: input.requestId,
@@ -611,11 +635,9 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
projectPath: input.project.path,
workingDirectory: normalizeOptionalString(input.workingDirectory),
workspaceKind: input.workspaceKind,
patternId: input.pattern.id,
patternName: input.pattern.name,
patternMode: input.pattern.mode,
workflowId: normalizeOptionalString(input.workflow?.id),
workflowName: normalizeOptionalString(input.workflow?.name),
workflowId: input.workflow.id,
workflowName: input.workflow.name,
workflowMode: input.workflow.settings.orchestrationMode ?? 'single',
triggerMessageId: input.triggerMessageId,
startedAt: input.startedAt,
status: 'running',
@@ -623,7 +645,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
postRunGitSummary: undefined,
agents: input.pattern.agents
agents: agents
.map((agent): RunTimelineAgentRecord => ({
agentId: agent.id,
agentName: agent.name,
@@ -659,13 +681,11 @@ export function normalizeSessionRunRecords(
const projectId = normalizeOptionalString(run.projectId);
const projectPath = normalizeOptionalString(run.projectPath);
const workingDirectory = normalizeOptionalString(run.workingDirectory);
const patternId = normalizeOptionalString(run.patternId);
const patternName = normalizeOptionalString(run.patternName);
const workflowId = normalizeOptionalString(run.workflowId);
const workflowName = normalizeOptionalString(run.workflowName);
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
const startedAt = normalizeOptionalString(run.startedAt);
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
if (!id || !requestId || !projectId || !projectPath || !workflowId || !workflowName || !triggerMessageId || !startedAt) {
return [];
}
@@ -677,11 +697,14 @@ export function normalizeSessionRunRecords(
projectPath,
workingDirectory,
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
patternId,
patternName,
patternMode: run.patternMode,
workflowId,
workflowName,
workflowMode: run.workflowMode === 'concurrent'
|| run.workflowMode === 'handoff'
|| run.workflowMode === 'group-chat'
|| run.workflowMode === 'single'
? run.workflowMode
: 'sequential',
triggerMessageId,
startedAt,
completedAt: normalizeOptionalString(run.completedAt),
+68 -38
View File
@@ -1,25 +1,32 @@
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
import {
createSessionToolingSelection,
normalizeSessionToolingSelection,
type SessionToolingSelection,
} from '@shared/domain/tooling';
import {
normalizeSessionApprovalSettings,
resolveEffectiveApprovalPolicy,
type PendingApprovalRecord,
type SessionApprovalSettings,
} from '@shared/domain/approval';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { InteractionMode } from '@shared/contracts/sidecar';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import {
normalizeProjectPromptInvocation,
type ProjectPromptInvocation,
} from '@shared/domain/projectCustomization';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import {
createSessionToolingSelection,
normalizeSessionToolingSelection,
type SessionToolingSelection,
} from '@shared/domain/tooling';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
import {
buildWorkflowExecutionDefinition,
resolveWorkflowAgentNodes,
type ReasoningEffort,
type WorkflowDefinition,
type WorkflowResolutionOptions,
} from '@shared/domain/workflow';
import type { InteractionMode } from '@shared/contracts/sidecar';
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
export type ChatRole = 'system' | 'user' | 'assistant';
export type ChatMessageKind = 'response' | 'thinking';
@@ -56,8 +63,7 @@ export interface SessionBranchOrigin {
export interface SessionRecord {
id: string;
projectId: string;
patternId: string;
workflowId?: string;
workflowId: string;
title: string;
titleSource?: SessionTitleSource;
createdAt: string;
@@ -120,20 +126,26 @@ export function normalizeSessionBranchOrigin(
export function resolveSessionTitle(
session: Pick<SessionRecord, 'title' | 'titleSource'>,
pattern: PatternDefinition,
workflow: Pick<WorkflowDefinition, 'name'>,
messages: ChatMessageRecord[],
): string {
if (session.titleSource === 'manual') {
return session.title;
}
return buildSessionTitle(pattern, messages);
const firstUserMessage = messages.find((message) => message.role === 'user');
if (!firstUserMessage) {
return workflow.name;
}
return buildMarkdownExcerpt(firstUserMessage.content, 48) ?? workflow.name;
}
export function createSessionModelConfig(
pattern: PatternDefinition,
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
): SessionModelConfig | undefined {
const primaryAgent = pattern.agents[0];
const primaryAgent = buildWorkflowExecutionDefinition(workflow, options).agents[0];
if (!primaryAgent) {
return undefined;
}
@@ -158,9 +170,9 @@ export function resolveSessionApprovalSettings(
export function resolveSessionModelConfig(
session: SessionRecord,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
): SessionModelConfig | undefined {
const defaults = createSessionModelConfig(pattern);
const defaults = createSessionModelConfig(workflow);
if (!defaults) {
return undefined;
}
@@ -187,38 +199,56 @@ export function normalizeChatMessageRecord(message: ChatMessageRecord): ChatMess
}
export function applySessionModelConfig(
pattern: PatternDefinition,
workflow: WorkflowDefinition,
session: SessionRecord,
): PatternDefinition {
const config = resolveSessionModelConfig(session, pattern);
const primaryAgent = pattern.agents[0];
if (!config || !primaryAgent) {
return pattern;
): WorkflowDefinition {
const config = resolveSessionModelConfig(session, workflow);
if (!config) {
return workflow;
}
return {
...pattern,
agents: [
{
...primaryAgent,
let applied = false;
const nodes = workflow.graph.nodes.map((node, index) => {
if (applied || node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
applied = true;
return {
...node,
config: {
...node.config,
model: config.model,
reasoningEffort: config.reasoningEffort,
},
...pattern.agents.slice(1),
],
};
};
});
return applied
? {
...workflow,
graph: {
...workflow.graph,
nodes,
},
}
: workflow;
}
export function applySessionApprovalSettings(
pattern: PatternDefinition,
workflow: WorkflowDefinition,
session: Pick<SessionRecord, 'approvalSettings'>,
): PatternDefinition {
): WorkflowDefinition {
if (session.approvalSettings === undefined) {
return pattern;
return workflow;
}
const execution = buildWorkflowExecutionDefinition(workflow);
return {
...pattern,
approvalPolicy: resolveEffectiveApprovalPolicy(pattern.approvalPolicy, session.approvalSettings),
...workflow,
settings: {
...workflow.settings,
approvalPolicy: resolveEffectiveApprovalPolicy(execution.approvalPolicy, session.approvalSettings),
},
};
}
+23 -28
View File
@@ -1,4 +1,3 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import {
@@ -14,16 +13,16 @@ import {
type SessionStatus,
} from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { buildWorkflowExecutionPattern } from '@shared/domain/workflow';
import type { WorkflowDefinition } from '@shared/domain/workflow';
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'workflow';
export type SessionWorkspaceKind = 'project' | 'scratchpad';
export interface QuerySessionsInput {
searchText?: string;
statuses?: SessionStatus[];
projectIds?: string[];
patternIds?: string[];
workflowIds?: string[];
workspaceKinds?: SessionWorkspaceKind[];
includeArchived?: boolean;
onlyPinned?: boolean;
@@ -38,17 +37,17 @@ export interface SessionQueryResult {
const fieldWeights: Record<SessionQueryMatchField, number> = {
title: 12,
project: 8,
pattern: 7,
workflow: 7,
message: 4,
};
const orderedMatchFields: SessionQueryMatchField[] = ['title', 'message', 'project', 'pattern'];
const orderedMatchFields: SessionQueryMatchField[] = ['title', 'message', 'project', 'workflow'];
interface SessionSearchFields {
title: string;
message: string;
project: string;
pattern: string;
workflow: string;
}
function normalizeSearchText(value: string): string {
@@ -68,18 +67,21 @@ function tokenizeSearchText(value?: string): string[] {
function buildSearchFields(
session: SessionRecord,
project?: ProjectRecord,
pattern?: PatternDefinition,
workflow?: WorkflowDefinition,
): SessionSearchFields {
return {
title: normalizeSearchText(session.title),
message: normalizeSearchText(session.messages.map((message) => message.content).join('\n')),
project: normalizeSearchText([project?.name, project?.path].filter(Boolean).join('\n')),
pattern: normalizeSearchText(
workflow: normalizeSearchText(
[
pattern?.name,
pattern?.description,
pattern?.mode,
...(pattern?.agents.flatMap((agent) => [agent.name, agent.description]) ?? []),
workflow?.name,
workflow?.description,
workflow?.settings.orchestrationMode,
...(workflow?.graph.nodes.flatMap((node) =>
node.kind === 'agent' && node.config.kind === 'agent'
? [node.config.name, node.config.description]
: []) ?? []),
]
.filter(Boolean)
.join('\n'),
@@ -146,7 +148,7 @@ function matchesFilters(
return false;
}
if (input.patternIds && input.patternIds.length > 0 && !input.patternIds.includes(session.patternId)) {
if (input.workflowIds && input.workflowIds.length > 0 && !input.workflowIds.includes(session.workflowId)) {
return false;
}
@@ -280,7 +282,7 @@ export function duplicateSessionRecord(
export function branchSessionRecord(
session: SessionRecord,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
sessionId: string,
messageId: string,
branchedAt: string,
@@ -299,7 +301,7 @@ export function branchSessionRecord(
return {
...createDerivedSessionRecord(session, sessionId, branchedAt),
title: resolveSessionTitle(session, pattern, branchedMessages),
title: resolveSessionTitle(session, workflow, branchedMessages),
messages: branchedMessages,
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, branchedAt, 'branch'),
};
@@ -338,7 +340,7 @@ export function setSessionMessagePinnedRecord(
export function regenerateSessionRecord(
session: SessionRecord,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
sessionId: string,
messageId: string,
regeneratedAt: string,
@@ -373,7 +375,7 @@ export function regenerateSessionRecord(
return {
...createDerivedSessionRecord(session, sessionId, regeneratedAt),
title: resolveSessionTitle(session, pattern, regeneratedMessages),
title: resolveSessionTitle(session, workflow, regeneratedMessages),
messages: regeneratedMessages,
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, regeneratedAt, 'regenerate'),
};
@@ -381,7 +383,7 @@ export function regenerateSessionRecord(
export function editAndResendSessionRecord(
session: SessionRecord,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
sessionId: string,
messageId: string,
content: string,
@@ -410,7 +412,7 @@ export function editAndResendSessionRecord(
return {
...createDerivedSessionRecord(session, sessionId, editedAt),
title: resolveSessionTitle(session, pattern, editedMessages),
title: resolveSessionTitle(session, workflow, editedMessages),
messages: editedMessages,
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, editedAt, 'edit-and-resend'),
};
@@ -456,7 +458,6 @@ export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
const workflowsById = new Map(workspace.workflows.map((workflow) => [workflow.id, workflow]));
const searchTokens = tokenizeSearchText(input.searchText);
@@ -471,13 +472,7 @@ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInp
buildSearchFields(
session,
projectsById.get(session.projectId),
patternsById.get(session.patternId)
?? (session.workflowId
? (() => {
const workflow = workflowsById.get(session.workflowId);
return workflow ? buildWorkflowExecutionPattern(workflow) : undefined;
})()
: undefined),
workflowsById.get(session.workflowId),
),
searchTokens,
);
+416 -20
View File
@@ -1,10 +1,23 @@
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
import type { ApprovalPolicy } from '@shared/domain/approval';
import {
syncPatternGraph,
type PatternDefinition,
type PatternGraph,
type PatternAgentDefinition,
} from '@shared/domain/pattern';
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
export type WorkflowOrchestrationMode = 'single' | 'sequential' | 'concurrent' | 'handoff' | 'group-chat';
export interface WorkflowAgentOverrides {
name?: string;
description?: string;
instructions?: string;
model?: string;
reasoningEffort?: ReasoningEffort;
}
export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Very High' },
];
export type WorkflowNodeKind =
| 'start'
@@ -40,6 +53,7 @@ export interface WorkflowStateScope {
export interface WorkflowSettings {
checkpointing: WorkflowCheckpointSettings;
executionMode: WorkflowExecutionMode;
orchestrationMode?: WorkflowOrchestrationMode;
maxIterations?: number;
approvalPolicy?: ApprovalPolicy;
stateScopes?: WorkflowStateScope[];
@@ -56,8 +70,17 @@ export interface EndNodeConfig {
outputType?: string;
}
export interface AgentNodeConfig extends PatternAgentDefinition {
export interface AgentNodeConfig {
kind: 'agent';
id: string;
name: string;
description: string;
instructions: string;
model: string;
reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
workspaceAgentId?: string;
overrides?: WorkflowAgentOverrides;
}
export interface InvokeFunctionConfig {
@@ -163,6 +186,16 @@ export interface WorkflowResolutionOptions {
resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined;
}
export interface WorkflowExecutionDefinition {
id: string;
name: string;
description: string;
orchestrationMode: WorkflowOrchestrationMode;
maxIterations: number;
approvalPolicy?: ApprovalPolicy;
agents: AgentNodeConfig[];
}
const executableNodeKinds = new Set<WorkflowNodeKind>([
'start',
'end',
@@ -177,6 +210,25 @@ function normalizeOptionalString(value?: string): string | undefined {
return trimmed ? trimmed : undefined;
}
function normalizeWorkflowOrchestrationMode(
mode?: WorkflowOrchestrationMode,
): WorkflowOrchestrationMode | undefined {
switch (mode) {
case 'single':
case 'sequential':
case 'concurrent':
case 'handoff':
case 'group-chat':
return mode;
default:
return undefined;
}
}
export function isReasoningEffort(value: string | undefined): value is ReasoningEffort {
return reasoningEffortOptions.some((option) => option.value === value);
}
function normalizePosition(position?: Partial<WorkflowPosition>): WorkflowPosition {
return {
x: typeof position?.x === 'number' && Number.isFinite(position.x) ? position.x : 0,
@@ -311,6 +363,7 @@ export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): Workf
enabled: workflow.settings?.checkpointing?.enabled ?? false,
},
executionMode: workflow.settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread',
orchestrationMode: normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode),
maxIterations:
typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0
? Math.round(workflow.settings.maxIterations)
@@ -346,13 +399,14 @@ export function resolveWorkflowAgentNodes(workflow: WorkflowDefinition): Workflo
});
}
export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgentDefinition[] {
export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeConfig[] {
return resolveWorkflowAgentNodes(workflow).flatMap((node) => {
if (node.config.kind !== 'agent') {
return [];
}
return [{
kind: 'agent',
id: node.config.id || node.id,
name: node.config.name,
description: node.config.description,
@@ -410,7 +464,7 @@ function resolveWorkflowExecutionAgents(
options?: WorkflowResolutionOptions,
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
): PatternAgentDefinition[] {
): AgentNodeConfig[] {
const agents = [...resolveWorkflowAgents(workflow)];
for (const node of workflow.graph.nodes) {
@@ -448,10 +502,15 @@ function resolveWorkflowExecutionAgents(
return agents;
}
function inferWorkflowPatternMode(
export function inferWorkflowOrchestrationMode(
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
): PatternDefinition['mode'] {
): WorkflowOrchestrationMode {
const configuredMode = normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode);
if (configuredMode) {
return configuredMode;
}
const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options);
const agentCount = resolveWorkflowExecutionAgents(workflow, options).length;
if (hasFanEdges) {
@@ -461,30 +520,366 @@ function inferWorkflowPatternMode(
return agentCount <= 1 ? 'single' : 'sequential';
}
export function buildWorkflowExecutionPattern(
export function buildWorkflowExecutionDefinition(
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
): PatternDefinition {
): WorkflowExecutionDefinition {
const agents = resolveWorkflowExecutionAgents(workflow, options);
const pattern: PatternDefinition = {
return {
id: workflow.id,
name: workflow.name,
description: workflow.description,
mode: inferWorkflowPatternMode(workflow, options),
availability: 'available',
orchestrationMode: inferWorkflowOrchestrationMode(workflow, options),
maxIterations: workflow.settings.maxIterations ?? 5,
approvalPolicy: workflow.settings.approvalPolicy,
agents,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
};
}
const builtinWorkflowModels = {
claude: 'claude-opus-4.5',
gpt54: 'gpt-5.4',
gpt53: 'gpt-5.3-codex',
} as const;
function createStartNode(x: number, y: number): WorkflowNode {
return { id: 'start', kind: 'start', label: 'Start', position: { x, y }, config: { kind: 'start' } };
}
function createEndNode(x: number, y: number): WorkflowNode {
return { id: 'end', kind: 'end', label: 'End', position: { x, y }, config: { kind: 'end' } };
}
function createAgentNode(
id: string,
label: string,
description: string,
instructions: string,
model: string,
reasoningEffort: ReasoningEffort | undefined,
x: number,
y: number,
order: number,
): WorkflowNode {
return {
...pattern,
graph: syncPatternGraph(pattern).graph as PatternGraph,
id,
kind: 'agent',
label,
position: { x, y },
order,
config: {
kind: 'agent',
id,
name: label,
description,
instructions,
model,
reasoningEffort,
},
};
}
function createWorkflowEdge(
id: string,
source: string,
target: string,
kind: WorkflowEdgeKind = 'direct',
overrides?: Partial<Omit<WorkflowEdge, 'id' | 'source' | 'target' | 'kind'>>,
): WorkflowEdge {
return {
id,
source,
target,
kind,
...overrides,
};
}
function createBuiltinWorkflow(
workflow: Omit<WorkflowDefinition, 'createdAt' | 'updatedAt'>,
timestamp: string,
): WorkflowDefinition {
return normalizeWorkflowDefinition({
...workflow,
createdAt: timestamp,
updatedAt: timestamp,
});
}
export function createBuiltinWorkflows(timestamp: string): WorkflowDefinition[] {
return [
createBuiltinWorkflow({
id: 'workflow-single-chat',
name: '1-on-1 Copilot Chat',
description: 'Direct human-agent conversation for a selected project.',
graph: {
nodes: [
createStartNode(0, 0),
createAgentNode(
'agent-single-primary',
'Primary Agent',
'General-purpose project assistant.',
'You are a helpful coding assistant working inside the selected project.',
builtinWorkflowModels.gpt54,
'high',
220,
0,
0,
),
createEndNode(440, 0),
],
edges: [
createWorkflowEdge('edge-start-to-agent-single-primary', 'start', 'agent-single-primary'),
createWorkflowEdge('edge-agent-single-primary-to-end', 'agent-single-primary', 'end'),
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
orchestrationMode: 'single',
maxIterations: 1,
},
}, timestamp),
createBuiltinWorkflow({
id: 'workflow-sequential-review',
name: 'Sequential Trio Review',
description: 'Agents execute in order, each seeing the full conversation and appending to a shared transcript.',
graph: {
nodes: [
createStartNode(0, 0),
createAgentNode(
'agent-sequential-analyst',
'Analyst',
'Breaks the task down and captures risks.',
'Analyze the request, identify constraints, and produce a short working plan.',
builtinWorkflowModels.gpt54,
'high',
220,
0,
0,
),
createAgentNode(
'agent-sequential-builder',
'Builder',
'Translates the plan into a practical implementation.',
'Use the prior context to propose a concrete implementation.',
builtinWorkflowModels.gpt53,
'medium',
440,
0,
1,
),
createAgentNode(
'agent-sequential-reviewer',
'Reviewer',
'Checks the proposal for gaps and edge cases.',
'Review the previous answer, tighten it, and call out any missing edge cases.',
builtinWorkflowModels.claude,
'medium',
660,
0,
2,
),
createEndNode(880, 0),
],
edges: [
createWorkflowEdge('edge-start-to-agent-sequential-analyst', 'start', 'agent-sequential-analyst'),
createWorkflowEdge('edge-agent-sequential-analyst-to-agent-sequential-builder', 'agent-sequential-analyst', 'agent-sequential-builder'),
createWorkflowEdge('edge-agent-sequential-builder-to-agent-sequential-reviewer', 'agent-sequential-builder', 'agent-sequential-reviewer'),
createWorkflowEdge('edge-agent-sequential-reviewer-to-end', 'agent-sequential-reviewer', 'end'),
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
orchestrationMode: 'sequential',
maxIterations: 1,
},
}, timestamp),
createBuiltinWorkflow({
id: 'workflow-concurrent-brainstorm',
name: 'Concurrent Brainstorm',
description: 'Agents work independently in parallel and the final conversation aggregates every response.',
graph: {
nodes: [
createStartNode(0, 120),
createAgentNode(
'agent-concurrent-architect',
'Architect',
'Focuses on architecture and boundaries.',
'Answer from an architecture-first perspective.',
builtinWorkflowModels.gpt54,
'high',
260,
0,
0,
),
createAgentNode(
'agent-concurrent-product',
'Product',
'Focuses on UX and scope.',
'Answer from a product and UX perspective.',
builtinWorkflowModels.claude,
'medium',
260,
120,
1,
),
createAgentNode(
'agent-concurrent-implementer',
'Implementer',
'Focuses on practical delivery.',
'Answer from an implementation and testing perspective.',
builtinWorkflowModels.gpt53,
'medium',
260,
240,
2,
),
createEndNode(520, 120),
],
edges: [
createWorkflowEdge('edge-start-to-agent-concurrent-architect', 'start', 'agent-concurrent-architect', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
createWorkflowEdge('edge-start-to-agent-concurrent-product', 'start', 'agent-concurrent-product', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
createWorkflowEdge('edge-start-to-agent-concurrent-implementer', 'start', 'agent-concurrent-implementer', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
createWorkflowEdge('edge-agent-concurrent-architect-to-end', 'agent-concurrent-architect', 'end', 'fan-in'),
createWorkflowEdge('edge-agent-concurrent-product-to-end', 'agent-concurrent-product', 'end', 'fan-in'),
createWorkflowEdge('edge-agent-concurrent-implementer-to-end', 'agent-concurrent-implementer', 'end', 'fan-in'),
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
orchestrationMode: 'concurrent',
maxIterations: 1,
},
}, timestamp),
createBuiltinWorkflow({
id: 'workflow-handoff-support',
name: 'Handoff Support Flow',
description: 'A triage agent routes work to specialists, and the next user turn continues when more input is needed.',
graph: {
nodes: [
createStartNode(0, 120),
createAgentNode(
'agent-handoff-triage',
'Triage',
'Routes the request to the right specialist.',
'You triage requests and must hand them off to the most appropriate specialist. For any substantive task, hand off before inspecting files, calling tools, or drafting the implementation yourself. Do not claim that you delegated unless you actually executed the handoff.',
builtinWorkflowModels.gpt54,
'medium',
240,
120,
0,
),
createAgentNode(
'agent-handoff-ux',
'UX Specialist',
'Handles user experience questions.',
'You focus on navigation, UX, and interaction details. Once triage hands work to you, you own the substantive answer.',
builtinWorkflowModels.claude,
'medium',
520,
0,
1,
),
createAgentNode(
'agent-handoff-runtime',
'Runtime Specialist',
'Handles backend and execution details.',
'You focus on runtime, orchestration, and backend integration details. Once triage hands work to you, you own the substantive answer.',
builtinWorkflowModels.gpt53,
'medium',
520,
240,
2,
),
createEndNode(800, 120),
],
edges: [
createWorkflowEdge('edge-start-to-agent-handoff-triage', 'start', 'agent-handoff-triage'),
createWorkflowEdge('edge-agent-handoff-triage-to-end', 'agent-handoff-triage', 'end'),
createWorkflowEdge('edge-agent-handoff-triage-to-agent-handoff-ux', 'agent-handoff-triage', 'agent-handoff-ux'),
createWorkflowEdge('edge-agent-handoff-triage-to-agent-handoff-runtime', 'agent-handoff-triage', 'agent-handoff-runtime'),
createWorkflowEdge('edge-agent-handoff-ux-to-agent-handoff-triage', 'agent-handoff-ux', 'agent-handoff-triage', 'direct', {
isLoop: true,
maxIterations: 4,
condition: { type: 'always' },
}),
createWorkflowEdge('edge-agent-handoff-runtime-to-agent-handoff-triage', 'agent-handoff-runtime', 'agent-handoff-triage', 'direct', {
isLoop: true,
maxIterations: 4,
condition: { type: 'always' },
}),
createWorkflowEdge('edge-agent-handoff-ux-to-end', 'agent-handoff-ux', 'end'),
createWorkflowEdge('edge-agent-handoff-runtime-to-end', 'agent-handoff-runtime', 'end'),
],
},
settings: {
checkpointing: { enabled: true },
executionMode: 'off-thread',
orchestrationMode: 'handoff',
maxIterations: 4,
},
}, timestamp),
createBuiltinWorkflow({
id: 'workflow-group-chat',
name: 'Collaborative Group Chat',
description: 'Agents take turns under a round-robin manager, iteratively refining a shared conversation.',
graph: {
nodes: [
createStartNode(0, 0),
createAgentNode(
'agent-group-writer',
'Writer',
'Produces candidate answers.',
'You draft a concise, useful answer for the task. On later turns, refine your earlier draft based on peer feedback rather than restarting.',
builtinWorkflowModels.gpt54,
'medium',
240,
0,
0,
),
createAgentNode(
'agent-group-reviewer',
'Reviewer',
'Critiques and refines the answer.',
'You review the latest draft and offer specific improvements. Focus on critique and refinement instead of restarting the conversation.',
builtinWorkflowModels.claude,
'medium',
480,
0,
1,
),
createEndNode(720, 0),
],
edges: [
createWorkflowEdge('edge-start-to-agent-group-writer', 'start', 'agent-group-writer'),
createWorkflowEdge('edge-agent-group-writer-to-agent-group-reviewer', 'agent-group-writer', 'agent-group-reviewer', 'direct', {
isLoop: true,
maxIterations: 5,
condition: { type: 'always' },
}),
createWorkflowEdge('edge-agent-group-reviewer-to-agent-group-writer', 'agent-group-reviewer', 'agent-group-writer', 'direct', {
isLoop: true,
maxIterations: 5,
condition: { type: 'always' },
label: 'Loop',
}),
createWorkflowEdge('edge-agent-group-reviewer-to-end', 'agent-group-reviewer', 'end'),
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
orchestrationMode: 'group-chat',
maxIterations: 5,
},
}, timestamp),
];
}
function addIssue(
issues: WorkflowValidationIssue[],
issue: WorkflowValidationIssue,
@@ -1091,3 +1486,4 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
return issues;
}
-291
View File
@@ -1,9 +1,3 @@
import {
resolvePatternGraph,
type PatternAgentDefinition,
type PatternDefinition,
type PatternGraphNode,
} from '@shared/domain/pattern';
import {
normalizeWorkflowDefinition,
type WorkflowDefinition,
@@ -45,47 +39,6 @@ function normalizeRequiredString(value: string | undefined, fallback: string): s
return normalizeOptionalString(value) ?? fallback;
}
function toWorkflowIdFromPatternId(patternId: string): string {
const trimmed = patternId.trim();
return trimmed.startsWith('pattern-')
? `workflow-${trimmed.slice('pattern-'.length)}`
: `${trimmed}-workflow`;
}
function createWorkflowEdge(
source: string,
target: string,
kind: WorkflowEdge['kind'],
overrides?: Partial<WorkflowEdge>,
): WorkflowEdge {
return {
id: overrides?.id?.trim() || `workflow-edge-${source}-to-${target}`,
source,
target,
kind,
label: normalizeOptionalString(overrides?.label),
condition: overrides?.condition,
fanOutConfig: overrides?.fanOutConfig,
isLoop: overrides?.isLoop,
maxIterations: overrides?.maxIterations,
};
}
function sortAgentNodes(nodes: ReadonlyArray<PatternGraphNode>): PatternGraphNode[] {
return nodes
.filter((node) => node.kind === 'agent' && node.agentId)
.slice()
.sort((left, right) => {
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.id.localeCompare(right.id);
});
}
function normalizeTemplateWorkflow(
workflow: WorkflowDefinition | Partial<WorkflowDefinition> | undefined,
): WorkflowDefinition {
@@ -115,115 +68,6 @@ function normalizeTemplateWorkflow(
});
}
function createWorkflowAgentNode(
patternNode: PatternGraphNode,
agent: PatternAgentDefinition,
fallbackOrder: number,
): WorkflowNode {
return {
id: patternNode.id.trim(),
kind: 'agent',
label: normalizeRequiredString(agent.name, `Agent ${fallbackOrder + 1}`),
position: {
x: patternNode.position.x,
y: patternNode.position.y,
},
order: patternNode.order ?? fallbackOrder,
config: {
kind: 'agent',
...agent,
},
};
}
function mapPatternNodeId(node: PatternGraphNode | undefined): string | undefined {
if (!node) {
return undefined;
}
switch (node.kind) {
case 'user-input':
return 'start';
case 'user-output':
return 'end';
case 'agent':
return node.id.trim();
default:
return undefined;
}
}
function buildWorkflowAdjacency(
edges: ReadonlyArray<WorkflowEdge>,
excludedEdgeId?: string,
): Map<string, string[]> {
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (edge.id === excludedEdgeId) {
continue;
}
const next = adjacency.get(edge.source);
if (next) {
next.push(edge.target);
} else {
adjacency.set(edge.source, [edge.target]);
}
}
return adjacency;
}
function canReachWorkflowNode(
edges: ReadonlyArray<WorkflowEdge>,
startNodeId: string,
targetNodeId: string,
excludedEdgeId?: string,
): boolean {
if (startNodeId === targetNodeId) {
return true;
}
const adjacency = buildWorkflowAdjacency(edges, excludedEdgeId);
const queue = [startNodeId];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) {
continue;
}
visited.add(current);
for (const candidate of adjacency.get(current) ?? []) {
if (candidate === targetNodeId) {
return true;
}
queue.push(candidate);
}
}
return false;
}
function markLoopEdges(
edges: ReadonlyArray<WorkflowEdge>,
maxIterations: number,
): WorkflowEdge[] {
return edges.map((edge) => {
const participatesInCycle = canReachWorkflowNode(edges, edge.target, edge.source, edge.id);
if (!participatesInCycle) {
return edge;
}
return {
...edge,
isLoop: true,
maxIterations,
condition: edge.condition ?? { type: 'always' },
};
});
}
export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDefinition): WorkflowTemplateDefinition {
const workflow = normalizeTemplateWorkflow(template.workflow);
@@ -240,141 +84,6 @@ export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDe
};
}
export function buildWorkflowFromPattern(pattern: PatternDefinition): WorkflowDefinition {
const resolvedGraph = resolvePatternGraph(pattern);
const patternNodesById = new Map(resolvedGraph.nodes.map((node) => [node.id, node]));
const agentsById = new Map(pattern.agents.map((agent) => [agent.id, agent]));
const orderedAgentNodes = sortAgentNodes(resolvedGraph.nodes);
const startPosition = patternNodesById.get('system-user-input')?.position ?? { x: 0, y: 0 };
const endPosition = patternNodesById.get('system-user-output')?.position ?? { x: 500, y: 0 };
const workflowNodes: WorkflowNode[] = [
{
id: 'start',
kind: 'start',
label: 'Start',
position: { x: startPosition.x, y: startPosition.y },
config: { kind: 'start' },
},
...orderedAgentNodes.map((node, index) => {
const agent = node.agentId ? agentsById.get(node.agentId) : undefined;
if (!agent) {
throw new Error(`Pattern "${pattern.id}" references missing agent "${node.agentId ?? node.id}".`);
}
return createWorkflowAgentNode(node, agent, index);
}),
{
id: 'end',
kind: 'end',
label: 'End',
position: { x: endPosition.x, y: endPosition.y },
config: { kind: 'end' },
},
];
const orderedAgentIds = workflowNodes.filter((node) => node.kind === 'agent').map((node) => node.id);
const edges: WorkflowEdge[] = [];
const seenEdges = new Set<string>();
const pushEdge = (edge: WorkflowEdge) => {
const dedupeKey = `${edge.source}:${edge.target}:${edge.kind}:${edge.isLoop === true ? 'loop' : 'plain'}`;
if (seenEdges.has(dedupeKey)) {
return;
}
seenEdges.add(dedupeKey);
edges.push(edge);
};
switch (pattern.mode) {
case 'single':
case 'sequential':
case 'magentic': {
const path = ['start', ...orderedAgentIds, 'end'];
for (let index = 0; index < path.length - 1; index += 1) {
pushEdge(createWorkflowEdge(path[index]!, path[index + 1]!, 'direct'));
}
break;
}
case 'concurrent': {
for (const agentId of orderedAgentIds) {
pushEdge(createWorkflowEdge('start', agentId, 'fan-out', {
fanOutConfig: { strategy: 'broadcast' },
}));
pushEdge(createWorkflowEdge(agentId, 'end', 'fan-in'));
}
break;
}
case 'handoff': {
for (const patternEdge of resolvedGraph.edges) {
const sourceNode = patternNodesById.get(patternEdge.source);
const targetNode = patternNodesById.get(patternEdge.target);
const source = mapPatternNodeId(sourceNode);
const target = mapPatternNodeId(targetNode);
if (!source || !target) {
continue;
}
pushEdge(createWorkflowEdge(source, target, 'direct', {
id: `workflow-${patternEdge.id.trim()}`,
}));
}
break;
}
case 'group-chat': {
const path = ['start', ...orderedAgentIds, 'end'];
for (let index = 0; index < path.length - 1; index += 1) {
const source = path[index]!;
const target = path[index + 1]!;
pushEdge(createWorkflowEdge(source, target, 'direct', source !== 'start' && target !== 'end'
? {
isLoop: true,
maxIterations: pattern.maxIterations,
condition: { type: 'always' },
}
: undefined));
}
if (orderedAgentIds.length > 0) {
pushEdge(createWorkflowEdge(
orderedAgentIds[orderedAgentIds.length - 1]!,
orderedAgentIds[0]!,
'direct',
{
id: `workflow-edge-${orderedAgentIds[orderedAgentIds.length - 1]}-loop-${orderedAgentIds[0]}`,
isLoop: true,
maxIterations: pattern.maxIterations,
condition: { type: 'always' },
label: 'Loop',
},
));
}
break;
}
}
const finalizedEdges = pattern.mode === 'handoff'
? markLoopEdges(edges, Math.max(pattern.maxIterations, 1))
: edges;
return normalizeWorkflowDefinition({
id: toWorkflowIdFromPatternId(pattern.id),
name: pattern.name,
description: pattern.description,
graph: {
nodes: workflowNodes,
edges: finalizedEdges,
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
maxIterations: pattern.maxIterations,
approvalPolicy: pattern.approvalPolicy,
},
createdAt: pattern.createdAt,
updatedAt: pattern.updatedAt,
});
}
export function createBuiltinWorkflowTemplates(timestamp: string): WorkflowTemplateDefinition[] {
return [
createCodeReviewPipeline(timestamp),
+2 -9
View File
@@ -1,5 +1,3 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
@@ -7,22 +5,18 @@ import {
createBuiltinWorkflowTemplates,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createBuiltinWorkflows, type WorkflowDefinition } from '@shared/domain/workflow';
import { nowIso } from '@shared/utils/ids';
export interface WorkspaceState {
projects: ProjectRecord[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
workflowTemplates: WorkflowTemplateDefinition[];
sessions: SessionRecord[];
settings: WorkspaceSettings;
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
deletedBuiltinPatternIds?: string[];
/** Runtime-only MCP probe progress for live UI updates. */
mcpProbingServerIds?: string[];
selectedProjectId?: string;
selectedPatternId?: string;
selectedWorkflowId?: string;
selectedSessionId?: string;
lastUpdatedAt: string;
@@ -32,8 +26,7 @@ export function createWorkspaceSeed(): WorkspaceState {
const timestamp = nowIso();
return {
projects: [],
patterns: createBuiltinPatterns(timestamp),
workflows: [],
workflows: createBuiltinWorkflows(timestamp),
workflowTemplates: createBuiltinWorkflowTemplates(timestamp),
sessions: [],
settings: createWorkspaceSettings(),
+44 -40
View File
@@ -1,5 +1,9 @@
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
import type { PatternAgentDefinition, PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type {
AgentNodeConfig,
ReasoningEffort,
WorkflowDefinition,
} from '@shared/domain/workflow';
export interface WorkspaceAgentDefinition {
id: string;
@@ -13,38 +17,32 @@ export interface WorkspaceAgentDefinition {
updatedAt: string;
}
export interface PatternAgentOverrides {
name?: string;
description?: string;
instructions?: string;
model?: string;
reasoningEffort?: ReasoningEffort;
export interface WorkflowAgentUsage {
workflowId: string;
workflowName: string;
}
export interface WorkspaceAgentUsage {
patternId: string;
patternName: string;
function normalizeOptionalString(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/**
* Resolves a single pattern agent by merging its workspace agent base with
* per-pattern overrides. Returns the agent unchanged if it is inline.
*/
export function resolvePatternAgent(
agent: PatternAgentDefinition,
export function resolveWorkflowAgentNode(
agent: AgentNodeConfig,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): PatternAgentDefinition {
): AgentNodeConfig {
if (!agent.workspaceAgentId) {
return agent;
}
const base = workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId);
const base = workspaceAgents.find((workspaceAgent) => workspaceAgent.id === agent.workspaceAgentId);
if (!base) {
return agent;
}
const overrides = agent.overrides ?? {};
return {
...agent,
id: agent.id,
name: overrides.name ?? base.name,
description: overrides.description ?? base.description,
@@ -57,40 +55,45 @@ export function resolvePatternAgent(
};
}
/**
* Resolves all agents in a pattern, producing a new pattern whose agents
* have workspace-agent references merged with their base definitions.
*/
export function resolvePatternAgents(
pattern: PatternDefinition,
export function resolveWorkflowAgents(
workflow: WorkflowDefinition,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): PatternDefinition {
): WorkflowDefinition {
return {
...pattern,
agents: pattern.agents.map((agent) => resolvePatternAgent(agent, workspaceAgents)),
...workflow,
graph: {
...workflow.graph,
nodes: workflow.graph.nodes.map((node) => {
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
return {
...node,
config: resolveWorkflowAgentNode(node.config, workspaceAgents),
};
}),
},
};
}
/**
* Returns every pattern that references the given workspace agent.
*/
export function findWorkspaceAgentUsages(
agentId: string,
patterns: ReadonlyArray<PatternDefinition>,
): WorkspaceAgentUsage[] {
const usages: WorkspaceAgentUsage[] = [];
for (const pattern of patterns) {
if (pattern.agents.some((a) => a.workspaceAgentId === agentId)) {
usages.push({ patternId: pattern.id, patternName: pattern.name });
workflows: ReadonlyArray<WorkflowDefinition>,
): WorkflowAgentUsage[] {
const usages: WorkflowAgentUsage[] = [];
for (const workflow of workflows) {
if (workflow.graph.nodes.some((node) =>
node.kind === 'agent'
&& node.config.kind === 'agent'
&& node.config.workspaceAgentId === agentId)) {
usages.push({ workflowId: workflow.id, workflowName: workflow.name });
}
}
return usages;
}
/**
* Normalizes a workspace agent definition, trimming string fields and
* ensuring consistent shape.
*/
export function normalizeWorkspaceAgentDefinition(
agent: WorkspaceAgentDefinition,
): WorkspaceAgentDefinition {
@@ -100,5 +103,6 @@ export function normalizeWorkspaceAgentDefinition(
description: agent.description.trim(),
instructions: agent.instructions.trim(),
model: agent.model.trim(),
reasoningEffort: normalizeOptionalString(agent.reasoningEffort) as ReasoningEffort | undefined,
};
}