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:
parent
35da03c25f
commit
c30184052a
9 changed files with 1139 additions and 24 deletions
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1016
src/main/services/team/TeamBackupService.ts
Normal file
1016
src/main/services/team/TeamBackupService.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue