feat: add TeamBackupService for team data persistence

- Auto-backup team files (config, tasks, inboxes, attachments, etc.)
  to app's own storage (Electron userData) every 3 minutes
- Sync backup on app shutdown (after SIGKILL, before cleanup)
- Auto-restore missing/corrupted teams on startup with identity guard
- Move attachments to app-owned storage (outside ~/.claude/)
- Add stopAllTeams() with SIGKILL for reliable shutdown
- Clean up app data when permanently deleting teams
- Generation-based shutdown isolation prevents async/sync races
This commit is contained in:
iliya 2026-03-15 15:51:52 +02:00
parent 35da03c25f
commit c30184052a
9 changed files with 1139 additions and 24 deletions

View file

@ -23,6 +23,7 @@ import { ChangeExtractorService } from '@main/services/team/ChangeExtractorServi
import { FileContentResolver } from '@main/services/team/FileContentResolver'; import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TeamBackupService } from '@main/services/team/TeamBackupService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
import { SchedulerService } from '@main/services/schedule/SchedulerService'; import { SchedulerService } from '@main/services/schedule/SchedulerService';
@ -338,6 +339,7 @@ let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService; let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer; let httpServer: HttpServer;
let schedulerService: SchedulerService; let schedulerService: SchedulerService;
let teamBackupService: TeamBackupService | null = null;
// File watcher event cleanup functions // File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null; let fileChangeCleanup: (() => void) | null = null;
@ -528,6 +530,16 @@ function wireFileWatcherEvents(context: ServiceContext): void {
`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}` `[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}`
) )
); );
// Schedule debounced backup for changed task file
if (teamBackupService) {
teamBackupService.scheduleTaskBackup(teamName, detail);
}
}
// Backup on config changes (covers team ready, config updates)
if (row.type === 'config' && detail === 'config.json' && teamBackupService) {
void teamBackupService.backupTeam(teamName).catch(() => undefined);
} }
} catch { } catch {
// ignore // ignore
@ -671,6 +683,13 @@ function initializeServices(): void {
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
teamDataService = new TeamDataService(); teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService(); teamProvisioningService = new TeamProvisioningService();
teamBackupService = new TeamBackupService();
// Fire-and-forget: initializeServices() is sync, cannot await.
// Safe because TeamBackupService.initialized flag blocks all backup/restore
// operations until initialize() completes internally (restore → prune → set flag).
void teamBackupService.initialize().catch((error: unknown) =>
logger.warn(`[Init] TeamBackupService init failed: ${String(error)}`)
);
// Cross-team communication service // Cross-team communication service
const crossTeamConfigReader = new TeamConfigReader(); const crossTeamConfigReader = new TeamConfigReader();
@ -783,7 +802,8 @@ function initializeServices(): void {
pluginInstallService, pluginInstallService,
mcpInstallService, mcpInstallService,
apiKeyService, apiKeyService,
crossTeamService crossTeamService,
teamBackupService ?? undefined
); );
// Forward SSH state changes to renderer and HTTP SSE clients // Forward SSH state changes to renderer and HTTP SSE clients
@ -848,6 +868,16 @@ async function startHttpServer(
function shutdownServices(): void { function shutdownServices(): void {
logger.info('Shutting down services...'); logger.info('Shutting down services...');
// 1. Kill all team CLI processes via SIGKILL BEFORE anything else.
if (teamProvisioningService) {
teamProvisioningService.stopAllTeams();
}
// 2. Sync backup all team data (files are stable after SIGKILL).
if (teamBackupService) {
teamBackupService.runShutdownBackupSync();
}
// Stop HTTP server // Stop HTTP server
if (httpServer?.isRunning()) { if (httpServer?.isRunning()) {
void httpServer.stop(); void httpServer.stop();
@ -880,13 +910,6 @@ function shutdownServices(): void {
sshConnectionManager.dispose(); sshConnectionManager.dispose();
} }
// Stop all running team provisioning processes
if (teamProvisioningService) {
for (const teamName of teamProvisioningService.getAliveTeams()) {
teamProvisioningService.stopTeam(teamName);
}
}
// Stop background polling timers (prevents hanging shutdown). // Stop background polling timers (prevents hanging shutdown).
if (teamDataService) { if (teamDataService) {
teamDataService.stopProcessHealthPolling(); teamDataService.stopProcessHealthPolling();
@ -905,6 +928,9 @@ function shutdownServices(): void {
// Remove IPC handlers // Remove IPC handlers
removeIpcHandlers(); removeIpcHandlers();
// Dispose backup service timers
teamBackupService?.dispose();
logger.info('Services shut down successfully'); logger.info('Services shut down successfully');
} }

View file

@ -104,6 +104,7 @@ import type {
} from '../services'; } from '../services';
import type { HttpServer } from '../services/infrastructure/HttpServer'; import type { HttpServer } from '../services/infrastructure/HttpServer';
import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { TeamBackupService } from '../services/team/TeamBackupService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
@ -141,7 +142,8 @@ export function initializeIpcHandlers(
pluginInstaller?: PluginInstallService, pluginInstaller?: PluginInstallService,
mcpInstaller?: McpInstallService, mcpInstaller?: McpInstallService,
apiKeyService?: ApiKeyService, apiKeyService?: ApiKeyService,
crossTeamService?: CrossTeamService crossTeamService?: CrossTeamService,
teamBackupService?: TeamBackupService
): void { ): void {
// Initialize domain handlers with registry // Initialize domain handlers with registry
initializeProjectHandlers(registry); initializeProjectHandlers(registry);
@ -155,7 +157,8 @@ export function initializeIpcHandlers(
teamDataService, teamDataService,
teamProvisioningService, teamProvisioningService,
teamMemberLogsFinder, teamMemberLogsFinder,
memberStatsComputer memberStatsComputer,
teamBackupService
); );
initializeConfigHandlers({ initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,

View file

@ -1,5 +1,6 @@
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
import { getAppIconPath } from '@main/utils/appIcon'; import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting'; import { stripMarkdown } from '@main/utils/textFormatting';
import { import {
TEAM_ADD_MEMBER, TEAM_ADD_MEMBER,
@ -97,6 +98,7 @@ import type {
TeamMemberLogsFinder, TeamMemberLogsFinder,
TeamProvisioningService, TeamProvisioningService,
} from '../services'; } from '../services';
import type { TeamBackupService } from '../services/team/TeamBackupService';
import type { import type {
AgentActionMode, AgentActionMode,
AttachmentFileData, AttachmentFileData,
@ -193,6 +195,7 @@ let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null; let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null; let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
const attachmentStore = new TeamAttachmentStore(); const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore();
@ -206,12 +209,14 @@ export function initializeTeamHandlers(
service: TeamDataService, service: TeamDataService,
provisioningService: TeamProvisioningService, provisioningService: TeamProvisioningService,
logsFinder?: TeamMemberLogsFinder, logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService
): void { ): void {
teamDataService = service; teamDataService = service;
teamProvisioningService = provisioningService; teamProvisioningService = provisioningService;
teamMemberLogsFinder = logsFinder ?? null; teamMemberLogsFinder = logsFinder ?? null;
memberStatsComputer = statsComputer ?? null; memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
} }
export function registerTeamHandlers(ipcMain: IpcMain): void { export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -521,9 +526,21 @@ async function handlePermanentlyDeleteTeam(
if (!validated.valid) { if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' }; return { success: false, error: validated.error ?? 'Invalid teamName' };
} }
return wrapTeamHandler('permanentlyDeleteTeam', () => return wrapTeamHandler('permanentlyDeleteTeam', async () => {
getTeamDataService().permanentlyDeleteTeam(validated.value!) await getTeamDataService().permanentlyDeleteTeam(validated.value!);
); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
const appData = getAppDataPath();
await fs.promises
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
.catch(() => undefined);
await fs.promises
.rm(path.join(appData, 'task-attachments', validated.value!), { recursive: true, force: true })
.catch(() => undefined);
// Mark in backup registry AFTER successful deletion
if (teamBackupService) {
await teamBackupService.markDeletedByUser(validated.value!);
}
});
} }
async function handleUpdateConfig( async function handleUpdateConfig(

View file

@ -1,4 +1,4 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getAppDataPath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -9,7 +9,6 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore'); const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments';
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
export class TeamAttachmentStore { export class TeamAttachmentStore {
@ -27,7 +26,7 @@ export class TeamAttachmentStore {
private getDir(teamName: string): string { private getDir(teamName: string): string {
this.assertSafePathSegment('teamName', teamName); this.assertSafePathSegment('teamName', teamName);
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR); return path.join(getAppDataPath(), 'attachments', teamName);
} }
private getFilePath(teamName: string, messageId: string): string { private getFilePath(teamName: string, messageId: string): string {

File diff suppressed because it is too large Load diff

View file

@ -3708,7 +3708,7 @@ export class TeamProvisioningService {
/** /**
* Stop the running process for a team. No-op if team is not running. * Stop the running process for a team. No-op if team is not running.
*/ */
stopTeam(teamName: string): void { stopTeam(teamName: string, signal?: NodeJS.Signals): void {
const runId = this.activeByTeam.get(teamName); const runId = this.activeByTeam.get(teamName);
if (!runId) { if (!runId) {
return; return;
@ -3723,12 +3723,26 @@ export class TeamProvisioningService {
} }
run.processKilled = true; run.processKilled = true;
run.cancelRequested = true; run.cancelRequested = true;
run.child?.stdin?.end(); // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
killProcessTree(run.child); // shutdown which deletes team files (config.json, inboxes/, tasks/).
killProcessTree(run.child, signal);
const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress); run.onProgress(progress);
this.cleanupRun(run); this.cleanupRun(run);
logger.info(`[${teamName}] Process stopped by user`); logger.info(`[${teamName}] Process stopped (signal=${signal ?? 'SIGTERM'})`);
}
/**
* Stop all running team processes. Called during app shutdown.
* Uses SIGKILL (uncatchable) to guarantee instant death without cleanup.
*/
stopAllTeams(): void {
const alive = this.getAliveTeams();
if (alive.length === 0) return;
logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`);
for (const teamName of alive) {
this.stopTeam(teamName, 'SIGKILL');
}
} }
/** /**

View file

@ -1,4 +1,4 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getAppDataPath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -7,7 +7,6 @@ import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
const logger = createLogger('Service:TeamTaskAttachmentStore'); const logger = createLogger('Service:TeamTaskAttachmentStore');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
export class TeamTaskAttachmentStore { export class TeamTaskAttachmentStore {
@ -30,7 +29,7 @@ export class TeamTaskAttachmentStore {
private getTaskDir(teamName: string, taskId: string): string { private getTaskDir(teamName: string, taskId: string): string {
this.assertSafePathSegment('teamName', teamName); this.assertSafePathSegment('teamName', teamName);
this.assertSafePathSegment('taskId', taskId); this.assertSafePathSegment('taskId', taskId);
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId); return path.join(getAppDataPath(), 'task-attachments', teamName, taskId);
} }
private sanitizeStoredFilename(original: string): string { private sanitizeStoredFilename(original: string): string {

View file

@ -10,6 +10,7 @@ export { MemberStatsComputer } from './MemberStatsComputer';
export { ReviewApplierService } from './ReviewApplierService'; export { ReviewApplierService } from './ReviewApplierService';
export { TaskBoundaryParser } from './TaskBoundaryParser'; export { TaskBoundaryParser } from './TaskBoundaryParser';
export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamBackupService } from './TeamBackupService';
export { TeamConfigReader } from './TeamConfigReader'; export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService'; export { TeamDataService } from './TeamDataService';
export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxReader } from './TeamInboxReader';

View file

@ -368,3 +368,43 @@ export function getToolsBasePath(): string {
export function getSchedulesBasePath(): string { export function getSchedulesBasePath(): string {
return path.join(getClaudeBasePath(), 'claude-devtools-schedules'); return path.join(getClaudeBasePath(), 'claude-devtools-schedules');
} }
/**
* Get the backups directory path for the app's own storage.
* Uses Electron's app.getPath('userData') for cross-platform correctness:
* macOS: ~/Library/Application Support/Claude Agent Teams UI/backups
* Linux: ~/.config/Claude Agent Teams UI/backups
* Windows: C:\Users\Name\AppData\Roaming\Claude Agent Teams UI\backups
*/
export function getBackupsBasePath(): string {
return path.join(getAppDataBasePath(), 'backups');
}
/**
* Get the app's own data directory (attachments, task-attachments).
* Separate from ~/.claude/ so CLI cannot delete our data.
*/
export function getAppDataPath(): string {
return path.join(getAppDataBasePath(), 'data');
}
// ── App data root (Electron userData) ──
let appDataBasePathOverride: string | null = null;
export function setAppDataBasePath(p: string): void {
appDataBasePathOverride = p;
}
function getAppDataBasePath(): string {
if (appDataBasePathOverride) return appDataBasePathOverride;
// Fallback: resolve lazily from Electron app (safe after app.whenReady)
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron');
return app.getPath('userData');
} catch {
// Outside Electron (tests, CLI) — fall back to home dir
return path.join(getHomeDir(), '.claude-agent-teams-ui');
}
}