1268 lines
43 KiB
TypeScript
1268 lines
43 KiB
TypeScript
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';
|
|
|
|
import { TeamConfigReader } from './TeamConfigReader';
|
|
|
|
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<string, { mtime: number; size: number }>;
|
|
}
|
|
|
|
interface BackupRegistry {
|
|
version: 1;
|
|
teams: Record<string, BackupRegistryEntry>;
|
|
}
|
|
|
|
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',
|
|
'team.meta.json',
|
|
'launch-state.json',
|
|
'launch-summary.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'];
|
|
const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime', 'members'];
|
|
const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.';
|
|
const FILE_LOCK_SUFFIX = '.lock';
|
|
const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/;
|
|
const MEMBER_WORK_SYNC_DIR = '.member-work-sync';
|
|
const MEMBER_WORK_SYNC_JOURNAL_FILE = 'journal.jsonl';
|
|
// 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<string, unknown>;
|
|
return typeof parsed.name === 'string' && parsed.name.trim() !== '';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function shouldCollectRecursiveBackupFile(relPath: string): boolean {
|
|
const fileName = path.basename(relPath);
|
|
if (fileName.startsWith(ATOMIC_WRITE_TEMP_FILE_PREFIX)) {
|
|
return false;
|
|
}
|
|
if (fileName.endsWith(FILE_LOCK_SUFFIX)) {
|
|
return false;
|
|
}
|
|
// Runtime quarantine files are diagnostic snapshots of invalid JSON.
|
|
if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) {
|
|
return false;
|
|
}
|
|
const segments = relPath.split('/');
|
|
const workSyncIndex = segments.lastIndexOf(MEMBER_WORK_SYNC_DIR);
|
|
if (
|
|
segments[0] === 'members' &&
|
|
workSyncIndex >= 2 &&
|
|
segments[workSyncIndex + 1] === MEMBER_WORK_SYNC_JOURNAL_FILE
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function collectRecursiveFiles(
|
|
rootDir: string,
|
|
relPrefix: string
|
|
): Promise<BackupFileDescriptor[]> {
|
|
const files: BackupFileDescriptor[] = [];
|
|
const walk = async (dirPath: string, relDir: string): Promise<void> => {
|
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const sourcePath = path.join(dirPath, entry.name);
|
|
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
await walk(sourcePath, relPath);
|
|
continue;
|
|
}
|
|
if (entry.isFile()) {
|
|
const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath;
|
|
if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) {
|
|
continue;
|
|
}
|
|
files.push({
|
|
sourcePath,
|
|
relPath: descriptorRelPath,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
await walk(rootDir, '');
|
|
return files;
|
|
}
|
|
|
|
function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFileDescriptor[] {
|
|
const files: BackupFileDescriptor[] = [];
|
|
const walk = (dirPath: string, relDir: string): void => {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const sourcePath = path.join(dirPath, entry.name);
|
|
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
walk(sourcePath, relPath);
|
|
continue;
|
|
}
|
|
if (entry.isFile()) {
|
|
const descriptorRelPath = relPrefix ? `${relPrefix}/${relPath}` : relPath;
|
|
if (!shouldCollectRecursiveBackupFile(descriptorRelPath)) {
|
|
continue;
|
|
}
|
|
files.push({
|
|
sourcePath,
|
|
relPath: descriptorRelPath,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(rootDir, '');
|
|
return files;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TeamBackupService
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class TeamBackupService {
|
|
private registry: BackupRegistry = { version: 1, teams: {} };
|
|
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
|
private taskDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
private teamMutex = new Map<string, Promise<void>>();
|
|
private initialized = false;
|
|
private isShuttingDown = false;
|
|
private backupGeneration = 0;
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────
|
|
|
|
async initialize(): Promise<void> {
|
|
this.registry = await this.loadRegistry();
|
|
await this.reconcileResurrectedTeams();
|
|
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<void> {
|
|
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();
|
|
|
|
// Re-activate any resurrected teams before the backup loop.
|
|
// At shutdown, source files are still on disk (SIGKILL ran before stdin EOF).
|
|
this.reconcileResurrectedTeamsSync();
|
|
|
|
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<void> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<void>): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
// Reset stale manifest from a previously deleted team with the same name.
|
|
// The backup dir may already contain the new team's files (copied by FileWatcher),
|
|
// but the manifest was never updated because the deletion guard blocked it.
|
|
if (manifest?.status === 'deleted_by_user') {
|
|
manifest = null;
|
|
}
|
|
const isNew = !manifest;
|
|
|
|
if (!manifest) {
|
|
const identityId = crypto.randomUUID();
|
|
manifest = {
|
|
teamName,
|
|
identityId,
|
|
status: 'active',
|
|
firstBackupAt: nowIso(),
|
|
lastBackupAt: nowIso(),
|
|
fileStats: {},
|
|
};
|
|
await this.ensureIdentityMarker(teamName, identityId);
|
|
} else {
|
|
// Ensure identity marker is present — may have been lost during full restore
|
|
// (reconcile creates new identity in manifest, but restored config.json
|
|
// from backup doesn't have the marker yet)
|
|
await this.ensureIdentityMarker(teamName, manifest.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.
|
|
// For resurrected teams (isNew after manifest reset), allow only if
|
|
// the source config still exists — if it was rm -rf'd mid-backup,
|
|
// the user genuinely deleted the team and we must not re-activate it.
|
|
const currentEntry = this.registry.teams[teamName];
|
|
if (currentEntry?.status === 'deleted_by_user') {
|
|
if (!isNew || !(await this.isConfigReady(teamName))) 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<string, unknown>;
|
|
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<string, unknown>;
|
|
config._backupIdentityId = identityId;
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
// Reset stale manifest from a previously deleted team with the same name.
|
|
// Without this, manifest.status ('deleted_by_user') would be written back
|
|
// to the registry (line below), blocking future backups and restores.
|
|
if (manifest.status === 'deleted_by_user') {
|
|
const identityId = crypto.randomUUID();
|
|
manifest = {
|
|
teamName,
|
|
identityId,
|
|
status: 'active',
|
|
firstBackupAt: nowIso(),
|
|
lastBackupAt: nowIso(),
|
|
fileStats: {},
|
|
};
|
|
// Write identity marker (sync, best-effort)
|
|
try {
|
|
const configRaw = fs.readFileSync(configPath, 'utf8');
|
|
const config = JSON.parse(configRaw) as Record<string, unknown>;
|
|
config._backupIdentityId = identityId;
|
|
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<boolean> {
|
|
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?.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?.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<void> {
|
|
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<void> {
|
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
|
try {
|
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
|
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
if (config._backupIdentityId === identityId) return;
|
|
config._backupIdentityId = identityId;
|
|
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
|
TeamConfigReader.invalidateTeam(teamName);
|
|
} catch {
|
|
// best-effort — config may not exist yet
|
|
}
|
|
}
|
|
|
|
// ── Internal: restore ────────────────────────────────────────────────
|
|
|
|
private async restoreTeam(teamName: string): Promise<boolean> {
|
|
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);
|
|
TeamConfigReader.invalidateTeam(teamName);
|
|
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<number> {
|
|
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<string, unknown>,
|
|
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<string, unknown> }
|
|
| { 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<string, unknown> };
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
for (const subdir of TEAM_RECURSIVE_SUBDIRS) {
|
|
const dirPath = path.join(teamDir, subdir);
|
|
try {
|
|
files.push(...(await collectRecursiveFiles(dirPath, subdir)));
|
|
} 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
|
|
}
|
|
}
|
|
|
|
for (const subdir of TEAM_RECURSIVE_SUBDIRS) {
|
|
try {
|
|
files.push(...collectRecursiveFilesSync(path.join(teamDir, subdir), subdir));
|
|
} 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<string[]> {
|
|
const backupDir = this.getBackupDir(teamName);
|
|
const results: string[] = [];
|
|
|
|
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
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<BackupRegistry> {
|
|
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<void> {
|
|
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<BackupRegistry> {
|
|
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<BackupManifest | null> {
|
|
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<void> {
|
|
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<boolean> {
|
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
|
try {
|
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
|
return isValidConfig(raw);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private reconcileResurrectedTeamsSync(): void {
|
|
const teamsDir = getTeamsBasePath();
|
|
try {
|
|
const entries = fs.readdirSync(teamsDir, { withFileTypes: true });
|
|
for (const dirEntry of entries) {
|
|
if (!dirEntry.isDirectory()) continue;
|
|
const entry = this.registry.teams[dirEntry.name];
|
|
if (entry?.status !== 'deleted_by_user') continue;
|
|
const configPath = path.join(teamsDir, dirEntry.name, 'config.json');
|
|
try {
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
if (isValidConfig(raw)) {
|
|
logger.info(`[Backup] Shutdown reconcile: ${dirEntry.name} resurrected`);
|
|
entry.status = 'active';
|
|
delete entry.deletedByUserAt;
|
|
}
|
|
} catch {
|
|
// no config — truly deleted
|
|
}
|
|
}
|
|
} catch {
|
|
// no teams dir
|
|
}
|
|
// Registry will be saved by saveRegistrySync() at end of runShutdownBackupSync()
|
|
}
|
|
|
|
private async reconcileResurrectedTeams(): Promise<void> {
|
|
let changed = false;
|
|
for (const [teamName, entry] of Object.entries(this.registry.teams)) {
|
|
if (entry.status !== 'deleted_by_user') continue;
|
|
|
|
// Level 1: source config exists on disk — team is alive right now
|
|
if (await this.isConfigReady(teamName)) {
|
|
logger.info(`[Backup] Reconcile: team ${teamName} alive on disk`);
|
|
entry.status = 'active';
|
|
delete entry.deletedByUserAt;
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
// Level 2: source config gone, but backup data is NEWER than deletion.
|
|
// Catches: new team created → FileWatcher copied files to backup →
|
|
// force-kill → CLI cleaned up source → backup has the new team's data.
|
|
if (!entry.deletedByUserAt) continue;
|
|
const deletedAtMs = new Date(entry.deletedByUserAt).getTime();
|
|
const backupConfigPath = path.join(this.getBackupDir(teamName), 'config.json');
|
|
try {
|
|
const stat = await fs.promises.stat(backupConfigPath);
|
|
if (stat.mtimeMs > deletedAtMs + 60_000) {
|
|
logger.info(
|
|
`[Backup] Reconcile: team ${teamName} has post-deletion backup data, re-activating`
|
|
);
|
|
entry.status = 'active';
|
|
delete entry.deletedByUserAt;
|
|
// Reset stale manifest so restoreTeam() does full restore with new identity
|
|
const manifest = await this.loadManifest(teamName);
|
|
if (manifest?.status === 'deleted_by_user') {
|
|
manifest.identityId = crypto.randomUUID();
|
|
manifest.status = 'active';
|
|
delete manifest.deletedByUserAt;
|
|
manifest.fileStats = {};
|
|
await this.saveManifest(teamName, manifest);
|
|
}
|
|
changed = true;
|
|
}
|
|
} catch {
|
|
// no backup config — truly deleted, leave as is
|
|
}
|
|
}
|
|
if (changed) await this.saveRegistry();
|
|
}
|
|
|
|
private async discoverActiveTeams(): Promise<string[]> {
|
|
const teamsDir = getTeamsBasePath();
|
|
try {
|
|
const entries = await fs.promises.readdir(teamsDir, { withFileTypes: true });
|
|
const teams: string[] = [];
|
|
let registryChanged = false;
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const registryEntry = this.registry.teams[entry.name];
|
|
if (registryEntry?.status === 'deleted_by_user') {
|
|
// A valid config on disk means a new team was created with the same name.
|
|
// permanentlyDeleteTeam() removes files BEFORE markDeletedByUser(), so
|
|
// if config exists after marking deleted, it must be a new team.
|
|
if (await this.isConfigReady(entry.name)) {
|
|
logger.info(`[Backup] Team ${entry.name} resurrected (valid config on disk)`);
|
|
registryEntry.status = 'active';
|
|
delete registryEntry.deletedByUserAt;
|
|
registryChanged = true;
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
teams.push(entry.name);
|
|
}
|
|
if (registryChanged) await this.saveRegistry();
|
|
return teams;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|