Fix/cleanup 2.7.5 (#1271)

* fix(frontend): resolve preload API duplicates and terminal session corruption

- Remove duplicate API spreads (IdeationAPI, InsightsAPI, GitLabAPI) from
  createElectronAPI() - these are already included via createAgentAPI()
- Fix "object is not iterable" error in Electron sandbox renderer
- Implement atomic writes for TerminalSessionStore using temp file + rename
- Add backup rotation and automatic recovery from corrupted session files
- Prevents data loss on app crash or interrupted writes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(hooks): use perl for cross-platform README version sync

BSD sed (macOS) doesn't support the {block} syntax with address ranges.
Replace sed with perl for the download links update which works
consistently across macOS and Linux.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: bump version to 2.7.5

* perf(frontend): optimize terminal session store async operations

- Replace sync existsSync() with async fileExists() helper in saveAsync()
- Add pendingDeleteTimers Map to prevent timer accumulation on rapid deletes
- Cancel existing cleanup timers before creating new ones
- Add comprehensive unit tests (28 tests) covering:
  - Atomic write pattern (temp file -> backup rotation -> rename)
  - Backup recovery from corrupted main file
  - Race condition prevention via pendingDelete
  - Write serialization with writeInProgress/writePending
  - Timer cleanup for pendingDeleteTimers
  - Session CRUD operations, output buffer, display order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-17 23:47:55 +01:00
committed by GitHub
parent 44304a61d1
commit f0c3e50858
8 changed files with 759 additions and 54 deletions
+4 -2
View File
@@ -62,8 +62,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
else
# STABLE: Update stable sections and top badge
@@ -78,8 +79,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
fi
+6 -6
View File
@@ -16,17 +16,17 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.4-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.flatpak) |
<!-- STABLE_DOWNLOADS_END -->
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.4"
__version__ = "2.7.5"
__author__ = "Auto Claude Team"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.4",
"version": "2.7.5",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -0,0 +1,578 @@
/**
* Unit tests for Terminal Session Store
* Tests atomic writes, backup recovery, race condition prevention, and write serialization
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
import path from 'path';
// Test directories
const TEST_DIR = '/tmp/terminal-session-store-test';
const USER_DATA_PATH = path.join(TEST_DIR, 'userData');
const SESSIONS_DIR = path.join(USER_DATA_PATH, 'sessions');
const STORE_PATH = path.join(SESSIONS_DIR, 'terminals.json');
const TEMP_PATH = path.join(SESSIONS_DIR, 'terminals.json.tmp');
const BACKUP_PATH = path.join(SESSIONS_DIR, 'terminals.json.backup');
const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
// Mock Electron before importing the store
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') return USER_DATA_PATH;
return TEST_DIR;
})
}
}));
// Setup test directories
function setupTestDirs(): void {
mkdirSync(SESSIONS_DIR, { recursive: true });
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
}
// Cleanup test directories
function cleanupTestDirs(): void {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
}
// Create a valid session data structure
function createValidStoreData(sessionsByDate: Record<string, Record<string, unknown[]>> = {}): string {
return JSON.stringify({
version: 2,
sessionsByDate
}, null, 2);
}
// Create a test session
function createTestSession(overrides: Partial<{
id: string;
title: string;
cwd: string;
projectPath: string;
isClaudeMode: boolean;
outputBuffer: string;
createdAt: string;
lastActiveAt: string;
}> = {}) {
return {
id: overrides.id ?? 'test-session-1',
title: overrides.title ?? 'Test Terminal',
cwd: overrides.cwd ?? TEST_PROJECT_PATH,
projectPath: overrides.projectPath ?? TEST_PROJECT_PATH,
isClaudeMode: overrides.isClaudeMode ?? false,
outputBuffer: overrides.outputBuffer ?? 'test output',
createdAt: overrides.createdAt ?? new Date().toISOString(),
lastActiveAt: overrides.lastActiveAt ?? new Date().toISOString()
};
}
describe('TerminalSessionStore', () => {
beforeEach(async () => {
cleanupTestDirs();
setupTestDirs();
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
cleanupTestDirs();
vi.clearAllMocks();
vi.useRealTimers();
});
describe('initialization', () => {
it('should create sessions directory if not exists', async () => {
rmSync(SESSIONS_DIR, { recursive: true, force: true });
const { TerminalSessionStore } = await import('../terminal-session-store');
new TerminalSessionStore();
expect(existsSync(SESSIONS_DIR)).toBe(true);
});
it('should initialize with empty data when no store file exists', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
const data = store.getAllSessions();
expect(data.version).toBe(2);
expect(data.sessionsByDate).toEqual({});
});
it('should load existing valid store data', async () => {
const today = new Date().toISOString().split('T')[0];
const existingData = createValidStoreData({
[today]: {
[TEST_PROJECT_PATH]: [createTestSession()]
}
});
writeFileSync(STORE_PATH, existingData);
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
const sessions = store.getSessions(TEST_PROJECT_PATH);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe('test-session-1');
});
});
describe('atomic writes', () => {
it('should write to temp file then rename atomically', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession());
// Main file should exist after save
expect(existsSync(STORE_PATH)).toBe(true);
// Temp file should be cleaned up
expect(existsSync(TEMP_PATH)).toBe(false);
// Verify content
const content = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
expect(content.version).toBe(2);
});
it('should rotate current file to backup before overwriting', async () => {
// Create initial store with one session
const today = new Date().toISOString().split('T')[0];
const initialData = createValidStoreData({
[today]: {
[TEST_PROJECT_PATH]: [createTestSession({ id: 'original-session' })]
}
});
writeFileSync(STORE_PATH, initialData);
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Save a new session (triggers backup rotation)
store.saveSession(createTestSession({ id: 'new-session' }));
// Backup should exist with original data
expect(existsSync(BACKUP_PATH)).toBe(true);
const backupContent = JSON.parse(readFileSync(BACKUP_PATH, 'utf-8'));
const backupSessions = backupContent.sessionsByDate[today][TEST_PROJECT_PATH];
expect(backupSessions.some((s: { id: string }) => s.id === 'original-session')).toBe(true);
});
it('should not backup corrupted files', async () => {
// Create corrupted store file
writeFileSync(STORE_PATH, 'not valid json {{{');
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Save a session
store.saveSession(createTestSession());
// Backup should NOT contain the corrupted data
if (existsSync(BACKUP_PATH)) {
const backupContent = readFileSync(BACKUP_PATH, 'utf-8');
expect(backupContent).not.toContain('not valid json');
}
});
it('should clean up temp file on error', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Force an error by making the directory read-only (if possible)
// This test mainly verifies the code path exists
store.saveSession(createTestSession());
// Temp file should not exist after successful save
expect(existsSync(TEMP_PATH)).toBe(false);
});
});
describe('backup recovery', () => {
it('should recover from corrupted main file using backup', async () => {
const today = new Date().toISOString().split('T')[0];
// Create valid backup
const backupData = createValidStoreData({
[today]: {
[TEST_PROJECT_PATH]: [createTestSession({ id: 'recovered-session' })]
}
});
writeFileSync(BACKUP_PATH, backupData);
// Create corrupted main file
writeFileSync(STORE_PATH, 'corrupted {{{ json');
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Should recover from backup
const sessions = store.getSessions(TEST_PROJECT_PATH);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe('recovered-session');
});
it('should restore main file from backup after recovery', async () => {
const today = new Date().toISOString().split('T')[0];
// Create valid backup
const backupData = createValidStoreData({
[today]: {
[TEST_PROJECT_PATH]: [createTestSession()]
}
});
writeFileSync(BACKUP_PATH, backupData);
// Create corrupted main file
writeFileSync(STORE_PATH, 'corrupted');
const { TerminalSessionStore } = await import('../terminal-session-store');
new TerminalSessionStore();
// Main file should now be valid
const mainContent = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
expect(mainContent.version).toBe(2);
});
it('should start fresh if both main and backup are corrupted', async () => {
writeFileSync(STORE_PATH, 'corrupted main');
writeFileSync(BACKUP_PATH, 'corrupted backup');
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
const data = store.getAllSessions();
expect(data.version).toBe(2);
expect(data.sessionsByDate).toEqual({});
});
});
describe('race condition prevention', () => {
it('should not resurrect deleted sessions in async save', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Create and save a session
const session = createTestSession({ id: 'to-be-deleted' });
store.saveSession(session);
// Verify session exists
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(1);
// Delete the session
store.removeSession(TEST_PROJECT_PATH, 'to-be-deleted');
// Try to save the same session again (simulating in-flight async save)
await store.saveSessionAsync(session);
// Session should NOT be resurrected
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
});
it('should track session in pendingDelete after removal', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'session-1' }));
store.removeSession(TEST_PROJECT_PATH, 'session-1');
// Attempt to save the deleted session
const result = await store.saveSessionAsync(createTestSession({ id: 'session-1' }));
// Session should not be saved (saveSessionAsync returns undefined when skipped)
expect(result).toBeUndefined();
});
it('should clean up pendingDelete after timeout', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'cleanup-test' }));
store.removeSession(TEST_PROJECT_PATH, 'cleanup-test');
// Fast-forward past the cleanup timeout (5000ms)
vi.advanceTimersByTime(5001);
// Now the session should be saveable again
store.saveSession(createTestSession({ id: 'cleanup-test' }));
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(1);
});
it('should prevent timer accumulation on rapid deletes', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Create a session
store.saveSession(createTestSession({ id: 'rapid-delete' }));
// Delete the same session ID multiple times rapidly
for (let i = 0; i < 100; i++) {
store.removeSession(TEST_PROJECT_PATH, 'rapid-delete');
}
// Fast-forward to trigger cleanup
vi.advanceTimersByTime(5001);
// Should complete without issues (no timer accumulation)
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
});
});
describe('write serialization', () => {
it('should serialize concurrent async writes', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Start multiple concurrent saves
const promises = [
store.saveSessionAsync(createTestSession({ id: 'session-1', title: 'First' })),
store.saveSessionAsync(createTestSession({ id: 'session-2', title: 'Second' })),
store.saveSessionAsync(createTestSession({ id: 'session-3', title: 'Third' }))
];
await Promise.all(promises);
// All sessions should be saved
const sessions = store.getSessions(TEST_PROJECT_PATH);
expect(sessions).toHaveLength(3);
});
it('should coalesce rapid writes using writePending flag', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Use real timers for this test since we need setImmediate to work
vi.useRealTimers();
// Fire many rapid saves
const promises: Promise<void>[] = [];
for (let i = 0; i < 10; i++) {
promises.push(store.saveSessionAsync(createTestSession({
id: `rapid-${i}`,
title: `Session ${i}`
})));
}
await Promise.all(promises);
// All sessions should be saved
const sessions = store.getSessions(TEST_PROJECT_PATH);
expect(sessions).toHaveLength(10);
vi.useFakeTimers();
});
});
describe('failure tracking', () => {
it('should reset consecutive failures on successful save', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Successful save should work
store.saveSession(createTestSession());
// Verify file was written
expect(existsSync(STORE_PATH)).toBe(true);
});
});
describe('session CRUD operations', () => {
it('should save and retrieve sessions', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
const session = createTestSession({
id: 'crud-test',
title: 'CRUD Test Terminal'
});
store.saveSession(session);
const retrieved = store.getSession(TEST_PROJECT_PATH, 'crud-test');
expect(retrieved).toBeDefined();
expect(retrieved?.title).toBe('CRUD Test Terminal');
});
it('should update existing sessions', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Save initial session
store.saveSession(createTestSession({
id: 'update-test',
title: 'Original Title'
}));
// Update the session
store.saveSession(createTestSession({
id: 'update-test',
title: 'Updated Title'
}));
const sessions = store.getSessions(TEST_PROJECT_PATH);
expect(sessions).toHaveLength(1);
expect(sessions[0].title).toBe('Updated Title');
});
it('should remove sessions correctly', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'to-remove' }));
store.saveSession(createTestSession({ id: 'to-keep' }));
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(2);
store.removeSession(TEST_PROJECT_PATH, 'to-remove');
const remaining = store.getSessions(TEST_PROJECT_PATH);
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe('to-keep');
});
it('should clear all sessions for a project', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'session-1' }));
store.saveSession(createTestSession({ id: 'session-2' }));
store.clearProjectSessions(TEST_PROJECT_PATH);
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
});
});
describe('output buffer management', () => {
it('should limit output buffer size to MAX_OUTPUT_BUFFER', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Create session with large output buffer (> 100KB)
const largeOutput = 'x'.repeat(150000);
store.saveSession(createTestSession({
id: 'large-buffer',
outputBuffer: largeOutput
}));
const session = store.getSession(TEST_PROJECT_PATH, 'large-buffer');
expect(session?.outputBuffer.length).toBeLessThanOrEqual(100000);
});
it('should update output buffer incrementally', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({
id: 'buffer-update',
outputBuffer: 'initial'
}));
store.updateOutputBuffer(TEST_PROJECT_PATH, 'buffer-update', ' appended');
const session = store.getSession(TEST_PROJECT_PATH, 'buffer-update');
expect(session?.outputBuffer).toBe('initial appended');
});
});
describe('display order', () => {
it('should update display orders for terminals', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'term-1' }));
store.saveSession(createTestSession({ id: 'term-2' }));
store.saveSession(createTestSession({ id: 'term-3' }));
store.updateDisplayOrders(TEST_PROJECT_PATH, [
{ terminalId: 'term-1', displayOrder: 2 },
{ terminalId: 'term-2', displayOrder: 0 },
{ terminalId: 'term-3', displayOrder: 1 }
]);
const sessions = store.getSessions(TEST_PROJECT_PATH);
const term1 = sessions.find(s => s.id === 'term-1');
const term2 = sessions.find(s => s.id === 'term-2');
const term3 = sessions.find(s => s.id === 'term-3');
expect(term1?.displayOrder).toBe(2);
expect(term2?.displayOrder).toBe(0);
expect(term3?.displayOrder).toBe(1);
});
it('should preserve display order on session update', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'ordered-term' }));
store.updateDisplayOrders(TEST_PROJECT_PATH, [
{ terminalId: 'ordered-term', displayOrder: 5 }
]);
// Update session without displayOrder (simulating periodic output save)
store.saveSession(createTestSession({
id: 'ordered-term',
outputBuffer: 'new output'
}));
const session = store.getSession(TEST_PROJECT_PATH, 'ordered-term');
expect(session?.displayOrder).toBe(5);
});
});
describe('version migration', () => {
it('should migrate v1 data to v2 structure', async () => {
const today = new Date().toISOString().split('T')[0];
// Create v1 format data
const v1Data = JSON.stringify({
version: 1,
sessions: {
[TEST_PROJECT_PATH]: [createTestSession()]
}
});
writeFileSync(STORE_PATH, v1Data);
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
// Should have migrated to v2 with today's date
const data = store.getAllSessions();
expect(data.version).toBe(2);
expect(data.sessionsByDate[today]).toBeDefined();
});
});
describe('date-based organization', () => {
it('should get available dates with session info', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
store.saveSession(createTestSession({ id: 'today-1' }));
store.saveSession(createTestSession({ id: 'today-2' }));
const dates = store.getAvailableDates();
expect(dates).toHaveLength(1);
expect(dates[0].sessionCount).toBe(2);
expect(dates[0].label).toBe('Today');
});
it('should filter available dates by project', async () => {
const { TerminalSessionStore } = await import('../terminal-session-store');
const store = new TerminalSessionStore();
const otherProjectPath = path.join(TEST_DIR, 'other-project');
mkdirSync(otherProjectPath, { recursive: true });
store.saveSession(createTestSession({ projectPath: TEST_PROJECT_PATH }));
store.saveSession(createTestSession({ id: 'other', projectPath: otherProjectPath }));
const dates = store.getAvailableDates(TEST_PROJECT_PATH);
expect(dates).toHaveLength(1);
expect(dates[0].sessionCount).toBe(1);
});
});
});
+163 -33
View File
@@ -1,6 +1,6 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, promises as fsPromises } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, promises as fsPromises } from 'fs';
import type { TerminalWorktreeConfig } from '../shared/types';
/**
@@ -76,6 +76,8 @@ function getDateLabel(dateStr: string): string {
*/
export class TerminalSessionStore {
private storePath: string;
private tempPath: string;
private backupPath: string;
private data: SessionData;
/**
* Tracks session IDs that are being deleted to prevent async writes from
@@ -83,6 +85,11 @@ export class TerminalSessionStore {
* could complete after removeSession() and re-add deleted sessions.
*/
private pendingDelete: Set<string> = new Set();
/**
* Tracks cleanup timers for pendingDelete entries to prevent timer accumulation
* when many sessions are deleted rapidly.
*/
private pendingDeleteTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/**
* Write serialization state - prevents concurrent async writes from
* interleaving and potentially losing data.
@@ -99,6 +106,8 @@ export class TerminalSessionStore {
constructor() {
const sessionsDir = join(app.getPath('userData'), 'sessions');
this.storePath = join(sessionsDir, 'terminals.json');
this.tempPath = join(sessionsDir, 'terminals.json.tmp');
this.backupPath = join(sessionsDir, 'terminals.json.backup');
// Ensure directory exists
if (!existsSync(sessionsDir)) {
@@ -113,54 +122,133 @@ export class TerminalSessionStore {
}
/**
* Load sessions from disk
* Load sessions from disk with backup recovery
*/
private load(): SessionData {
try {
if (existsSync(this.storePath)) {
const content = readFileSync(this.storePath, 'utf-8');
const data = JSON.parse(content);
// Try loading from main file first
const mainResult = this.tryLoadFile(this.storePath);
if (mainResult.success && mainResult.data) {
return mainResult.data;
}
// Migrate from v1 to v2 structure
if (data.version === 1 && data.sessions) {
console.warn('[TerminalSessionStore] Migrating from v1 to v2 structure');
const today = getDateString();
const migratedData: SessionData = {
version: STORE_VERSION,
sessionsByDate: {
[today]: data.sessions
}
};
return migratedData;
// If main file failed, try backup
if (mainResult.error) {
console.warn('[TerminalSessionStore] Main file corrupted, attempting backup recovery...');
const backupResult = this.tryLoadFile(this.backupPath);
if (backupResult.success && backupResult.data) {
console.warn('[TerminalSessionStore] Successfully recovered from backup!');
// Immediately save the recovered data to main file
try {
writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2));
console.warn('[TerminalSessionStore] Restored main file from backup');
} catch (writeError) {
console.error('[TerminalSessionStore] Failed to restore main file:', writeError);
}
if (data.version === STORE_VERSION) {
return data as SessionData;
}
console.warn('[TerminalSessionStore] Version mismatch, resetting sessions');
return { version: STORE_VERSION, sessionsByDate: {} };
return backupResult.data;
}
} catch (error) {
console.error('[TerminalSessionStore] Error loading sessions:', error);
console.error('[TerminalSessionStore] Backup recovery failed, starting fresh');
}
return { version: STORE_VERSION, sessionsByDate: {} };
}
/**
* Save sessions to disk
* Try to load and parse a session file
*/
private save(): void {
private tryLoadFile(filePath: string): { success: boolean; data?: SessionData; error?: Error } {
try {
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
if (!existsSync(filePath)) {
return { success: false };
}
const content = readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Migrate from v1 to v2 structure
if (data.version === 1 && data.sessions) {
console.warn('[TerminalSessionStore] Migrating from v1 to v2 structure');
const today = getDateString();
const migratedData: SessionData = {
version: STORE_VERSION,
sessionsByDate: {
[today]: data.sessions
}
};
return { success: true, data: migratedData };
}
if (data.version === STORE_VERSION) {
return { success: true, data: data as SessionData };
}
console.warn('[TerminalSessionStore] Version mismatch, resetting sessions');
return { success: false };
} catch (error) {
console.error('[TerminalSessionStore] Error saving sessions:', error);
console.error(`[TerminalSessionStore] Error loading ${filePath}:`, error);
return { success: false, error: error as Error };
}
}
/**
* Save sessions to disk asynchronously (non-blocking)
* Save sessions to disk using atomic write pattern:
* 1. Write to temp file
* 2. Rotate current file to backup
* 3. Rename temp to target (atomic on most filesystems)
*/
private save(): void {
try {
const content = JSON.stringify(this.data, null, 2);
// Step 1: Write to temp file
writeFileSync(this.tempPath, content);
// Step 2: Rotate current file to backup (if it exists and is valid)
if (existsSync(this.storePath)) {
try {
// Verify current file is valid before backing up
const currentContent = readFileSync(this.storePath, 'utf-8');
JSON.parse(currentContent); // Throws if invalid
// Current file is valid, rotate to backup
if (existsSync(this.backupPath)) {
unlinkSync(this.backupPath);
}
renameSync(this.storePath, this.backupPath);
} catch {
// Current file is corrupted, don't back it up - just delete
console.warn('[TerminalSessionStore] Current file corrupted, not backing up');
unlinkSync(this.storePath);
}
}
// Step 3: Atomic rename temp to target
renameSync(this.tempPath, this.storePath);
} catch (error) {
console.error('[TerminalSessionStore] Error saving sessions:', error);
// Clean up temp file if it exists
try {
if (existsSync(this.tempPath)) {
unlinkSync(this.tempPath);
}
} catch {
// Ignore cleanup errors
}
}
}
/**
* Helper to check if a file exists asynchronously
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Save sessions to disk asynchronously (non-blocking) using atomic write pattern
*
* Safe to call from Electron main process without blocking the event loop.
* Uses write serialization to prevent concurrent writes from losing data.
@@ -175,13 +263,46 @@ export class TerminalSessionStore {
this.writeInProgress = true;
try {
await fsPromises.writeFile(this.storePath, JSON.stringify(this.data, null, 2));
const content = JSON.stringify(this.data, null, 2);
// Step 1: Write to temp file
await fsPromises.writeFile(this.tempPath, content);
// Step 2: Rotate current file to backup (if it exists and is valid)
if (await this.fileExists(this.storePath)) {
try {
const currentContent = await fsPromises.readFile(this.storePath, 'utf-8');
JSON.parse(currentContent); // Throws if invalid
// Current file is valid, rotate to backup
if (await this.fileExists(this.backupPath)) {
await fsPromises.unlink(this.backupPath);
}
await fsPromises.rename(this.storePath, this.backupPath);
} catch {
// Current file is corrupted, don't back it up - just delete
console.warn('[TerminalSessionStore] Current file corrupted, not backing up');
await fsPromises.unlink(this.storePath);
}
}
// Step 3: Atomic rename temp to target
await fsPromises.rename(this.tempPath, this.storePath);
// Reset failure counter on success
this.consecutiveFailures = 0;
} catch (error) {
this.consecutiveFailures++;
console.error('[TerminalSessionStore] Error saving sessions:', error);
// Clean up temp file if it exists
try {
if (await this.fileExists(this.tempPath)) {
await fsPromises.unlink(this.tempPath);
}
} catch {
// Ignore cleanup errors
}
// Warn about persistent failures that might indicate a real problem
if (this.consecutiveFailures >= TerminalSessionStore.MAX_FAILURES_BEFORE_WARNING) {
console.error(
@@ -459,11 +580,20 @@ export class TerminalSessionStore {
this.save();
}
// Cancel any existing cleanup timer for this session (prevents timer accumulation
// when the same session ID is deleted multiple times rapidly)
const existingTimer = this.pendingDeleteTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Keep the ID in pendingDelete for a short time to handle any in-flight
// async operations, then clean up to prevent memory leaks
setTimeout(() => {
const timer = setTimeout(() => {
this.pendingDelete.delete(sessionId);
this.pendingDeleteTimers.delete(sessionId);
}, 5000);
this.pendingDeleteTimers.set(sessionId, timer);
}
/**
+5 -10
View File
@@ -4,11 +4,11 @@ import { TaskAPI, createTaskAPI } from './task-api';
import { SettingsAPI, createSettingsAPI } from './settings-api';
import { FileAPI, createFileAPI } from './file-api';
import { AgentAPI, createAgentAPI } from './agent-api';
import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
import type { IdeationAPI } from './modules/ideation-api';
import type { InsightsAPI } from './modules/insights-api';
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
import { GitHubAPI, createGitHubAPI } from './modules/github-api';
import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api';
import type { GitLabAPI } from './modules/gitlab-api';
import { DebugAPI, createDebugAPI } from './modules/debug-api';
import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api';
import { McpAPI, createMcpAPI } from './modules/mcp-api';
@@ -38,11 +38,8 @@ export const createElectronAPI = (): ElectronAPI => ({
...createTaskAPI(),
...createSettingsAPI(),
...createFileAPI(),
...createAgentAPI(),
...createIdeationAPI(),
...createInsightsAPI(),
...createAgentAPI(), // Includes: Roadmap, Ideation, Insights, Changelog, Linear, GitHub, GitLab, Shell
...createAppUpdateAPI(),
...createGitLabAPI(),
...createDebugAPI(),
...createClaudeCodeAPI(),
...createMcpAPI(),
@@ -51,6 +48,7 @@ export const createElectronAPI = (): ElectronAPI => ({
});
// Export individual API creators for potential use in tests or specialized contexts
// Note: IdeationAPI, InsightsAPI, and GitLabAPI are included in AgentAPI
export {
createProjectAPI,
createTerminalAPI,
@@ -58,12 +56,9 @@ export {
createSettingsAPI,
createFileAPI,
createAgentAPI,
createIdeationAPI,
createInsightsAPI,
createAppUpdateAPI,
createProfileAPI,
createGitHubAPI,
createGitLabAPI,
createDebugAPI,
createClaudeCodeAPI,
createMcpAPI
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude",
"version": "2.7.4",
"version": "2.7.5",
"description": "Autonomous multi-agent coding framework powered by Claude AI",
"license": "AGPL-3.0",
"author": "Auto Claude Team",