fix(backup): detect resurrected teams with reused names

When a team was permanently deleted and a new team created with the
same name, the backup service still treated it as deleted. The old
registry entry blocked periodic backups, FileWatcher-triggered backups,
shutdown backups, and auto-restore on startup.

- discoverActiveTeams: re-activate deleted entries when valid config exists
- doBackupTeam: reset stale deleted manifest, always ensure identity marker
- doBackupTeam guard: allow resurrected teams through with config check
- doBackupTeamSync: reset deleted manifest to prevent status writeback
- reconcileResurrectedTeams: detect post-deletion backup data on init
- reconcileResurrectedTeamsSync: sync reconciliation before shutdown backup
This commit is contained in:
iliya 2026-03-22 13:35:18 +02:00
parent 60cf80f90a
commit be38447c97

View file

@ -118,6 +118,7 @@ export class TeamBackupService {
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)}`)
@ -154,6 +155,10 @@ export class TeamBackupService {
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 {
@ -260,6 +265,12 @@ export class TeamBackupService {
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) {
@ -273,6 +284,11 @@ export class TeamBackupService {
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)
@ -288,9 +304,14 @@ export class TeamBackupService {
}
if (anyChanged || isNew) {
// Guard: if team was deleted while we were backing up, don't overwrite
// 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') return;
if (currentEntry?.status === 'deleted_by_user') {
if (!isNew || !(await this.isConfigReady(teamName))) return;
}
manifest.lastBackupAt = nowIso();
// Update informational fields from config
@ -363,6 +384,30 @@ export class TeamBackupService {
}
}
// 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);
}
@ -1005,17 +1050,103 @@ export class TeamBackupService {
}
}
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') continue;
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 [];