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 { GitDiffFallback } from '@main/services/team/GitDiffFallback';
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TeamBackupService } from '@main/services/team/TeamBackupService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
import { SchedulerService } from '@main/services/schedule/SchedulerService';
@ -338,6 +339,7 @@ let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
let schedulerService: SchedulerService;
let teamBackupService: TeamBackupService | null = null;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
@ -528,6 +530,16 @@ function wireFileWatcherEvents(context: ServiceContext): void {
`[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 {
// ignore
@ -671,6 +683,13 @@ function initializeServices(): void {
ptyTerminalService = new PtyTerminalService();
teamDataService = new TeamDataService();
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
const crossTeamConfigReader = new TeamConfigReader();
@ -783,7 +802,8 @@ function initializeServices(): void {
pluginInstallService,
mcpInstallService,
apiKeyService,
crossTeamService
crossTeamService,
teamBackupService ?? undefined
);
// Forward SSH state changes to renderer and HTTP SSE clients
@ -848,6 +868,16 @@ async function startHttpServer(
function shutdownServices(): void {
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
if (httpServer?.isRunning()) {
void httpServer.stop();
@ -880,13 +910,6 @@ function shutdownServices(): void {
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).
if (teamDataService) {
teamDataService.stopProcessHealthPolling();
@ -905,6 +928,9 @@ function shutdownServices(): void {
// Remove IPC handlers
removeIpcHandlers();
// Dispose backup service timers
teamBackupService?.dispose();
logger.info('Services shut down successfully');
}

View file

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

View file

@ -1,5 +1,6 @@
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting';
import {
TEAM_ADD_MEMBER,
@ -97,6 +98,7 @@ import type {
TeamMemberLogsFinder,
TeamProvisioningService,
} from '../services';
import type { TeamBackupService } from '../services/team/TeamBackupService';
import type {
AgentActionMode,
AttachmentFileData,
@ -193,6 +195,7 @@ let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
@ -206,12 +209,14 @@ export function initializeTeamHandlers(
service: TeamDataService,
provisioningService: TeamProvisioningService,
logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
teamMemberLogsFinder = logsFinder ?? null;
memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -521,9 +526,21 @@ async function handlePermanentlyDeleteTeam(
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('permanentlyDeleteTeam', () =>
getTeamDataService().permanentlyDeleteTeam(validated.value!)
);
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
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(

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 * as fs from 'fs';
import * as path from 'path';
@ -9,7 +9,6 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments';
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
export class TeamAttachmentStore {
@ -27,7 +26,7 @@ export class TeamAttachmentStore {
private getDir(teamName: string): string {
this.assertSafePathSegment('teamName', teamName);
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
return path.join(getAppDataPath(), 'attachments', teamName);
}
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.
*/
stopTeam(teamName: string): void {
stopTeam(teamName: string, signal?: NodeJS.Signals): void {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
return;
@ -3723,12 +3723,26 @@ export class TeamProvisioningService {
}
run.processKilled = true;
run.cancelRequested = true;
run.child?.stdin?.end();
killProcessTree(run.child);
// Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
// shutdown which deletes team files (config.json, inboxes/, tasks/).
killProcessTree(run.child, signal);
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress);
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 * as fs from 'fs';
import * as path from 'path';
@ -7,7 +7,6 @@ import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
const logger = createLogger('Service:TeamTaskAttachmentStore');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
export class TeamTaskAttachmentStore {
@ -30,7 +29,7 @@ export class TeamTaskAttachmentStore {
private getTaskDir(teamName: string, taskId: string): string {
this.assertSafePathSegment('teamName', teamName);
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 {

View file

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

View file

@ -368,3 +368,43 @@ export function getToolsBasePath(): string {
export function getSchedulesBasePath(): string {
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');
}
}