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
+3 -3
View File
@@ -33,7 +33,7 @@ flowchart LR
Renderer[Renderer UI<br/>React + Tailwind]
Preload[Preload bridge]
Main[Electron main process]
Workspace[Workspace storage<br/>JSON + scratchpad files]
Workspace[Workspace storage<br/>workspace.json + per-session scratchpad directories]
Git[Local git repositories]
Sidecar[.NET sidecar]
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
@@ -122,7 +122,7 @@ Projects are the container for context. There are two kinds:
- a special **scratchpad** project for lightweight work
- normal **project-backed** entries pointing at local folders
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior.
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations.
### Patterns
@@ -278,7 +278,7 @@ This lets the application treat tooling as reusable workspace capability while s
### Project awareness
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
### Execution observability
+1
View File
@@ -28,6 +28,7 @@ It works especially well when you want AI help that stays grounded in an actual
### Ask quick questions in a scratchpad
If you just want to think through an idea, draft something, or ask for help without connecting a project, start a scratchpad session and begin chatting.
Each scratchpad session keeps its own isolated working directory, so files created in one scratchpad do not leak into another.
### Connect a real project
+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;
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { access, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { PatternDefinition } from '@shared/domain/pattern';
import { createScratchpadProject } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures';
mock.module('electron', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
getPath: () => USER_DATA_PATH,
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
};
return {
...electronMock,
default: electronMock,
};
});
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { AryxAppService } = await import('@main/AryxAppService');
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
return pattern;
}
function createScratchpadSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-scratchpad',
projectId: 'project-scratchpad',
patternId,
title: 'Scratchpad',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
status: 'idle',
messages: [],
runs: [],
...overrides,
};
}
function createService(
workspace: WorkspaceState,
options?: {
onDeleteSession?: (sessionId: string) => Promise<void>;
},
): InstanceType<typeof AryxAppService> {
const service = new AryxAppService();
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.loadAvailableModelCatalog = async () => [];
(
service as unknown as {
sidecar: {
deleteSession: (sessionId: string) => Promise<void>;
};
}
).sidecar = {
deleteSession: async (sessionId: string) => {
await options?.onDeleteSession?.(sessionId);
},
};
return service;
}
beforeEach(async () => {
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
});
afterEach(async () => {
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
});
describe('AryxAppService scratchpad directories', () => {
test('creates a dedicated directory for new scratchpad sessions', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
workspace.projects = [scratchpadProject];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
const service = createService(workspace);
const result = await service.createSession(scratchpadProject.id, pattern.id);
const session = result.sessions[0];
if (!session) {
throw new Error('Expected createSession to prepend a scratchpad session.');
}
expect(session.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', session.id));
expect(await pathExists(session.cwd!)).toBe(true);
});
test('creates a new directory when duplicating a scratchpad session', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
const originalDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-original');
const originalSession = createScratchpadSession(pattern.id, {
id: 'session-original',
cwd: originalDirectory,
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Draft a plan.',
createdAt: TIMESTAMP,
},
],
});
workspace.projects = [scratchpadProject];
workspace.sessions = [originalSession];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedSessionId = originalSession.id;
const service = createService(workspace);
const result = await service.duplicateSession(originalSession.id);
const duplicate = result.sessions[0];
if (!duplicate) {
throw new Error('Expected duplicateSession to prepend the duplicate.');
}
expect(duplicate.id).not.toBe(originalSession.id);
expect(duplicate.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', duplicate.id));
expect(duplicate.cwd).not.toBe(originalSession.cwd);
expect(await pathExists(duplicate.cwd!)).toBe(true);
});
test('removes the scratchpad directory when deleting a session', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
const sessionDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-scratchpad');
const session = createScratchpadSession(pattern.id, {
cwd: sessionDirectory,
});
workspace.projects = [scratchpadProject];
workspace.sessions = [session];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedSessionId = session.id;
const service = createService(workspace);
await mkdir(sessionDirectory, { recursive: true });
expect(await pathExists(sessionDirectory)).toBe(true);
const result = await service.deleteSession(session.id);
expect(result.sessions).toHaveLength(0);
expect(await pathExists(sessionDirectory)).toBe(false);
});
});
+3 -1
View File
@@ -8,6 +8,7 @@ import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspa
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
const SCRATCHPAD_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\scratchpad';
const SCRATCHPAD_SESSION_PATH = `${SCRATCHPAD_PATH}\\session-scratchpad`;
mock.module('electron', () => {
const electronMock = {
@@ -60,6 +61,7 @@ function createWorkspaceFixture(): {
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
status: 'idle',
cwd: SCRATCHPAD_SESSION_PATH,
messages: [],
runs: [],
};
@@ -186,7 +188,7 @@ describe('AryxAppService scratchpad tooling support', () => {
expect(command).toMatchObject({
sessionId: session.id,
projectPath: SCRATCHPAD_PATH,
projectPath: SCRATCHPAD_SESSION_PATH,
workspaceKind: 'scratchpad',
tooling: {
mcpServers: [
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createScratchpadProject } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures';
mock.module('electron', () => {
const electronMock = {
app: {
getPath: () => USER_DATA_PATH,
},
};
return {
...electronMock,
default: electronMock,
};
});
const { WorkspaceRepository } = await import('@main/persistence/workspaceRepository');
function createStoredWorkspace(): WorkspaceState {
const workspace = createWorkspaceSeed();
const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP);
const patternId = workspace.patterns[0]?.id;
if (!patternId) {
throw new Error('Expected workspace seed to include at least one pattern.');
}
const session: SessionRecord = {
id: 'session-scratchpad',
projectId: scratchpadProject.id,
patternId,
title: 'Scratchpad',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
status: 'idle',
messages: [],
runs: [],
};
return {
...workspace,
projects: [scratchpadProject],
sessions: [session],
selectedProjectId: scratchpadProject.id,
selectedPatternId: patternId,
selectedSessionId: session.id,
};
}
beforeEach(async () => {
await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true });
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
});
afterEach(async () => {
await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true });
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
});
describe('WorkspaceRepository scratchpad migration', () => {
test('assigns per-session scratchpad directories while loading existing workspace state', async () => {
const workspaceFilePath = join(USER_DATA_PATH, 'workspace.json');
await mkdir(USER_DATA_PATH, { recursive: true });
await writeFile(workspaceFilePath, JSON.stringify(createStoredWorkspace(), null, 2), 'utf8');
const repository = new WorkspaceRepository();
const loaded = await repository.load();
const loadedSession = loaded.sessions[0];
if (!loadedSession) {
throw new Error('Expected load() to return the migrated scratchpad session.');
}
const expectedScratchpadPath = join(USER_DATA_PATH, 'scratchpad');
const expectedSessionPath = join(expectedScratchpadPath, loadedSession.id);
expect(loaded.projects[0]?.path).toBe(expectedScratchpadPath);
expect(loadedSession.cwd).toBe(expectedSessionPath);
await access(expectedSessionPath);
const persisted = JSON.parse(await readFile(workspaceFilePath, 'utf8')) as WorkspaceState;
expect(persisted.sessions[0]?.cwd).toBe(expectedSessionPath);
});
});