diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 8c60101..52053c4 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1945,6 +1945,8 @@ export class DownloadManager extends EventEmitter { private standalonePackageResults = new Set(); + private suppressedPackageResults = new Set(); + private successDigestResults = new Map(); private successDigestTimer: NodeJS.Timeout | null = null; @@ -3129,6 +3131,7 @@ export class DownloadManager extends EventEmitter { this.runContexts.clear(); this.activeRunContextId = null; this.standalonePackageResults.clear(); + this.suppressedPackageResults.clear(); this.successDigestResults.clear(); if (this.successDigestTimer) { clearTimeout(this.successDigestTimer); @@ -6337,9 +6340,12 @@ export class DownloadManager extends EventEmitter { public stop(options?: { parkForRestart?: boolean }): void { const parkForRestart = options?.parkForRestart === true; - const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; - const keepExtraction = this.settings.autoExtractWhenStopped; - const wasRunning = this.session.running; + const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; + const keepExtraction = this.settings.autoExtractWhenStopped; + const wasRunning = this.session.running; + const stoppedRunContext = wasRunning + ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) + : null; this.schedulerGeneration += 1; this.session.running = false; this.session.paused = false; @@ -6385,20 +6391,20 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); } } - if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { - const packageResults = [...this.runPackageIds] - .flatMap((packageId) => { - const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId))); + if (stoppedRunContext && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { + const packageResults = [...stoppedRunContext.packageGenerations] + .flatMap(([packageId, generation]) => { + const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); return result ? [result] : []; }); this.flushPackageSuccessDigest(); this.queueNotificationEvent(buildRunNotificationEvent(buildRunResult({ - id: uuidv4(), + id: stoppedRunContext.id, stopped: true, - startedAt: this.session.runStartedAt, + startedAt: stoppedRunContext.startedAt, completedAt: nowMs(), packages: packageResults, - totalPackages: this.runPackageIds.size + totalPackages: stoppedRunContext.packageGenerations.size }))); } this.runItemIds.clear(); @@ -8005,7 +8011,8 @@ export class DownloadManager extends EventEmitter { } private runPackagePostProcessing(packageId: string): Promise { - const existing = this.packagePostProcessTasks.get(packageId); + this.trackPackagePostProcessResult(packageId); + const existing = this.packagePostProcessTasks.get(packageId); if (existing) { this.hybridExtractRequeue.add(packageId); return existing; @@ -8267,9 +8274,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Entpacken - Ausstehend"; item.updatedAt = nowMs(); } - } - logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`)); + } + logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`); + this.trackPackagePostProcessResult(packageId); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`)); } continue; } @@ -8286,9 +8294,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Entpacken - Ausstehend"; item.updatedAt = nowMs(); } - } - logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPendingHybrid): ${compactErrorText(err)}`)); + } + logger.info(`Hybrid-Entpacken via Start ausgelöst: pkg=${pkg.name}, completed=${success}/${items.length}`); + this.trackPackagePostProcessResult(packageId); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPendingHybrid): ${compactErrorText(err)}`)); } } } @@ -11743,6 +11752,9 @@ export class DownloadManager extends EventEmitter { private beginActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { const context = this.createRunContext(packageIds, startedAt, false); + for (const [packageId, generation] of context.packageGenerations) { + this.suppressedPackageResults.delete(this.packageResultKey(packageId, generation)); + } this.activeRunContextId = context.id; return context; } @@ -11752,8 +11764,10 @@ export class DownloadManager extends EventEmitter { return; } const context = this.runContexts.get(this.activeRunContextId); - if (context && !context.packageGenerations.has(packageId)) { - context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + if (context) { + const generation = this.getPackageResultGeneration(packageId); + context.packageGenerations.set(packageId, generation); + this.suppressedPackageResults.delete(this.packageResultKey(packageId, generation)); } } @@ -11779,6 +11793,26 @@ export class DownloadManager extends EventEmitter { return context; } + private stopActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const active = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + const context = active || this.createRunContext(packageIds, startedAt, false); + for (const packageId of packageIds) { + if (!context.packageGenerations.has(packageId)) { + context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + } + for (const [packageId, generation] of context.packageGenerations) { + const key = this.packageResultKey(packageId, generation); + this.standalonePackageResults.delete(key); + this.suppressedPackageResults.add(key); + } + this.runContexts.delete(context.id); + if (this.activeRunContextId === context.id) { + this.activeRunContextId = null; + } + return context; + } + private isPackageResultTracked(packageId: string, generation: number): boolean { const key = this.packageResultKey(packageId, generation); if (this.standalonePackageResults.has(key)) { @@ -11793,7 +11827,22 @@ export class DownloadManager extends EventEmitter { } private trackStandalonePackageResult(packageId: string): void { - this.standalonePackageResults.add(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId))); + const key = this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)); + this.suppressedPackageResults.delete(key); + this.standalonePackageResults.add(key); + } + + private trackPackagePostProcessResult(packageId: string): void { + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + if (this.suppressedPackageResults.has(key)) { + return; + } + if (this.activeRunContextId) { + this.trackActiveRunPackage(packageId); + } else if (!this.isPackageResultTracked(packageId, generation)) { + this.standalonePackageResults.add(key); + } } private queueNotificationEvent(notification: NotificationEvent): void { @@ -12681,9 +12730,10 @@ export class DownloadManager extends EventEmitter { updatedAt: failedAt }); } - } - if (result.extracted > 0) { - const hybridController = new AbortController(); + } + if (result.extracted > 0) { + this.trackPackagePostProcessResult(packageId); + const hybridController = new AbortController(); let hybridSet = this.packageHybridPostProcessControllers.get(packageId); if (!hybridSet) { hybridSet = new Set(); @@ -13357,6 +13407,7 @@ export class DownloadManager extends EventEmitter { alreadyMarkedExtracted: boolean, extractedCount: number ): Promise { + this.trackPackagePostProcessResult(packageId); const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) .finally(() => { const tasks = this.packageDeferredPostProcessTasks.get(packageId); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 2b99c66..bbd9943 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -488,6 +488,140 @@ describe("authoritative run completion", () => { expect(history[0]).toMatchObject({ name: pkg.name, status: "completed", fileCount: 0 }); }); + it("updates an active run from generation one to generation two after resetting the same package", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(pkg.itemIds); + state.runPackageIds = new Set([pkg.id]); + state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); + state.beginActiveRunContext(state.runPackageIds, session.runStartedAt); + state.tryFinalizePackageResult(pkg.id); + const generationOne = state.finalizedPackageResults.get(`${pkg.id}:1`); + state.finalizedPackageResults.set(`${pkg.id}:1`, { + ...generationOne, + status: "failed", + successfulFiles: 0, + failedFiles: 9, + failurePhase: "download", + errorCategory: "stale" + }); + await flushNotifications(); + events.length = 0; + history.length = 0; + + vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined); + await manager.resetPackage(pkg.id); + expect(pkg.resultGeneration).toBe(2); + const item = session.items[pkg.itemIds[0]]; + item.status = "completed"; + item.downloadedBytes = 1_000; + item.totalBytes = 1_000; + item.progressPercent = 100; + item.fullStatus = "Fertig"; + pkg.status = "completed"; + state.runOutcomes.set(item.id, "completed"); + state.tryFinalizePackageResult(pkg.id); + state.finishRun(); + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(events.find((event) => event.type === "package_completed")?.id).toContain(":2:"); + const completedRun = events.find((event) => event.type === "run_completed"); + expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); + expect(history).toHaveLength(1); + expect(history[0].id).toBe(`hist-${pkg.id}-2`); + }); + + it("tracks a main postprocess task created by triggerPendingExtractions after start begins", async () => { + const { manager, session, events, history } = setup({ autoExtract: true }); + const pkg = addPackage(session); + const state = internal(manager); + pkg.status = "completed"; + session.items[pkg.itemIds[0]].fullStatus = "Fertig"; + let releasePostProcess = (): void => {}; + const postProcessGate = new Promise((resolve) => { + releasePostProcess = resolve; + }); + state.handlePackagePostProcessing = vi.fn(async () => postProcessGate); + + await manager.start(); + const postProcess = state.packagePostProcessTasks.get(pkg.id); + expect(postProcess).toBeDefined(); + releasePostProcess(); + await postProcess; + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(history).toHaveLength(1); + expect(history[0].name).toBe(pkg.name); + }); + + it("tracks a deferred-only startup task at creation without an active run", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + pkg.status = "completed"; + session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (1.0s)"; + state.executeDeferredPostExtraction = vi.fn(async () => undefined); + + await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, true, 1); + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(history).toHaveLength(1); + expect(history[0].name).toBe(pkg.name); + }); + + it("stops and removes the active run context before late package work can emit", async () => { + const { manager, session, events, history } = setup({ autoExtractWhenStopped: true }); + const packageA = addPackage(session, ["completed"], "stopped-package"); + const state = internal(manager); + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(packageA.itemIds); + state.runPackageIds = new Set([packageA.id]); + state.runOutcomes = new Map([[packageA.itemIds[0], "completed"]]); + const stoppedContext = state.beginActiveRunContext(state.runPackageIds, session.runStartedAt); + let releasePostProcess = (): void => {}; + const postProcessGate = new Promise((resolve) => { + releasePostProcess = resolve; + }); + state.handlePackagePostProcessing = vi.fn(async () => postProcessGate); + const latePostProcess = state.runPackagePostProcessing(packageA.id); + await Promise.resolve(); + + manager.stop(); + await flushNotifications(); + const stoppedEvent = events.find((event) => event.type === "run_stopped"); + expect(stoppedEvent?.id).toBe(`run:${stoppedContext.id}:run_stopped`); + expect(state.activeRunContextId).toBeNull(); + expect(state.runContexts.has(stoppedContext.id)).toBe(false); + + releasePostProcess(); + await latePostProcess; + await flushNotifications(); + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0); + expect(history).toHaveLength(0); + + const packageB = addPackage(session, ["completed"], "follow-up-package"); + session.running = true; + session.runStartedAt = Date.now() - 5_000; + state.runItemIds = new Set(packageB.itemIds); + state.runPackageIds = new Set([packageB.id]); + state.runOutcomes = new Map([[packageB.itemIds[0], "completed"]]); + state.beginActiveRunContext(state.runPackageIds, session.runStartedAt); + state.finishRun(); + await flushNotifications(); + + expect(events.filter((event) => event.type === "run_stopped")).toHaveLength(1); + expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1); + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(history.map((entry) => entry.name)).toEqual([packageB.name]); + }); + it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => { const { manager, session, events, history } = setup(); const packageA = addPackage(session, ["completed"], "package-a");