Merge pull request #3883 from mastertyko/fix/3762-notification-dedup-fallback

fix(gsd): dedupe repeated notifications
This commit is contained in:
Jeremy McSpadden 2026-04-11 22:25:58 -05:00 committed by GitHub
commit 2858eb70f7
4 changed files with 62 additions and 2 deletions

View file

@ -26,12 +26,15 @@ export interface NotificationEntry {
const MAX_ENTRIES = 500;
const FILENAME = "notifications.jsonl";
const LOCKFILE = "notifications.lock";
const DEDUP_WINDOW_MS = 30_000;
const DEDUP_PRUNE_THRESHOLD = 200;
// ─── Module State ───────────────────────────────────────────────────────
let _basePath: string | null = null;
let _lineCount = 0; // Hint for rotation — not authoritative for public API
let _suppressCount = 0;
let _recentMessageTimestamps = new Map<string, number>();
// ─── Public API ─────────────────────────────────────────────────────────
@ -40,6 +43,9 @@ let _suppressCount = 0;
* project root. Seeds in-memory counters from the existing file on disk.
*/
export function initNotificationStore(basePath: string): void {
if (_basePath !== basePath) {
_recentMessageTimestamps.clear();
}
_basePath = basePath;
// Seed line count hint for rotation — public counters read from disk
_lineCount = _readEntriesFromDisk(basePath).length;
@ -56,12 +62,23 @@ export function appendNotification(
): void {
if (!_basePath) return;
if (_suppressCount > 0) return;
const persistedMessage = message.length > 500 ? message.slice(0, 500) + "…" : message;
const dedupKey = `${_basePath}:${severity}:${source}:${persistedMessage}`;
const now = Date.now();
const lastSeen = _recentMessageTimestamps.get(dedupKey);
if (lastSeen !== undefined && now - lastSeen < DEDUP_WINDOW_MS) return;
_recentMessageTimestamps.set(dedupKey, now);
if (_recentMessageTimestamps.size > DEDUP_PRUNE_THRESHOLD) {
for (const [key, ts] of _recentMessageTimestamps) {
if (now - ts > DEDUP_WINDOW_MS) _recentMessageTimestamps.delete(key);
}
}
const entry: NotificationEntry = {
id: randomUUID(),
ts: new Date().toISOString(),
severity,
message: message.length > 500 ? message.slice(0, 500) + "…" : message,
message: persistedMessage,
source,
read: false,
};
@ -181,6 +198,7 @@ export function _resetNotificationStore(): void {
_basePath = null;
_lineCount = 0;
_suppressCount = 0;
_recentMessageTimestamps = new Map();
}
// ─── Internal ───────────────────────────────────────────────────────────

View file

@ -25,7 +25,7 @@ export function buildNotificationWidgetLines(): string[] {
? latest.message.slice(0, msgMax - 1) + "…"
: latest.message;
return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} to view)`];
return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} or /gsd notifications)`];
}
// ─── Widget init ────────────────────────────────────────────────────────

View file

@ -187,6 +187,23 @@ describe("notification-store", () => {
assert.ok(!entries.some((e) => e.message === "suppressed"));
});
test("appendNotification suppresses identical messages within the dedup window", (t) => {
initNotificationStore(tmp);
let now = 1_000;
t.mock.method(Date, "now", () => now);
appendNotification("same", "warning");
now += 1_000;
appendNotification("same", "warning");
now += 31_000;
appendNotification("same", "warning");
const entries = readNotifications();
assert.equal(entries.length, 2);
assert.equal(entries[0].message, "same");
assert.equal(entries[1].message, "same");
});
test("suppressPersistence is ref-counted", () => {
initNotificationStore(tmp);
suppressPersistence();

View file

@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { initNotificationStore, appendNotification, _resetNotificationStore } from "../notification-store.js";
import { buildNotificationWidgetLines } from "../notification-widget.js";
test("buildNotificationWidgetLines includes slash-command fallback for unread notifications", () => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-notification-widget-"));
try {
mkdirSync(join(tmp, ".gsd"), { recursive: true });
_resetNotificationStore();
initNotificationStore(tmp);
appendNotification("Need attention", "warning");
const lines = buildNotificationWidgetLines();
assert.equal(lines.length, 1);
assert.match(lines[0]!, /\/gsd notifications/);
} finally {
_resetNotificationStore();
rmSync(tmp, { recursive: true, force: true });
}
});