fix: isolate scratchpad session working directories

Give each scratchpad session its own working directory, migrate existing scratchpad sessions on workspace load, and route sidecar turns through the session-specific cwd. Add regression coverage for create, duplicate, delete, and migration flows, and document the new persistence model.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 14:31:39 +01:00
co-authored by Copilot
parent fa5774cbc0
commit 147b437e36
9 changed files with 366 additions and 18 deletions
+38 -2
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events';
import { rm } from 'node:fs/promises';
import { mkdir, rm } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import electron from 'electron';
@@ -101,6 +101,7 @@ import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
import { SecretStore } from '@main/secrets/secretStore';
import { ConfigScannerRegistry } from '@main/services/configScanner';
import {
@@ -521,6 +522,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
runs: [],
};
await this.ensureScratchpadSessionDirectory(session);
workspace.sessions.unshift(session);
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
@@ -532,7 +534,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const duplicate = duplicateSessionRecord(session, createId('session'), nowIso());
if (isScratchpadProject(duplicate.projectId)) {
duplicate.cwd = undefined;
}
await this.ensureScratchpadSessionDirectory(duplicate);
workspace.sessions.unshift(duplicate);
workspace.selectedProjectId = duplicate.projectId;
workspace.selectedPatternId = duplicate.patternId;
@@ -572,6 +578,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
throw new Error(`Session ${sessionId} not found.`);
}
const session = workspace.sessions[sessionIndex];
if (!session) {
throw new Error(`Session ${sessionId} not found.`);
}
const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session);
if (scratchpadDirectory) {
await rm(scratchpadDirectory, { recursive: true, force: true });
}
workspace.sessions.splice(sessionIndex, 1);
if (workspace.selectedSessionId === sessionId) {
@@ -654,7 +670,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
type: 'run-turn',
requestId,
sessionId: session.id,
projectPath: project.path,
projectPath: session.cwd ?? project.path,
workspaceKind,
mode: session.interactionMode ?? 'interactive',
messageMode,
@@ -1225,6 +1241,26 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return session;
}
private resolveScratchpadSessionDirectory(
session: Pick<SessionRecord, 'id' | 'projectId' | 'cwd'>,
): string | undefined {
if (!isScratchpadProject(session.projectId)) {
return undefined;
}
return session.cwd ?? getScratchpadSessionPath(session.id);
}
private async ensureScratchpadSessionDirectory(session: SessionRecord): Promise<void> {
const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session);
if (!scratchpadDirectory) {
return;
}
await mkdir(scratchpadDirectory, { recursive: true });
session.cwd = scratchpadDirectory;
}
private async applyTurnDelta(
workspace: WorkspaceState,
sessionId: string,
+4
View File
@@ -10,3 +10,7 @@ export function getWorkspaceFilePath(): string {
export function getScratchpadDirectoryPath(): string {
return join(app.getPath('userData'), 'scratchpad');
}
export function getScratchpadSessionPath(sessionId: string): string {
return join(getScratchpadDirectoryPath(), sessionId);
}
+30 -12
View File
@@ -2,9 +2,10 @@ import { mkdir } from 'node:fs/promises';
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
import type { PatternDefinition } from '@shared/domain/pattern';
import { mergeScratchpadProject } from '@shared/domain/project';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
@@ -17,7 +18,11 @@ import {
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
import { getScratchpadDirectoryPath, getWorkspaceFilePath } from '@main/persistence/appPaths';
import {
getScratchpadDirectoryPath,
getScratchpadSessionPath,
getWorkspaceFilePath,
} from '@main/persistence/appPaths';
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
@@ -71,6 +76,28 @@ export class WorkspaceRepository {
})),
this.scratchpadPath,
);
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
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 settings = normalizeWorkspaceSettings(stored.settings);
const workspace: WorkspaceState = {
@@ -81,16 +108,7 @@ export class WorkspaceRepository {
graph: resolvePatternGraph(pattern),
})),
projects,
sessions: (stored.sessions ?? []).map((session) => ({
...session,
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
})),
sessions,
settings,
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
? stored.selectedProjectId
+1
View File
@@ -48,6 +48,7 @@ export interface SessionRecord {
isPinned?: boolean;
isArchived?: boolean;
interactionMode?: InteractionMode;
cwd?: string;
messages: ChatMessageRecord[];
lastError?: string;
sessionModelConfig?: SessionModelConfig;