From c30184052a6796ccc9906c4724aca2566b1cf3db Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 15 Mar 2026 15:51:52 +0200 Subject: [PATCH] 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 --- src/main/index.ts | 42 +- src/main/ipc/handlers.ts | 7 +- src/main/ipc/teams.ts | 25 +- src/main/services/team/TeamAttachmentStore.ts | 5 +- src/main/services/team/TeamBackupService.ts | 1016 +++++++++++++++++ .../services/team/TeamProvisioningService.ts | 22 +- .../services/team/TeamTaskAttachmentStore.ts | 5 +- src/main/services/team/index.ts | 1 + src/main/utils/pathDecoder.ts | 40 + 9 files changed, 1139 insertions(+), 24 deletions(-) create mode 100644 src/main/services/team/TeamBackupService.ts diff --git a/src/main/index.ts b/src/main/index.ts index 79e64197..5d02890f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'); } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0a733e85..9d970ce7 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 0098cae6..f6e99d11 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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( diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index a6a93eb1..a91d9128 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -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 { diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts new file mode 100644 index 00000000..d9d8379b --- /dev/null +++ b/src/main/services/team/TeamBackupService.ts @@ -0,0 +1,1016 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { + getAppDataPath, + getBackupsBasePath, + getTasksBasePath, + getTeamsBasePath, +} from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('TeamBackupService'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface BackupManifest { + teamName: string; + identityId: string; + projectPath?: string; + displayName?: string; + status: 'active' | 'deleted_by_user'; + deletedByUserAt?: string; + firstBackupAt: string; + lastBackupAt: string; + fileStats: Record; +} + +interface BackupRegistry { + version: 1; + teams: Record; +} + +interface BackupRegistryEntry { + teamName: string; + identityId: string; + status: 'active' | 'deleted_by_user'; + deletedByUserAt?: string; + lastBackupAt: string; +} + +interface BackupFileDescriptor { + sourcePath: string; + relPath: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PERIODIC_INTERVAL_MS = 3 * 60 * 1000; +const TASK_DEBOUNCE_MS = 500; +const DELETED_RETENTION_DAYS = 30; +const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; + +const TEAM_ROOT_FILES = [ + 'config.json', + 'kanban-state.json', + 'sentMessages.json', + 'sent-cross-team.json', + 'members.meta.json', + 'comment-notification-journal.json', +]; + +// Subdirs under ~/.claude/teams/{teamName}/ +const TEAM_SUBDIRS = ['inboxes', 'review-decisions']; +// Subdirs under getAppDataPath() (our own storage, not in ~/.claude/) +const APP_DATA_SUBDIRS = ['attachments']; +const APP_DATA_DEEP_SUBDIRS = ['task-attachments']; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isEnoent(err: unknown): boolean { + return (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function isValidJson(content: string): boolean { + try { + JSON.parse(content); + return true; + } catch { + return false; + } +} + +function isValidConfig(content: string): boolean { + try { + const parsed = JSON.parse(content) as Record; + return typeof parsed.name === 'string' && parsed.name.trim() !== ''; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// TeamBackupService +// --------------------------------------------------------------------------- + +export class TeamBackupService { + private registry: BackupRegistry = { version: 1, teams: {} }; + private periodicTimer: ReturnType | null = null; + private taskDebounceTimers = new Map>(); + private teamMutex = new Map>(); + private initialized = false; + private isShuttingDown = false; + private backupGeneration = 0; + + // ── Public API ─────────────────────────────────────────────────────── + + async initialize(): Promise { + this.registry = await this.loadRegistry(); + await this.restoreIfNeeded(); + void this.pruneStaleBackups().catch((err: unknown) => + logger.warn(`[Backup] prune failed: ${String(err)}`) + ); + this.initialized = true; + this.periodicTimer = setInterval(() => { + void this.runPeriodicBackup().catch((err: unknown) => + logger.warn(`[Backup] periodic failed: ${String(err)}`) + ); + }, PERIODIC_INTERVAL_MS); + this.periodicTimer.unref(); + logger.info('[Backup] TeamBackupService initialized'); + } + + async backupTeam(teamName: string): Promise { + if (this.isShuttingDown || !this.initialized) return; + await this.withTeamMutex(teamName, () => this.doBackupTeam(teamName)); + } + + scheduleTaskBackup(teamName: string, taskFile: string): void { + if (this.isShuttingDown || !this.initialized) return; + const key = `${teamName}/${taskFile}`; + const existing = this.taskDebounceTimers.get(key); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + this.taskDebounceTimers.delete(key); + void this.backupTeam(teamName).catch(() => undefined); + }, TASK_DEBOUNCE_MS); + this.taskDebounceTimers.set(key, timer); + } + + runShutdownBackupSync(): void { + this.isShuttingDown = true; + this.backupGeneration++; + this.dispose(); + + for (const [teamName, entry] of Object.entries(this.registry.teams)) { + if (entry.status !== 'active') continue; + try { + this.doBackupTeamSync(teamName); + } catch (err: unknown) { + logger.warn(`[Backup] shutdown backup failed for ${teamName}: ${String(err)}`); + } + } + this.saveRegistrySync(); + } + + async markDeletedByUser(teamName: string): Promise { + const entry = this.registry.teams[teamName]; + if (entry) { + entry.status = 'deleted_by_user'; + entry.deletedByUserAt = nowIso(); + } + try { + const manifest = await this.loadManifest(teamName); + if (manifest) { + manifest.status = 'deleted_by_user'; + manifest.deletedByUserAt = nowIso(); + await this.saveManifest(teamName, manifest); + } + } catch (err: unknown) { + logger.warn(`[Backup] Failed to update manifest for ${teamName}: ${String(err)}`); + } + await this.saveRegistry(); + } + + async restoreIfNeeded(): Promise { + const restored: string[] = []; + for (const [teamName, entry] of Object.entries(this.registry.teams)) { + if (entry.status !== 'active') continue; + try { + const didRestore = await this.restoreTeam(teamName); + if (didRestore) restored.push(teamName); + } catch (err: unknown) { + logger.warn(`[Backup] restore failed for ${teamName}: ${String(err)}`); + } + } + return restored; + } + + async pruneStaleBackups(): Promise { + const cutoff = Date.now() - DELETED_RETENTION_DAYS * 24 * 60 * 60 * 1000; + let changed = false; + for (const [teamName, entry] of Object.entries(this.registry.teams)) { + if (entry.status !== 'deleted_by_user' || !entry.deletedByUserAt) continue; + const deletedAt = new Date(entry.deletedByUserAt).getTime(); + if (deletedAt > cutoff) continue; + const backupDir = this.getBackupDir(teamName); + await fs.promises.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + delete this.registry.teams[teamName]; + changed = true; + logger.info(`[Backup] Pruned stale backup for ${teamName}`); + } + if (changed) await this.saveRegistry(); + } + + dispose(): void { + if (this.periodicTimer) { + clearInterval(this.periodicTimer); + this.periodicTimer = null; + } + for (const timer of this.taskDebounceTimers.values()) { + clearTimeout(timer); + } + this.taskDebounceTimers.clear(); + } + + // ── Internal: backup ───────────────────────────────────────────────── + + private withTeamMutex(teamName: string, fn: () => Promise): Promise { + const prev = this.teamMutex.get(teamName) ?? Promise.resolve(); + const next = prev.then(fn, () => fn()); + this.teamMutex.set(teamName, next); + next.then( + () => { + if (this.teamMutex.get(teamName) === next) this.teamMutex.delete(teamName); + }, + () => { + if (this.teamMutex.get(teamName) === next) this.teamMutex.delete(teamName); + } + ); + return next; + } + + private async runPeriodicBackup(): Promise { + if (this.isShuttingDown || !this.initialized) return; + const teamNames = await this.discoverActiveTeams(); + for (const teamName of teamNames) { + if (this.isShuttingDown) return; + await this.withTeamMutex(teamName, () => this.doBackupTeam(teamName)); + } + } + + private async doBackupTeam(teamName: string): Promise { + const gen = this.backupGeneration; + if (!(await this.isConfigReady(teamName))) return; + + const { files: sourceFiles, hasErrors } = await this.enumerateTeamFilesWithErrors(teamName); + if (sourceFiles.length === 0) return; + + const backupDir = this.getBackupDir(teamName); + let manifest = await this.loadManifest(teamName); + const isNew = !manifest; + + if (!manifest) { + const identityId = crypto.randomUUID(); + manifest = { + teamName, + identityId, + status: 'active', + firstBackupAt: nowIso(), + lastBackupAt: nowIso(), + fileStats: {}, + }; + await this.ensureIdentityMarker(teamName, identityId); + } + + // Prune stale backup files (only if source enumeration was error-free) + if (!hasErrors) { + await this.pruneStaleBackupFiles(teamName, sourceFiles, backupDir, manifest); + } + + let anyChanged = false; + for (const descriptor of sourceFiles) { + if (this.backupGeneration !== gen) return; + const changed = await this.backupSingleFile(descriptor, backupDir, manifest); + if (changed) anyChanged = true; + } + + if (anyChanged || isNew) { + // Guard: if team was deleted while we were backing up, don't overwrite + const currentEntry = this.registry.teams[teamName]; + if (currentEntry?.status === 'deleted_by_user') return; + + manifest.lastBackupAt = nowIso(); + // Update informational fields from config + try { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const raw = await fs.promises.readFile(configPath, 'utf8').catch(() => ''); + if (raw && isValidConfig(raw)) { + const parsed = JSON.parse(raw) as Record; + manifest.displayName = typeof parsed.name === 'string' ? parsed.name : undefined; + manifest.projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined; + } + } catch { + // best-effort + } + + if (this.backupGeneration !== gen) return; + await this.saveManifest(teamName, manifest); + + // Update thin registry + this.registry.teams[teamName] = { + teamName, + identityId: manifest.identityId, + status: manifest.status, + deletedByUserAt: manifest.deletedByUserAt, + lastBackupAt: manifest.lastBackupAt, + }; + if (this.backupGeneration !== gen) return; + await this.saveRegistry(); + } + } + + private doBackupTeamSync(teamName: string): void { + const teamDir = path.join(getTeamsBasePath(), teamName); + const configPath = path.join(teamDir, 'config.json'); + try { + const raw = fs.readFileSync(configPath, 'utf8'); + if (!isValidConfig(raw)) return; + } catch { + return; + } + + const sourceFiles = this.enumerateTeamFilesSync(teamName); + if (sourceFiles.length === 0) return; + + const backupDir = this.getBackupDir(teamName); + let manifest: BackupManifest; + try { + const raw = fs.readFileSync(path.join(backupDir, 'manifest.json'), 'utf8'); + manifest = JSON.parse(raw) as BackupManifest; + } catch { + const identityId = crypto.randomUUID(); + manifest = { + teamName, + identityId, + status: 'active', + firstBackupAt: nowIso(), + lastBackupAt: nowIso(), + fileStats: {}, + }; + // Write identity marker to source config (sync, best-effort) + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const config = JSON.parse(raw) as Record; + config._backupIdentityId = identityId; + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); + } catch { + // best-effort + } + } + + for (const descriptor of sourceFiles) { + this.backupSingleFileSync(descriptor, backupDir, manifest); + } + + manifest.lastBackupAt = nowIso(); + this.saveManifestSync(teamName, manifest); + + this.registry.teams[teamName] = { + teamName, + identityId: manifest.identityId, + status: manifest.status, + deletedByUserAt: manifest.deletedByUserAt, + lastBackupAt: manifest.lastBackupAt, + }; + } + + private async backupSingleFile( + descriptor: BackupFileDescriptor, + backupDir: string, + manifest: BackupManifest + ): Promise { + try { + const stat = await fs.promises.stat(descriptor.sourcePath); + if (!stat.isFile()) return false; + if (stat.size > MAX_FILE_SIZE_BYTES) { + logger.info(`[Backup] Skipping oversized file (${stat.size} bytes): ${descriptor.relPath}`); + return false; + } + + const cached = manifest.fileStats[descriptor.relPath]; + if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) { + return false; // not dirty + } + + const destPath = path.join(backupDir, descriptor.relPath); + + if (descriptor.sourcePath.endsWith('.json')) { + const content = await fs.promises.readFile(descriptor.sourcePath, 'utf8'); + if (!isValidJson(content)) { + logger.warn(`[Backup] Skipping invalid JSON: ${descriptor.sourcePath}`); + return false; + } + await atomicWriteAsync(destPath, content); + } else { + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.copyFile(descriptor.sourcePath, destPath); + } + + manifest.fileStats[descriptor.relPath] = { mtime: stat.mtimeMs, size: stat.size }; + return true; + } catch (err: unknown) { + if (!isEnoent(err)) { + logger.warn(`[Backup] Failed to backup ${descriptor.relPath}: ${String(err)}`); + } + return false; + } + } + + private backupSingleFileSync( + descriptor: BackupFileDescriptor, + backupDir: string, + manifest: BackupManifest + ): void { + try { + const stat = fs.statSync(descriptor.sourcePath); + if (!stat.isFile()) return; + if (stat.size > MAX_FILE_SIZE_BYTES) return; // skip oversized silently during shutdown + + const cached = manifest.fileStats[descriptor.relPath]; + if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) return; + + const destPath = path.join(backupDir, descriptor.relPath); + + if (descriptor.sourcePath.endsWith('.json')) { + const content = fs.readFileSync(descriptor.sourcePath, 'utf8'); + if (!isValidJson(content)) return; + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, content, 'utf8'); + } else { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(descriptor.sourcePath, destPath); + } + + manifest.fileStats[descriptor.relPath] = { mtime: stat.mtimeMs, size: stat.size }; + } catch { + // best-effort during shutdown + } + } + + private async pruneStaleBackupFiles( + teamName: string, + sourceFiles: BackupFileDescriptor[], + backupDir: string, + manifest: BackupManifest + ): Promise { + const backupFiles = await this.enumerateBackupFiles(teamName); + const sourceRelPaths = new Set(sourceFiles.map((f) => f.relPath)); + + for (const backupRelPath of backupFiles) { + if (backupRelPath === 'manifest.json') continue; + if (!sourceRelPaths.has(backupRelPath)) { + await fs.promises.unlink(path.join(backupDir, backupRelPath)).catch(() => undefined); + delete manifest.fileStats[backupRelPath]; + } + } + } + + private async ensureIdentityMarker(teamName: string, identityId: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config = JSON.parse(raw) as Record; + if (config._backupIdentityId === identityId) return; + config._backupIdentityId = identityId; + await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + } catch { + // best-effort — config may not exist yet + } + } + + // ── Internal: restore ──────────────────────────────────────────────── + + private async restoreTeam(teamName: string): Promise { + const manifest = await this.loadManifest(teamName); + if (!manifest) return false; + + const backupConfigPath = path.join(this.getBackupDir(teamName), 'config.json'); + try { + const raw = await fs.promises.readFile(backupConfigPath, 'utf8'); + if (!isValidConfig(raw)) { + logger.warn(`[Backup] Backup config.json invalid for ${teamName}, skipping restore`); + return false; + } + } catch { + logger.warn(`[Backup] No backup config.json for ${teamName}, skipping restore`); + return false; + } + + // Check source config + const sourceConfigPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const sourceConfigResult = await this.readSourceConfig(sourceConfigPath); + + if (sourceConfigResult.status === 'valid') { + // Config exists and is valid — do partial restore + const identity = this.checkIdentityFromConfig(sourceConfigResult.parsed, manifest); + if (identity === 'mismatch') { + logger.info(`[Backup] Skip restore ${teamName}: different team with same name`); + return false; + } + if (identity === 'no_marker') { + logger.info(`[Backup] Skip restore ${teamName}: no _backupIdentityId in source config`); + return false; + } + const restoredCount = await this.restoreGenericPartial(teamName, manifest); + if (restoredCount > 0) { + logger.info(`[Backup] Partial restored ${teamName}: ${restoredCount} files`); + return true; + } + return false; + } + + // Config missing or corrupted — full restore + logger.info(`[Backup] Full restoring team ${teamName} (config ${sourceConfigResult.status})`); + const backupDir = this.getBackupDir(teamName); + const backupFiles = await this.enumerateBackupFiles(teamName); + let count = 0; + + // Restore config.json first + const configBackup = path.join(backupDir, 'config.json'); + const configDest = sourceConfigPath; + try { + await fs.promises.mkdir(path.dirname(configDest), { recursive: true }); + const content = await fs.promises.readFile(configBackup, 'utf8'); + await atomicWriteAsync(configDest, content); + count++; + } catch (err: unknown) { + logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`); + return false; + } + + // Restore remaining files + for (const relPath of backupFiles) { + if (relPath === 'config.json' || relPath === 'manifest.json') continue; + try { + const src = path.join(backupDir, relPath); + const dest = this.getSourcePathForRelPath(teamName, relPath); + // Don't overwrite newer files + try { + const destStat = await fs.promises.stat(dest); + const srcStat = await fs.promises.stat(src); + if (destStat.mtimeMs > srcStat.mtimeMs) { + logger.info(`[Backup] Skip restore ${teamName}/${relPath}: source file is newer`); + continue; + } + } catch { + // dest doesn't exist — ok to restore + } + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + const content = await fs.promises.readFile(src); + await fs.promises.writeFile(dest, content); + count++; + } catch { + // skip individual file errors + } + } + + logger.info(`[Backup] Restored team ${teamName} (${count} files)`); + return count > 0; + } + + private async restoreGenericPartial( + teamName: string, + manifest: BackupManifest + ): Promise { + const backupDir = this.getBackupDir(teamName); + const backupFiles = await this.enumerateBackupFiles(teamName); + let count = 0; + + for (const relPath of backupFiles) { + if (relPath === 'manifest.json') continue; + const dest = this.getSourcePathForRelPath(teamName, relPath); + + try { + // Check if source file is missing or corrupted + let needsRestore = false; + let skipReason = ''; + try { + if (dest.endsWith('.json')) { + const raw = await fs.promises.readFile(dest, 'utf8'); + if (!isValidJson(raw)) { + needsRestore = true; // corrupted JSON + } else { + skipReason = 'valid existing file'; + } + } else { + // Binary file — just check existence + await fs.promises.stat(dest); + skipReason = 'existing binary file'; + } + } catch { + needsRestore = true; // missing + } + + if (!needsRestore) { + logger.info(`[Backup] Skip restore ${teamName}/${relPath}: ${skipReason}`); + continue; + } + + const src = path.join(backupDir, relPath); + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + const content = await fs.promises.readFile(src); + await fs.promises.writeFile(dest, content); + count++; + logger.info(`[Backup] Partial restored ${teamName}/${relPath}`); + } catch { + // skip individual file errors + } + } + + void manifest; // fileStats not checked during restore — mtime comparison happens in full restore + return count; + } + + private checkIdentityFromConfig( + config: Record, + manifest: BackupManifest + ): 'match' | 'mismatch' | 'no_marker' { + const sourceId = config._backupIdentityId; + if (typeof sourceId !== 'string') return 'no_marker'; + return sourceId === manifest.identityId ? 'match' : 'mismatch'; + } + + private async readSourceConfig( + configPath: string + ): Promise< + | { status: 'valid'; parsed: Record } + | { status: 'missing' } + | { status: 'corrupted' } + > { + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + if (!isValidConfig(raw)) return { status: 'corrupted' }; + return { status: 'valid', parsed: JSON.parse(raw) as Record }; + } catch (err: unknown) { + if (isEnoent(err)) return { status: 'missing' }; + return { status: 'corrupted' }; + } + } + + // ── Internal: enumeration ──────────────────────────────────────────── + + private async enumerateTeamFilesWithErrors( + teamName: string + ): Promise<{ files: BackupFileDescriptor[]; hasErrors: boolean }> { + const files: BackupFileDescriptor[] = []; + let hasErrors = false; + const teamDir = path.join(getTeamsBasePath(), teamName); + const tasksDir = path.join(getTasksBasePath(), teamName); + + // Root files + for (const fileName of TEAM_ROOT_FILES) { + const sourcePath = path.join(teamDir, fileName); + try { + const stat = await fs.promises.stat(sourcePath); + if (stat.isFile()) files.push({ sourcePath, relPath: fileName }); + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + + // Flat subdirs under team dir (inboxes/, review-decisions/) + for (const subdir of TEAM_SUBDIRS) { + const dirPath = path.join(teamDir, subdir); + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + files.push({ + sourcePath: path.join(dirPath, entry.name), + relPath: `${subdir}/${entry.name}`, + }); + } + } + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + + // Flat subdirs under app data dir (attachments/) + const appDataDir = getAppDataPath(); + for (const subdir of APP_DATA_SUBDIRS) { + const dirPath = path.join(appDataDir, subdir, teamName); + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + files.push({ + sourcePath: path.join(dirPath, entry.name), + relPath: `${subdir}/${entry.name}`, + }); + } + } + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + + // Deep subdirs under app data dir (task-attachments/) + for (const subdir of APP_DATA_DEEP_SUBDIRS) { + const dirPath = path.join(appDataDir, subdir, teamName); + try { + const taskDirs = await fs.promises.readdir(dirPath, { withFileTypes: true }); + for (const taskDir of taskDirs) { + if (!taskDir.isDirectory()) continue; + const taskDirPath = path.join(dirPath, taskDir.name); + try { + const attachments = await fs.promises.readdir(taskDirPath, { withFileTypes: true }); + for (const att of attachments) { + if (att.isFile()) { + files.push({ + sourcePath: path.join(taskDirPath, att.name), + relPath: `${subdir}/${taskDir.name}/${att.name}`, + }); + } + } + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + } + + // Tasks (from separate dir) + try { + const entries = await fs.promises.readdir(tasksDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + files.push({ + sourcePath: path.join(tasksDir, entry.name), + relPath: `tasks/${entry.name}`, + }); + } + // Skip _internal/ directory + } + } catch (err: unknown) { + if (!isEnoent(err)) hasErrors = true; + } + + return { files, hasErrors }; + } + + private enumerateTeamFilesSync(teamName: string): BackupFileDescriptor[] { + const files: BackupFileDescriptor[] = []; + const teamDir = path.join(getTeamsBasePath(), teamName); + const tasksDir = path.join(getTasksBasePath(), teamName); + + for (const fileName of TEAM_ROOT_FILES) { + const sourcePath = path.join(teamDir, fileName); + try { + const stat = fs.statSync(sourcePath); + if (stat.isFile()) files.push({ sourcePath, relPath: fileName }); + } catch { + // skip + } + } + + for (const subdir of TEAM_SUBDIRS) { + try { + const entries = fs.readdirSync(path.join(teamDir, subdir), { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + files.push({ + sourcePath: path.join(teamDir, subdir, entry.name), + relPath: `${subdir}/${entry.name}`, + }); + } + } + } catch { + // skip + } + } + + // Flat subdirs under app data dir (attachments/) + const appDataDir = getAppDataPath(); + for (const subdir of APP_DATA_SUBDIRS) { + try { + const entries = fs.readdirSync(path.join(appDataDir, subdir, teamName), { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isFile()) { + files.push({ + sourcePath: path.join(appDataDir, subdir, teamName, entry.name), + relPath: `${subdir}/${entry.name}`, + }); + } + } + } catch { + // skip + } + } + + // Deep subdirs under app data dir (task-attachments/) + for (const subdir of APP_DATA_DEEP_SUBDIRS) { + try { + const taskDirs = fs.readdirSync(path.join(appDataDir, subdir, teamName), { + withFileTypes: true, + }); + for (const taskDir of taskDirs) { + if (!taskDir.isDirectory()) continue; + try { + const attachments = fs.readdirSync( + path.join(appDataDir, subdir, teamName, taskDir.name), + { withFileTypes: true } + ); + for (const att of attachments) { + if (att.isFile()) { + files.push({ + sourcePath: path.join(appDataDir, subdir, teamName, taskDir.name, att.name), + relPath: `${subdir}/${taskDir.name}/${att.name}`, + }); + } + } + } catch { + // skip + } + } + } catch { + // skip + } + } + + try { + const entries = fs.readdirSync(tasksDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + files.push({ + sourcePath: path.join(tasksDir, entry.name), + relPath: `tasks/${entry.name}`, + }); + } + } + } catch { + // skip + } + + return files; + } + + private async enumerateBackupFiles(teamName: string): Promise { + const backupDir = this.getBackupDir(teamName); + const results: string[] = []; + + const walk = async (dir: string, prefix: string): Promise => { + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isFile()) { + results.push(relPath); + } else if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), relPath); + } + } + } catch { + // skip + } + }; + + await walk(backupDir, ''); + return results; + } + + // ── Internal: registry + manifest ──────────────────────────────────── + + private getRegistryPath(): string { + return path.join(getBackupsBasePath(), 'registry.json'); + } + + private getBackupDir(teamName: string): string { + return path.join(getBackupsBasePath(), 'teams', teamName); + } + + private getSourcePathForRelPath(teamName: string, relPath: string): string { + if (relPath.startsWith('tasks/')) { + return path.join(getTasksBasePath(), teamName, relPath.slice('tasks/'.length)); + } + if (relPath.startsWith('attachments/')) { + return path.join(getAppDataPath(), 'attachments', teamName, relPath.slice('attachments/'.length)); + } + if (relPath.startsWith('task-attachments/')) { + return path.join(getAppDataPath(), 'task-attachments', teamName, relPath.slice('task-attachments/'.length)); + } + return path.join(getTeamsBasePath(), teamName, relPath); + } + + private async loadRegistry(): Promise { + try { + const raw = await fs.promises.readFile(this.getRegistryPath(), 'utf8'); + const parsed = JSON.parse(raw) as BackupRegistry; + if (parsed.version === 1 && typeof parsed.teams === 'object') { + return parsed; + } + } catch (err: unknown) { + if (!isEnoent(err)) { + logger.warn(`[Backup] Registry corrupted, rebuilding from disk`); + return this.rebuildRegistryFromDisk(); + } + } + return { version: 1, teams: {} }; + } + + private async saveRegistry(): Promise { + if (this.isShuttingDown) return; + await atomicWriteAsync(this.getRegistryPath(), JSON.stringify(this.registry, null, 2)); + } + + private saveRegistrySync(): void { + try { + const dir = path.dirname(this.getRegistryPath()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.getRegistryPath(), JSON.stringify(this.registry, null, 2), 'utf8'); + } catch (err: unknown) { + logger.warn(`[Backup] Failed to save registry sync: ${String(err)}`); + } + } + + private async rebuildRegistryFromDisk(): Promise { + const registry: BackupRegistry = { version: 1, teams: {} }; + const teamsDir = path.join(getBackupsBasePath(), 'teams'); + try { + const entries = await fs.promises.readdir(teamsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifest = await this.loadManifest(entry.name); + if (manifest) { + registry.teams[entry.name] = { + teamName: manifest.teamName, + identityId: manifest.identityId, + status: manifest.status, + deletedByUserAt: manifest.deletedByUserAt, + lastBackupAt: manifest.lastBackupAt, + }; + } + } + } catch { + // empty registry if backup dir doesn't exist + } + return registry; + } + + private async loadManifest(teamName: string): Promise { + try { + const raw = await fs.promises.readFile( + path.join(this.getBackupDir(teamName), 'manifest.json'), + 'utf8' + ); + return JSON.parse(raw) as BackupManifest; + } catch { + return null; + } + } + + private async saveManifest(teamName: string, manifest: BackupManifest): Promise { + if (this.isShuttingDown) return; + await atomicWriteAsync( + path.join(this.getBackupDir(teamName), 'manifest.json'), + JSON.stringify(manifest, null, 2) + ); + } + + private saveManifestSync(teamName: string, manifest: BackupManifest): void { + try { + const manifestPath = path.join(this.getBackupDir(teamName), 'manifest.json'); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + } catch { + // best-effort + } + } + + // ── Internal: validation ───────────────────────────────────────────── + + private async isConfigReady(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + return isValidConfig(raw); + } catch { + return false; + } + } + + private async discoverActiveTeams(): Promise { + const teamsDir = getTeamsBasePath(); + try { + const entries = await fs.promises.readdir(teamsDir, { withFileTypes: true }); + const teams: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const registryEntry = this.registry.teams[entry.name]; + if (registryEntry?.status === 'deleted_by_user') continue; + teams.push(entry.name); + } + return teams; + } catch { + return []; + } + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 316629f0..7977a032 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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'); + } } /** diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 5f5b372c..45d95a4f 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -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 { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 9c3c5c2c..7cfb94e8 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -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'; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 0556ba07..357bbe67 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -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'); + } +}