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:
parent
60cf80f90a
commit
be38447c97
1 changed files with 134 additions and 3 deletions
|
|
@ -118,6 +118,7 @@ export class TeamBackupService {
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.registry = await this.loadRegistry();
|
this.registry = await this.loadRegistry();
|
||||||
|
await this.reconcileResurrectedTeams();
|
||||||
await this.restoreIfNeeded();
|
await this.restoreIfNeeded();
|
||||||
void this.pruneStaleBackups().catch((err: unknown) =>
|
void this.pruneStaleBackups().catch((err: unknown) =>
|
||||||
logger.warn(`[Backup] prune failed: ${String(err)}`)
|
logger.warn(`[Backup] prune failed: ${String(err)}`)
|
||||||
|
|
@ -154,6 +155,10 @@ export class TeamBackupService {
|
||||||
this.backupGeneration++;
|
this.backupGeneration++;
|
||||||
this.dispose();
|
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)) {
|
for (const [teamName, entry] of Object.entries(this.registry.teams)) {
|
||||||
if (entry.status !== 'active') continue;
|
if (entry.status !== 'active') continue;
|
||||||
try {
|
try {
|
||||||
|
|
@ -260,6 +265,12 @@ export class TeamBackupService {
|
||||||
|
|
||||||
const backupDir = this.getBackupDir(teamName);
|
const backupDir = this.getBackupDir(teamName);
|
||||||
let manifest = await this.loadManifest(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;
|
const isNew = !manifest;
|
||||||
|
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
|
|
@ -273,6 +284,11 @@ export class TeamBackupService {
|
||||||
fileStats: {},
|
fileStats: {},
|
||||||
};
|
};
|
||||||
await this.ensureIdentityMarker(teamName, identityId);
|
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)
|
// Prune stale backup files (only if source enumeration was error-free)
|
||||||
|
|
@ -288,9 +304,14 @@ export class TeamBackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anyChanged || isNew) {
|
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];
|
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();
|
manifest.lastBackupAt = nowIso();
|
||||||
// Update informational fields from config
|
// 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) {
|
for (const descriptor of sourceFiles) {
|
||||||
this.backupSingleFileSync(descriptor, backupDir, manifest);
|
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[]> {
|
private async discoverActiveTeams(): Promise<string[]> {
|
||||||
const teamsDir = getTeamsBasePath();
|
const teamsDir = getTeamsBasePath();
|
||||||
try {
|
try {
|
||||||
const entries = await fs.promises.readdir(teamsDir, { withFileTypes: true });
|
const entries = await fs.promises.readdir(teamsDir, { withFileTypes: true });
|
||||||
const teams: string[] = [];
|
const teams: string[] = [];
|
||||||
|
let registryChanged = false;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
const registryEntry = this.registry.teams[entry.name];
|
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);
|
teams.push(entry.name);
|
||||||
}
|
}
|
||||||
|
if (registryChanged) await this.saveRegistry();
|
||||||
return teams;
|
return teams;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue