Merge pull request #3883 from mastertyko/fix/3762-notification-dedup-fallback
fix(gsd): dedupe repeated notifications
This commit is contained in:
commit
2858eb70f7
4 changed files with 62 additions and 2 deletions
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue