mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 02:48:44 +02:00
feat: add backend session branching support
Add the backend contract and session-domain support for 'Branch from here': - new branchSession IPC method and sessions:branch channel - branchOrigin metadata on SessionRecord - session branching helper that truncates the transcript at a chosen user message and clears runtime state - AryxAppService branching flow with scratchpad directory support - persistence normalization and regression coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -62,6 +62,7 @@ import {
|
||||
} from '@shared/domain/approval';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import {
|
||||
branchSessionRecord,
|
||||
duplicateSessionRecord,
|
||||
querySessions as queryWorkspaceSessions,
|
||||
renameSessionRecord,
|
||||
@@ -703,6 +704,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
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());
|
||||
if (isScratchpadProject(branch.projectId)) {
|
||||
branch.cwd = undefined;
|
||||
}
|
||||
|
||||
await this.ensureScratchpadSessionDirectory(branch);
|
||||
workspace.sessions.unshift(branch);
|
||||
workspace.selectedProjectId = branch.projectId;
|
||||
workspace.selectedPatternId = branch.patternId;
|
||||
workspace.selectedSessionId = branch.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async renameSession(sessionId: string, title: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
BranchSessionInput,
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
@@ -135,6 +136,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||
service.duplicateSession(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
|
||||
service.branchSession(input.sessionId, input.messageId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
|
||||
service.renameSession(input.sessionId, input.title),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
@@ -81,6 +81,7 @@ export class WorkspaceRepository {
|
||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
|
||||
@@ -47,6 +47,7 @@ const api: ElectronApi = {
|
||||
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
|
||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
||||
branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input),
|
||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ipcChannels = {
|
||||
updateSessionApprovalSettings: 'sessions:update-approval-settings',
|
||||
createSession: 'sessions:create',
|
||||
duplicateSession: 'sessions:duplicate',
|
||||
branchSession: 'sessions:branch',
|
||||
renameSession: 'sessions:rename',
|
||||
setSessionPinned: 'sessions:set-pinned',
|
||||
setSessionArchived: 'sessions:set-archived',
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface DuplicateSessionInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface BranchSessionInput {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface RenameSessionInput {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
@@ -174,6 +179,7 @@ export interface ElectronApi {
|
||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ChatMessageRecord {
|
||||
attachments?: ChatMessageAttachment[];
|
||||
}
|
||||
|
||||
export interface SessionBranchOrigin {
|
||||
sourceSessionId: string;
|
||||
sourceMessageId: string;
|
||||
sourceMessageIndex: number;
|
||||
branchedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -47,6 +54,7 @@ export interface SessionRecord {
|
||||
status: SessionStatus;
|
||||
isPinned?: boolean;
|
||||
isArchived?: boolean;
|
||||
branchOrigin?: SessionBranchOrigin;
|
||||
interactionMode?: InteractionMode;
|
||||
cwd?: string;
|
||||
messages: ChatMessageRecord[];
|
||||
@@ -62,6 +70,38 @@ export interface SessionRecord {
|
||||
runs: SessionRunRecord[];
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSessionBranchOrigin(
|
||||
branchOrigin?: Partial<SessionBranchOrigin>,
|
||||
): SessionBranchOrigin | undefined {
|
||||
const sourceSessionId = normalizeOptionalString(branchOrigin?.sourceSessionId);
|
||||
const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId);
|
||||
const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt);
|
||||
const sourceMessageIndex = branchOrigin?.sourceMessageIndex;
|
||||
|
||||
if (
|
||||
!sourceSessionId
|
||||
|| !sourceMessageId
|
||||
|| !branchedAt
|
||||
|| typeof sourceMessageIndex !== 'number'
|
||||
|| !Number.isInteger(sourceMessageIndex)
|
||||
|| sourceMessageIndex < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceSessionId,
|
||||
sourceMessageId,
|
||||
sourceMessageIndex,
|
||||
branchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(
|
||||
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
||||
pattern: PatternDefinition,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import type { ChatMessageRecord, SessionRecord, SessionStatus } from '@shared/domain/session';
|
||||
import { resolveSessionTitle, type ChatMessageRecord, type SessionBranchOrigin, type SessionRecord, type SessionStatus } from '@shared/domain/session';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
||||
@@ -158,21 +158,32 @@ export function renameSessionRecord(session: SessionRecord, title: string, updat
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSessionRecord(
|
||||
function cloneBranchOrigin(branchOrigin?: SessionBranchOrigin): SessionBranchOrigin | undefined {
|
||||
return branchOrigin ? { ...branchOrigin } : undefined;
|
||||
}
|
||||
|
||||
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
|
||||
return {
|
||||
...message,
|
||||
pending: false,
|
||||
attachments: message.attachments?.map((attachment) => ({ ...attachment })),
|
||||
};
|
||||
}
|
||||
|
||||
function createDerivedSessionRecord(
|
||||
session: SessionRecord,
|
||||
sessionId: string,
|
||||
duplicatedAt: string,
|
||||
createdAt: string,
|
||||
): SessionRecord {
|
||||
return {
|
||||
...session,
|
||||
id: sessionId,
|
||||
title: `${session.title} (Copy)`,
|
||||
titleSource: 'manual',
|
||||
createdAt: duplicatedAt,
|
||||
updatedAt: duplicatedAt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
status: 'idle',
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
branchOrigin: cloneBranchOrigin(session.branchOrigin),
|
||||
lastError: undefined,
|
||||
sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined,
|
||||
tooling: session.tooling
|
||||
@@ -188,11 +199,60 @@ export function duplicateSessionRecord(
|
||||
: undefined,
|
||||
pendingApproval: undefined,
|
||||
pendingApprovalQueue: undefined,
|
||||
pendingUserInput: undefined,
|
||||
pendingPlanReview: undefined,
|
||||
pendingMcpAuth: undefined,
|
||||
runs: [],
|
||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||
...message,
|
||||
pending: false,
|
||||
})),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSessionRecord(
|
||||
session: SessionRecord,
|
||||
sessionId: string,
|
||||
duplicatedAt: string,
|
||||
): SessionRecord {
|
||||
return {
|
||||
...createDerivedSessionRecord(session, sessionId, duplicatedAt),
|
||||
title: `${session.title} (Copy)`,
|
||||
titleSource: 'manual',
|
||||
messages: session.messages.map(cloneChatMessageRecord),
|
||||
};
|
||||
}
|
||||
|
||||
export function branchSessionRecord(
|
||||
session: SessionRecord,
|
||||
pattern: PatternDefinition,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
branchedAt: string,
|
||||
): SessionRecord {
|
||||
const sourceMessageIndex = session.messages.findIndex((message) => message.id === messageId);
|
||||
if (sourceMessageIndex < 0) {
|
||||
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||
}
|
||||
|
||||
const sourceMessage = session.messages[sourceMessageIndex];
|
||||
if (!sourceMessage) {
|
||||
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||
}
|
||||
|
||||
if (sourceMessage.role !== 'user') {
|
||||
throw new Error('Only user messages can be used as a branch point.');
|
||||
}
|
||||
|
||||
const branchedMessages = session.messages.slice(0, sourceMessageIndex + 1).map(cloneChatMessageRecord);
|
||||
|
||||
return {
|
||||
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
||||
title: resolveSessionTitle(session, pattern, branchedMessages),
|
||||
messages: branchedMessages,
|
||||
branchOrigin: {
|
||||
sourceSessionId: session.id,
|
||||
sourceMessageId: messageId,
|
||||
sourceMessageIndex,
|
||||
branchedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user