fix(notifications): recover corrupted history writes

This commit is contained in:
777genius 2026-04-24 12:24:21 +03:00
parent 1c07e0fdb6
commit 267a192329
3 changed files with 180 additions and 54 deletions

View file

@ -174,10 +174,99 @@ async function selectLegacyNotificationData(): Promise<LegacyNotificationData |
}
function isNotificationHistoryJson(data: string): boolean {
return parseNotificationHistory(data) !== null;
}
interface NotificationHistoryParseResult {
notifications: StoredNotification[];
recovered: boolean;
}
function parseNotificationHistory(data: string): NotificationHistoryParseResult | null {
const parsed = parseNotificationHistoryArray(data);
if (parsed) {
return { notifications: parsed, recovered: false };
}
const firstArrayEnd = findFirstJsonArrayEnd(data);
if (firstArrayEnd === null) {
return null;
}
const recovered = parseNotificationHistoryArray(data.slice(0, firstArrayEnd));
return recovered ? { notifications: recovered, recovered: true } : null;
}
function parseNotificationHistoryArray(data: string): StoredNotification[] | null {
try {
return Array.isArray(JSON.parse(data));
const parsed = JSON.parse(data) as unknown;
return Array.isArray(parsed) ? (parsed as StoredNotification[]) : null;
} catch {
return false;
return null;
}
}
function findFirstJsonArrayEnd(data: string): number | null {
const start = data.search(/\S/u);
if (start === -1 || data[start] !== '[') {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < data.length; index++) {
const char = data[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === '[') {
depth += 1;
continue;
}
if (char === ']') {
depth -= 1;
if (depth === 0) {
return index + 1;
}
}
}
return null;
}
async function writeNotificationsFileAtomically(filePath: string, data: string): Promise<void> {
const dir = path.dirname(filePath);
const tempPath = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random()
.toString(16)
.slice(2)}.tmp`
);
try {
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(tempPath, data, 'utf8');
await fsp.rename(tempPath, filePath);
} catch (error) {
await fsp.rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}
@ -204,6 +293,7 @@ export class NotificationManager extends EventEmitter {
* before writing, preventing a race where save overwrites unloaded data. */
private initPromise: Promise<void> | null = null;
private notificationsPath = NOTIFICATIONS_PATH;
private saveChain: Promise<void> = Promise.resolve();
constructor(configManager?: ConfigManager) {
super();
@ -281,13 +371,18 @@ export class NotificationManager extends EventEmitter {
private async loadNotifications(): Promise<void> {
try {
const data = await fsp.readFile(this.notificationsPath, 'utf8');
const parsed = JSON.parse(data) as unknown;
const parsed = parseNotificationHistory(data);
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
if (!parsed) {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
return;
}
this.notifications = parsed.notifications;
if (parsed.recovered) {
logger.info('Recovered notifications from a corrupted history file, compacting storage');
this.saveNotifications();
}
} catch (error) {
// ENOENT is expected on first run — no file to load
@ -305,11 +400,11 @@ export class NotificationManager extends EventEmitter {
*/
private saveNotifications(): void {
const data = JSON.stringify(this.notifications, null, 2);
const dir = path.dirname(this.notificationsPath);
const notificationsPath = this.notificationsPath;
fsp
.mkdir(dir, { recursive: true })
.then(() => fsp.writeFile(this.notificationsPath, data, 'utf8'))
this.saveChain = this.saveChain
.catch(() => undefined)
.then(() => writeNotificationsFileAtomically(notificationsPath, data))
.catch((error) => {
logger.error('Error saving notifications:', error);
});

View file

@ -47,21 +47,23 @@ describe('NotificationManager storage migration', () => {
return tempHome;
}
function makeStoredNotification(id: string) {
return {
id,
title: id,
message: 'Copied',
timestamp: new Date().toISOString(),
type: 'error',
isRead: false,
createdAt: Date.now(),
};
}
it('copies legacy notification history to the new Agent Teams filename', async () => {
const home = useTempHome();
const legacyPath = path.join(home, '.claude', 'claude-devtools-notifications.json');
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const legacyNotifications = [
{
id: 'legacy-notification',
title: 'Legacy',
message: 'Copied',
timestamp: new Date().toISOString(),
type: 'error',
isRead: false,
createdAt: Date.now(),
},
];
const legacyNotifications = [makeStoredNotification('legacy-notification')];
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, JSON.stringify(legacyNotifications), 'utf8');
@ -82,17 +84,7 @@ describe('NotificationManager storage migration', () => {
const home = useTempHome();
const legacyPath = path.join(home, '.claude', 'claude-devtools-notifications.json');
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const currentNotifications = [
{
id: 'current-notification',
title: 'Current',
message: 'Kept',
timestamp: new Date().toISOString(),
type: 'error',
isRead: false,
createdAt: Date.now(),
},
];
const currentNotifications = [makeStoredNotification('current-notification')];
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
fs.writeFileSync(
legacyPath,
@ -117,17 +109,7 @@ describe('NotificationManager storage migration', () => {
const home = useTempHome();
const legacyPath = path.join(home, '.claude', 'claude-code-context-notifications.json');
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const legacyNotifications = [
{
id: 'pre-devtools-notification',
title: 'Old',
message: 'Copied',
timestamp: new Date().toISOString(),
type: 'error',
isRead: false,
createdAt: Date.now(),
},
];
const legacyNotifications = [makeStoredNotification('pre-devtools-notification')];
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, JSON.stringify(legacyNotifications), 'utf8');
@ -153,17 +135,7 @@ describe('NotificationManager storage migration', () => {
'claude-code-context-notifications.json'
);
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const legacyNotifications = [
{
id: 'older-valid-notification',
title: 'Old',
message: 'Copied',
timestamp: new Date().toISOString(),
type: 'error',
isRead: false,
createdAt: Date.now(),
},
];
const legacyNotifications = [makeStoredNotification('older-valid-notification')];
fs.mkdirSync(path.dirname(invalidNewerLegacyPath), { recursive: true });
fs.writeFileSync(invalidNewerLegacyPath, '', 'utf8');
fs.writeFileSync(validOlderLegacyPath, JSON.stringify(legacyNotifications), 'utf8');
@ -179,4 +151,61 @@ describe('NotificationManager storage migration', () => {
]);
expect(JSON.parse(fs.readFileSync(currentPath, 'utf8'))).toEqual(legacyNotifications);
});
it('recovers and compacts a notification history file with concatenated JSON', async () => {
const home = useTempHome();
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const currentNotifications = [makeStoredNotification('current-valid-notification')];
const trailingNotifications = [makeStoredNotification('trailing-garbage-notification')];
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
fs.writeFileSync(
currentPath,
`${JSON.stringify(currentNotifications, null, 2)}\n${JSON.stringify(trailingNotifications)}`,
'utf8'
);
const { NotificationManager } =
await import('../../../../src/main/services/infrastructure/NotificationManager');
const manager = new NotificationManager(createConfigManagerStub() as never);
await manager.initialize();
const result = await manager.getNotifications({ limit: 10 });
expect(result.notifications.map((notification) => notification.id)).toEqual([
'current-valid-notification',
]);
await vi.waitFor(() => {
expect(JSON.parse(fs.readFileSync(currentPath, 'utf8'))).toEqual(currentNotifications);
});
});
it('keeps notification history valid after rapid consecutive saves', async () => {
const home = useTempHome();
const currentPath = path.join(home, '.claude', 'agent-teams-notifications.json');
const { NotificationManager } =
await import('../../../../src/main/services/infrastructure/NotificationManager');
const manager = new NotificationManager(createConfigManagerStub() as never);
await manager.initialize();
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
manager.addTeamNotification({
teamEventType: 'user_inbox',
teamName: 'team-a',
teamDisplayName: 'Team A',
from: 'alice',
summary: `Message ${index}`,
body: `Message ${index}`,
dedupeKey: `rapid-save-${index}`,
suppressToast: true,
})
)
);
await vi.waitFor(() => {
const parsed = JSON.parse(fs.readFileSync(currentPath, 'utf8')) as Array<{ id: string }>;
expect(parsed).toHaveLength(20);
});
});
});

View file

@ -28,6 +28,8 @@ vi.mock('electron', () => ({
vi.mock('fs/promises', () => ({
readFile: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
writeFile: vi.fn().mockResolvedValue(undefined),
rename: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
}));