feat(gsd): persistent notification panel with TUI overlay, widget, and web API
Notifications from ctx.ui.notify() and workflow-logger now persist to .gsd/notifications.jsonl instead of evaporating as transient toasts. - notification-store: JSONL persistence with 500-entry rotation, atomic temp+rename rewrites, ref-counted suppress API, disk-synced counters - notify-interceptor: WeakSet-guarded monkey-patch on ctx.ui.notify installed at session_start and session_switch - notification-widget: always-on belowEditor strip showing unread count - notification-overlay: scrollable Ctrl+Alt+N panel with severity filter - /gsd notifications command: clear, tail, filter subcommands - workflow-logger: warnings now also persist to notification store - web API: GET/DELETE /api/notifications with ?countOnly support - 16 unit tests covering store, suppress, project isolation, resync
This commit is contained in:
parent
f6a1549edd
commit
8078755e4b
14 changed files with 1290 additions and 1 deletions
34
src/resources/extensions/gsd/bootstrap/notify-interceptor.ts
Normal file
34
src/resources/extensions/gsd/bootstrap/notify-interceptor.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// GSD Extension — Notify Interceptor
|
||||
// Wraps ctx.ui.notify() in-place to persist every notification through the
|
||||
// notification store. Uses a WeakSet to prevent double-wrapping and handle
|
||||
// UI context replacement on /reload gracefully.
|
||||
|
||||
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
||||
|
||||
import { appendNotification, type NotifySeverity } from "../notification-store.js";
|
||||
|
||||
// Track which ui context objects have been wrapped to prevent double-install.
|
||||
// WeakSet allows GC to collect replaced uiContext instances after /reload.
|
||||
const _wrappedContexts = new WeakSet<object>();
|
||||
|
||||
/**
|
||||
* Install the notify interceptor on a context's UI object.
|
||||
* Mutates ctx.ui.notify in place — the original is called after persistence.
|
||||
* Safe to call multiple times; no-ops if already installed on the same ui object.
|
||||
*/
|
||||
export function installNotifyInterceptor(ctx: ExtensionContext): void {
|
||||
if (_wrappedContexts.has(ctx.ui)) return;
|
||||
|
||||
const originalNotify = ctx.ui.notify.bind(ctx.ui);
|
||||
|
||||
(ctx.ui as any).notify = (message: string, type?: "info" | "warning" | "error" | "success"): void => {
|
||||
try {
|
||||
appendNotification(message, (type ?? "info") as NotifySeverity, "notify");
|
||||
} catch {
|
||||
// Non-fatal — never let persistence break the UI
|
||||
}
|
||||
originalNotify(message, type);
|
||||
};
|
||||
|
||||
_wrappedContexts.add(ctx.ui);
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ import { resetAskUserQuestionsCache } from "../../ask-user-questions.js";
|
|||
import { recordToolCall as safetyRecordToolCall, recordToolResult as safetyRecordToolResult } from "../safety/evidence-collector.js";
|
||||
import { classifyCommand } from "../safety/destructive-guard.js";
|
||||
import { logWarning as safetyLogWarning } from "../workflow-logger.js";
|
||||
import { installNotifyInterceptor } from "./notify-interceptor.js";
|
||||
import { initNotificationStore } from "../notification-store.js";
|
||||
import { initNotificationWidget } from "../notification-widget.js";
|
||||
|
||||
// Skip the welcome screen on the very first session_start — cli.ts already
|
||||
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
|
||||
|
|
@ -33,6 +36,9 @@ async function syncServiceTierStatus(ctx: ExtensionContext): Promise<void> {
|
|||
|
||||
export function registerHooks(pi: ExtensionAPI): void {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
initNotificationStore(process.cwd());
|
||||
installNotifyInterceptor(ctx);
|
||||
initNotificationWidget(ctx);
|
||||
resetWriteGateState();
|
||||
resetToolCallLoopGuard();
|
||||
resetAskUserQuestionsCache();
|
||||
|
|
@ -70,6 +76,8 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
initNotificationStore(process.cwd());
|
||||
installNotifyInterceptor(ctx);
|
||||
resetWriteGateState();
|
||||
resetToolCallLoopGuard();
|
||||
resetAskUserQuestionsCache();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
|
|||
import { Key } from "@gsd/pi-tui";
|
||||
|
||||
import { GSDDashboardOverlay } from "../dashboard-overlay.js";
|
||||
import { GSDNotificationOverlay } from "../notification-overlay.js";
|
||||
import { ParallelMonitorOverlay } from "../parallel-monitor-overlay.js";
|
||||
import { shortcutDesc } from "../../shared/mod.js";
|
||||
|
||||
|
|
@ -31,6 +32,24 @@ export function registerShortcuts(pi: ExtensionAPI): void {
|
|||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(Key.ctrlAlt("n"), {
|
||||
description: shortcutDesc("Open notification history", "/gsd notifications"),
|
||||
handler: async (ctx) => {
|
||||
await ctx.ui.custom<void>(
|
||||
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()),
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
width: "80%",
|
||||
minWidth: 60,
|
||||
maxHeight: "88%",
|
||||
anchor: "center",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(Key.ctrlAlt("p"), {
|
||||
description: shortcutDesc("Open parallel worker monitor", "/gsd parallel watch"),
|
||||
handler: async (ctx) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
|
|||
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
|
||||
|
||||
export const GSD_COMMAND_DESCRIPTION =
|
||||
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase";
|
||||
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications";
|
||||
|
||||
export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
|
||||
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
||||
|
|
@ -48,6 +48,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
|
|||
{ cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" },
|
||||
{ cmd: "run-hook", desc: "Manually trigger a specific hook" },
|
||||
{ cmd: "skill-health", desc: "Skill lifecycle dashboard" },
|
||||
{ cmd: "notifications", desc: "View, filter, and clear persistent notification history" },
|
||||
{ cmd: "doctor", desc: "Runtime health checks with auto-fix" },
|
||||
{ cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" },
|
||||
{ cmd: "forensics", desc: "Examine execution logs" },
|
||||
|
|
@ -110,6 +111,11 @@ const NESTED_COMPLETIONS: CompletionMap = {
|
|||
{ cmd: "keys", desc: "Manage API keys" },
|
||||
{ cmd: "prefs", desc: "Configure global preferences" },
|
||||
],
|
||||
notifications: [
|
||||
{ cmd: "clear", desc: "Clear all notifications" },
|
||||
{ cmd: "tail", desc: "Show last N notifications (default: 20)" },
|
||||
{ cmd: "filter", desc: "Filter by severity (error|warning|info|success)" },
|
||||
],
|
||||
logs: [
|
||||
{ cmd: "debug", desc: "List or view debug log files" },
|
||||
{ cmd: "tail", desc: "Show last N activity log summaries" },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function showHelp(ctx: ExtensionCommandContext): void {
|
|||
" /gsd queue Show queued/dispatched units and execution order",
|
||||
" /gsd history View execution history [--cost] [--phase] [--model] [N]",
|
||||
" /gsd changelog Show categorized release notes [version]",
|
||||
" /gsd notifications View persistent notification history [clear|tail|filter] (Ctrl+Alt+N)",
|
||||
"",
|
||||
"COURSE CORRECTION",
|
||||
" /gsd steer <desc> Apply user override to active work",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
// GSD Extension — /gsd notifications Command Handler
|
||||
// View, filter, and clear the persistent notification history.
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
||||
|
||||
import {
|
||||
readNotifications,
|
||||
clearNotifications,
|
||||
getUnreadCount,
|
||||
suppressPersistence,
|
||||
unsuppressPersistence,
|
||||
type NotifySeverity,
|
||||
} from "../../notification-store.js";
|
||||
import { GSDNotificationOverlay } from "../../notification-overlay.js";
|
||||
|
||||
function severityIcon(severity: NotifySeverity): string {
|
||||
switch (severity) {
|
||||
case "error": return "✗";
|
||||
case "warning": return "⚠";
|
||||
case "success": return "✓";
|
||||
case "info":
|
||||
default: return "●";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString("en-US", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return ts.slice(0, 19);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleNotificationsCommand(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
): Promise<boolean> {
|
||||
// /gsd notifications clear
|
||||
if (args === "clear") {
|
||||
clearNotifications();
|
||||
// Suppress persistence so the confirmation toast doesn't re-populate the store
|
||||
suppressPersistence();
|
||||
try {
|
||||
ctx.ui.notify("All notifications cleared.", "success");
|
||||
} finally {
|
||||
unsuppressPersistence();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// /gsd notifications tail [N]
|
||||
if (args === "tail" || args.startsWith("tail ")) {
|
||||
const countStr = args.replace(/^tail\s*/, "").trim();
|
||||
const count = countStr ? parseInt(countStr, 10) : 20;
|
||||
const n = isNaN(count) || count < 1 ? 20 : Math.min(count, 100);
|
||||
const entries = readNotifications().slice(0, n);
|
||||
|
||||
if (entries.length === 0) {
|
||||
ctx.ui.notify("No notifications.", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
const lines = entries.map((e) =>
|
||||
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
|
||||
);
|
||||
ctx.ui.notify(`Last ${entries.length} notification(s):\n${lines.join("\n")}`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
// /gsd notifications filter <severity>
|
||||
if (args.startsWith("filter ")) {
|
||||
const severity = args.replace(/^filter\s+/, "").trim().toLowerCase();
|
||||
if (!["error", "warning", "info", "success"].includes(severity)) {
|
||||
ctx.ui.notify("Usage: /gsd notifications filter <error|warning|info|success>", "warning");
|
||||
return true;
|
||||
}
|
||||
const entries = readNotifications().filter((e) => e.severity === severity);
|
||||
|
||||
if (entries.length === 0) {
|
||||
ctx.ui.notify(`No ${severity} notifications.`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
const lines = entries.slice(0, 20).map((e) =>
|
||||
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
|
||||
);
|
||||
const suffix = entries.length > 20 ? `\n... and ${entries.length - 20} more` : "";
|
||||
ctx.ui.notify(`${severity} notifications (${entries.length}):\n${lines.join("\n")}${suffix}`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
// /gsd notifications (no args) — open overlay in TUI, or print summary
|
||||
if (args === "" || args === "status") {
|
||||
// Try overlay first (TUI mode)
|
||||
if (ctx.hasUI) {
|
||||
try {
|
||||
await ctx.ui.custom<void>(
|
||||
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()),
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
width: "80%",
|
||||
minWidth: 60,
|
||||
maxHeight: "88%",
|
||||
anchor: "center",
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to text output if overlay fails
|
||||
}
|
||||
}
|
||||
|
||||
// Text fallback (RPC/headless mode)
|
||||
const unread = getUnreadCount();
|
||||
const entries = readNotifications().slice(0, 10);
|
||||
if (entries.length === 0) {
|
||||
ctx.ui.notify("No notifications.", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
const lines = entries.map((e) =>
|
||||
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
|
||||
);
|
||||
const header = unread > 0 ? `${unread} unread — ` : "";
|
||||
ctx.ui.notify(`${header}Recent notifications:\n${lines.join("\n")}`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown subcommand
|
||||
ctx.ui.notify(
|
||||
"Usage: /gsd notifications [clear|tail [N]|filter <severity>]",
|
||||
"warning",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -178,6 +178,11 @@ Examples:
|
|||
await dispatchDirectPhase(ctx, pi, phase, projectRoot());
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "notifications" || trimmed.startsWith("notifications ")) {
|
||||
const { handleNotificationsCommand } = await import("./notifications-handler.js");
|
||||
await handleNotificationsCommand(trimmed.replace(/^notifications\s*/, "").trim(), ctx, pi);
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "inspect") {
|
||||
await handleInspect(ctx);
|
||||
return true;
|
||||
|
|
|
|||
267
src/resources/extensions/gsd/notification-overlay.ts
Normal file
267
src/resources/extensions/gsd/notification-overlay.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// GSD Extension — Notification History Overlay
|
||||
// Scrollable panel showing all persisted notifications with severity filtering.
|
||||
// Toggled with Ctrl+Alt+N or opened from /gsd notifications.
|
||||
|
||||
import type { Theme } from "@gsd/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
|
||||
|
||||
import {
|
||||
readNotifications,
|
||||
markAllRead,
|
||||
clearNotifications,
|
||||
getUnreadCount,
|
||||
type NotificationEntry,
|
||||
type NotifySeverity,
|
||||
} from "./notification-store.js";
|
||||
import { padRight, centerLine, joinColumns, formatDuration } from "../shared/mod.js";
|
||||
|
||||
type FilterMode = "all" | "error" | "warning" | "info";
|
||||
const FILTER_CYCLE: FilterMode[] = ["all", "error", "warning", "info"];
|
||||
|
||||
function severityIcon(severity: NotifySeverity): string {
|
||||
switch (severity) {
|
||||
case "error": return "✗";
|
||||
case "warning": return "⚠";
|
||||
case "success": return "✓";
|
||||
case "info":
|
||||
default: return "●";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = Date.now();
|
||||
const diffMs = now - d.getTime();
|
||||
if (diffMs < 60_000) return "just now";
|
||||
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
|
||||
return `${Math.floor(diffMs / 86400_000)}d ago`;
|
||||
} catch {
|
||||
return ts.slice(11, 19); // fallback: HH:MM:SS
|
||||
}
|
||||
}
|
||||
|
||||
export class GSDNotificationOverlay {
|
||||
private tui: { requestRender: () => void };
|
||||
private theme: Theme;
|
||||
private onClose: () => void;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
private scrollOffset = 0;
|
||||
private filterIndex = 0;
|
||||
private entries: NotificationEntry[] = [];
|
||||
private refreshTimer: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
private resizeHandler: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
tui: { requestRender: () => void },
|
||||
theme: Theme,
|
||||
onClose: () => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.theme = theme;
|
||||
this.onClose = onClose;
|
||||
|
||||
// Mark all as read on open
|
||||
markAllRead();
|
||||
this.entries = readNotifications();
|
||||
|
||||
// Resize handler
|
||||
this.resizeHandler = () => {
|
||||
if (this.disposed) return;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
};
|
||||
process.stdout.on("resize", this.resizeHandler);
|
||||
|
||||
// Refresh every 3s for new notifications
|
||||
this.refreshTimer = setInterval(() => {
|
||||
if (this.disposed) return;
|
||||
const fresh = readNotifications();
|
||||
if (fresh.length !== this.entries.length) {
|
||||
this.entries = fresh;
|
||||
markAllRead();
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private get filter(): FilterMode {
|
||||
return FILTER_CYCLE[this.filterIndex]!;
|
||||
}
|
||||
|
||||
private get filteredEntries(): NotificationEntry[] {
|
||||
if (this.filter === "all") return this.entries;
|
||||
return this.entries.filter((e) => e.severity === this.filter);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("n"))) {
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll
|
||||
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
||||
this.scrollOffset++;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
||||
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (data === "g") {
|
||||
this.scrollOffset = 0;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (data === "G") {
|
||||
this.scrollOffset = 999;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter cycle
|
||||
if (data === "f") {
|
||||
this.filterIndex = (this.filterIndex + 1) % FILTER_CYCLE.length;
|
||||
this.scrollOffset = 0;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all
|
||||
if (data === "c") {
|
||||
clearNotifications();
|
||||
this.entries = [];
|
||||
this.scrollOffset = 0;
|
||||
this.invalidate();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const content = this.buildContentLines(width);
|
||||
const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24);
|
||||
const chromeHeight = 2; // top + bottom border
|
||||
const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
|
||||
const maxScroll = Math.max(0, content.length - visibleContentRows);
|
||||
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
||||
const visibleContent = content.slice(this.scrollOffset, this.scrollOffset + visibleContentRows);
|
||||
|
||||
const lines = this.wrapInBox(visibleContent, width);
|
||||
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedLines = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
clearInterval(this.refreshTimer);
|
||||
if (this.resizeHandler) {
|
||||
process.stdout.removeListener("resize", this.resizeHandler);
|
||||
this.resizeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private wrapInBox(inner: string[], width: number): string[] {
|
||||
const th = this.theme;
|
||||
const border = (s: string) => th.fg("borderAccent", s);
|
||||
const innerWidth = width - 4;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
|
||||
for (const line of inner) {
|
||||
const truncated = truncateToWidth(line, innerWidth);
|
||||
const padWidth = Math.max(0, innerWidth - visibleWidth(truncated));
|
||||
lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│"));
|
||||
}
|
||||
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private buildContentLines(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const shellWidth = width - 4;
|
||||
const contentWidth = Math.min(shellWidth, 128);
|
||||
const sidePad = Math.max(0, Math.floor((shellWidth - contentWidth) / 2));
|
||||
const leftMargin = " ".repeat(sidePad);
|
||||
const lines: string[] = [];
|
||||
|
||||
const row = (content = ""): string => {
|
||||
const truncated = truncateToWidth(content, contentWidth);
|
||||
return leftMargin + padRight(truncated, contentWidth);
|
||||
};
|
||||
const blank = () => row("");
|
||||
const hr = () => row(th.fg("dim", "─".repeat(contentWidth)));
|
||||
|
||||
// Header
|
||||
const title = th.fg("accent", th.bold("Notifications"));
|
||||
const filterLabel = this.filter === "all"
|
||||
? th.fg("dim", "all")
|
||||
: th.fg(this.filter === "error" ? "error" : this.filter === "warning" ? "warning" : "dim", this.filter);
|
||||
const count = `${this.filteredEntries.length} entries`;
|
||||
lines.push(row(joinColumns(
|
||||
`${title} ${th.fg("dim", "filter:")} ${filterLabel}`,
|
||||
th.fg("dim", count),
|
||||
contentWidth,
|
||||
)));
|
||||
lines.push(hr());
|
||||
|
||||
// Controls
|
||||
lines.push(row(th.fg("dim", "↑/↓ scroll f filter c clear Esc close")));
|
||||
lines.push(blank());
|
||||
|
||||
// Entries
|
||||
const filtered = this.filteredEntries;
|
||||
if (filtered.length === 0) {
|
||||
lines.push(blank());
|
||||
lines.push(row(th.fg("dim", this.entries.length === 0
|
||||
? "No notifications yet."
|
||||
: `No ${this.filter} notifications.`)));
|
||||
lines.push(blank());
|
||||
return lines;
|
||||
}
|
||||
|
||||
for (const entry of filtered) {
|
||||
const icon = severityIcon(entry.severity);
|
||||
const coloredIcon = entry.severity === "error" ? th.fg("error", icon)
|
||||
: entry.severity === "warning" ? th.fg("warning", icon)
|
||||
: entry.severity === "success" ? th.fg("success", icon)
|
||||
: th.fg("dim", icon);
|
||||
const time = th.fg("dim", formatTimestamp(entry.ts));
|
||||
const source = entry.source === "workflow-logger" ? th.fg("dim", " [engine]") : "";
|
||||
|
||||
// First line: icon + timestamp + source
|
||||
const msgMaxWidth = contentWidth - 20;
|
||||
const msg = entry.message.length > msgMaxWidth
|
||||
? entry.message.slice(0, msgMaxWidth - 1) + "…"
|
||||
: entry.message;
|
||||
|
||||
lines.push(row(`${coloredIcon} ${time}${source} ${msg}`));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
288
src/resources/extensions/gsd/notification-store.ts
Normal file
288
src/resources/extensions/gsd/notification-store.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// GSD Extension — Persistent Notification Store
|
||||
// Captures all ctx.ui.notify() calls and workflow-logger warnings to
|
||||
// .gsd/notifications.jsonl so they survive context resets and session restarts.
|
||||
// Rotates at MAX_ENTRIES to prevent unbounded growth.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type NotifySeverity = "info" | "success" | "warning" | "error";
|
||||
export type NotificationSource = "notify" | "workflow-logger";
|
||||
|
||||
export interface NotificationEntry {
|
||||
id: string;
|
||||
ts: string;
|
||||
severity: NotifySeverity;
|
||||
message: string;
|
||||
source: NotificationSource;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_ENTRIES = 500;
|
||||
const FILENAME = "notifications.jsonl";
|
||||
const LOCKFILE = "notifications.lock";
|
||||
|
||||
// ─── Module State ───────────────────────────────────────────────────────
|
||||
|
||||
let _basePath: string | null = null;
|
||||
let _lineCount = 0; // Hint for rotation — not authoritative for public API
|
||||
let _suppressCount = 0;
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize the notification store. Call once at session start with the
|
||||
* project root. Seeds in-memory counters from the existing file on disk.
|
||||
*/
|
||||
export function initNotificationStore(basePath: string): void {
|
||||
_basePath = basePath;
|
||||
// Seed line count hint for rotation — public counters read from disk
|
||||
_lineCount = _readEntriesFromDisk(basePath).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a notification entry to the store. Synchronous — safe to call
|
||||
* from the notify() shim which is declared void (not async).
|
||||
*/
|
||||
export function appendNotification(
|
||||
message: string,
|
||||
severity: NotifySeverity,
|
||||
source: NotificationSource = "notify",
|
||||
): void {
|
||||
if (!_basePath) return;
|
||||
if (_suppressCount > 0) return;
|
||||
|
||||
const entry: NotificationEntry = {
|
||||
id: randomUUID(),
|
||||
ts: new Date().toISOString(),
|
||||
severity,
|
||||
message: message.length > 500 ? message.slice(0, 500) + "…" : message,
|
||||
source,
|
||||
read: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const dir = join(_basePath, ".gsd");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILENAME), JSON.stringify(entry) + "\n", "utf-8");
|
||||
_lineCount++;
|
||||
|
||||
// Rotate if hint suggests we're over limit
|
||||
if (_lineCount > MAX_ENTRIES) {
|
||||
_rotate();
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — never let persistence break the caller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all notification entries from disk. Returns newest-first.
|
||||
*/
|
||||
export function readNotifications(basePath?: string): NotificationEntry[] {
|
||||
const bp = basePath ?? _basePath;
|
||||
if (!bp) return [];
|
||||
return _readEntriesFromDisk(bp).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read. Atomic rewrite via temp-file + rename.
|
||||
* Resyncs in-memory counters from disk after mutation.
|
||||
*/
|
||||
export function markAllRead(basePath?: string): void {
|
||||
const bp = basePath ?? _basePath;
|
||||
if (!bp) return;
|
||||
|
||||
const entries = _readEntriesFromDisk(bp);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const hasUnread = entries.some((e) => !e.read);
|
||||
if (!hasUnread) return;
|
||||
|
||||
try {
|
||||
_withLock(bp, () => {
|
||||
// Re-read inside lock to get freshest state
|
||||
const fresh = _readEntriesFromDisk(bp);
|
||||
if (fresh.length === 0 || !fresh.some((e) => !e.read)) return;
|
||||
const lines = fresh.map((e) => JSON.stringify({ ...e, read: true }));
|
||||
_atomicWrite(bp, lines.join("\n") + "\n");
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications. Atomic write of empty content under lock.
|
||||
*/
|
||||
export function clearNotifications(basePath?: string): void {
|
||||
const bp = basePath ?? _basePath;
|
||||
if (!bp) return;
|
||||
|
||||
try {
|
||||
_withLock(bp, () => {
|
||||
_atomicWrite(bp, "");
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current unread count. Reads from disk to stay accurate across
|
||||
* processes (web subprocess can clear/modify the file independently).
|
||||
*/
|
||||
export function getUnreadCount(): number {
|
||||
if (!_basePath) return 0;
|
||||
try {
|
||||
const entries = _readEntriesFromDisk(_basePath);
|
||||
return entries.filter((e) => !e.read).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total notification count. Reads from disk for cross-process accuracy.
|
||||
*/
|
||||
export function getLineCount(): number {
|
||||
if (!_basePath) return 0;
|
||||
try {
|
||||
return _readEntriesFromDisk(_basePath).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily suppress persistence. Use around ctx.ui.notify calls that
|
||||
* should NOT be persisted (e.g., confirmation toasts after clear).
|
||||
* Calls are ref-counted — nest safely.
|
||||
*/
|
||||
export function suppressPersistence(): void {
|
||||
_suppressCount++;
|
||||
}
|
||||
|
||||
export function unsuppressPersistence(): void {
|
||||
_suppressCount = Math.max(0, _suppressCount - 1);
|
||||
}
|
||||
|
||||
// ─── Test Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset module state. Only for use in tests.
|
||||
*/
|
||||
export function _resetNotificationStore(): void {
|
||||
_basePath = null;
|
||||
_lineCount = 0;
|
||||
_suppressCount = 0;
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────
|
||||
|
||||
function _readEntriesFromDisk(basePath: string): NotificationEntry[] {
|
||||
const filePath = join(basePath, ".gsd", FILENAME);
|
||||
if (!existsSync(filePath)) return [];
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return content
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0)
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l) as NotificationEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is NotificationEntry => e !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _rotate(): void {
|
||||
if (!_basePath) return;
|
||||
try {
|
||||
_withLock(_basePath, () => {
|
||||
// Re-read inside lock to get freshest state
|
||||
const entries = _readEntriesFromDisk(_basePath!);
|
||||
if (entries.length <= MAX_ENTRIES) return;
|
||||
const trimmed = entries.slice(entries.length - MAX_ENTRIES);
|
||||
const lines = trimmed.map((e) => JSON.stringify(e));
|
||||
_atomicWrite(_basePath!, lines.join("\n") + "\n");
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic file rewrite via temp-file + rename. Prevents partial reads
|
||||
* by other processes (web API subprocess, parallel workers).
|
||||
* Must be called inside _withLock for cross-process safety.
|
||||
*/
|
||||
function _atomicWrite(basePath: string, content: string): void {
|
||||
const dir = join(basePath, ".gsd");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const target = join(dir, FILENAME);
|
||||
const tmp = target + ".tmp." + process.pid;
|
||||
writeFileSync(tmp, content, "utf-8");
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lockfile for rewrite operations.
|
||||
* Uses O_CREAT|O_EXCL for atomic creation — if the file exists, another
|
||||
* process holds the lock. Retries briefly, then proceeds anyway (best-effort)
|
||||
* to avoid deadlocking the UI on a stale lock.
|
||||
*/
|
||||
function _withLock<T>(basePath: string, fn: () => T): T {
|
||||
const lockPath = join(basePath, ".gsd", LOCKFILE);
|
||||
let fd: number | null = null;
|
||||
const maxAttempts = 5;
|
||||
const retryMs = 20;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
mkdirSync(join(basePath, ".gsd"), { recursive: true });
|
||||
fd = openSync(lockPath, "wx");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EEXIST") {
|
||||
// Check if lock is stale (older than 5s)
|
||||
try {
|
||||
const stat = readFileSync(lockPath, "utf-8");
|
||||
const lockTime = parseInt(stat, 10);
|
||||
if (Date.now() - lockTime > 5000) {
|
||||
try { unlinkSync(lockPath); } catch { /* race ok */ }
|
||||
continue;
|
||||
}
|
||||
} catch { /* can't read lock, retry */ }
|
||||
|
||||
// Wait and retry
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < retryMs) { /* spin */ }
|
||||
continue;
|
||||
}
|
||||
// Other error — proceed without lock
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Write our PID timestamp into the lock for stale detection
|
||||
if (fd !== null) {
|
||||
writeFileSync(lockPath, String(Date.now()), "utf-8");
|
||||
closeSync(fd);
|
||||
}
|
||||
return fn();
|
||||
} finally {
|
||||
try { unlinkSync(lockPath); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
68
src/resources/extensions/gsd/notification-widget.ts
Normal file
68
src/resources/extensions/gsd/notification-widget.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// GSD Extension — Notification Widget
|
||||
// Always-on ambient widget rendered belowEditor showing unread count and
|
||||
// the most recent notification message. Refreshes every 5 seconds.
|
||||
// Widget key: "gsd-notifications", placement: "belowEditor"
|
||||
|
||||
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
||||
|
||||
import { getUnreadCount, readNotifications } from "./notification-store.js";
|
||||
|
||||
// ─── Pure rendering ──<E29480><E29480><EFBFBD>────────────────────────<E29480><E29480><EFBFBD>─────────────────────────
|
||||
|
||||
export function buildNotificationWidgetLines(): string[] {
|
||||
const unread = getUnreadCount();
|
||||
if (unread === 0) return [];
|
||||
|
||||
const entries = readNotifications();
|
||||
const latest = entries[0]; // newest-first
|
||||
if (!latest) return [];
|
||||
|
||||
const icon = latest.severity === "error" ? "✗" : latest.severity === "warning" ? "⚠" : "●";
|
||||
const badge = `${unread} unread`;
|
||||
const msgMax = 80;
|
||||
const truncated = latest.message.length > msgMax
|
||||
? latest.message.slice(0, msgMax - 1) + "…"
|
||||
: latest.message;
|
||||
|
||||
return [` ${icon} [${badge}] ${truncated} (Ctrl+Alt+N to view)`];
|
||||
}
|
||||
|
||||
// ─── Widget init ────────────────────────────────────────────────────────
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Initialize the always-on notification widget (belowEditor).
|
||||
* Call once from session_start after the notification store is initialized.
|
||||
*/
|
||||
export function initNotificationWidget(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// String-array fallback for RPC mode
|
||||
ctx.ui.setWidget("gsd-notifications", buildNotificationWidgetLines(), { placement: "belowEditor" });
|
||||
|
||||
// Factory-based widget for TUI mode
|
||||
ctx.ui.setWidget("gsd-notifications", (_tui, _theme) => {
|
||||
let cachedLines: string[] | undefined;
|
||||
|
||||
const refresh = () => {
|
||||
cachedLines = undefined;
|
||||
_tui.requestRender();
|
||||
};
|
||||
|
||||
const refreshTimer = setInterval(refresh, REFRESH_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
render(_width: number): string[] {
|
||||
if (!cachedLines) cachedLines = buildNotificationWidgetLines();
|
||||
return cachedLines;
|
||||
},
|
||||
invalidate(): void {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
dispose(): void {
|
||||
clearInterval(refreshTimer);
|
||||
},
|
||||
};
|
||||
}, { placement: "belowEditor" });
|
||||
}
|
||||
249
src/resources/extensions/gsd/tests/notification-store.test.ts
Normal file
249
src/resources/extensions/gsd/tests/notification-store.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// GSD Extension — Notification Store Tests
|
||||
|
||||
import { describe, test, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
initNotificationStore,
|
||||
appendNotification,
|
||||
readNotifications,
|
||||
markAllRead,
|
||||
clearNotifications,
|
||||
getUnreadCount,
|
||||
getLineCount,
|
||||
suppressPersistence,
|
||||
unsuppressPersistence,
|
||||
_resetNotificationStore,
|
||||
} from "../notification-store.js";
|
||||
|
||||
describe("notification-store", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "gsd-notif-test-"));
|
||||
mkdirSync(join(tmp, ".gsd"), { recursive: true });
|
||||
_resetNotificationStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetNotificationStore();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("appendNotification creates file and writes entry", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("test message", "info");
|
||||
|
||||
const filePath = join(tmp, ".gsd", "notifications.jsonl");
|
||||
assert.ok(existsSync(filePath));
|
||||
|
||||
const content = readFileSync(filePath, "utf-8").trim();
|
||||
const entry = JSON.parse(content);
|
||||
assert.equal(entry.message, "test message");
|
||||
assert.equal(entry.severity, "info");
|
||||
assert.equal(entry.source, "notify");
|
||||
assert.equal(entry.read, false);
|
||||
assert.ok(entry.id);
|
||||
assert.ok(entry.ts);
|
||||
});
|
||||
|
||||
test("readNotifications returns newest-first", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("first", "info");
|
||||
appendNotification("second", "warning");
|
||||
appendNotification("third", "error");
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.equal(entries.length, 3);
|
||||
assert.equal(entries[0].message, "third");
|
||||
assert.equal(entries[1].message, "second");
|
||||
assert.equal(entries[2].message, "first");
|
||||
});
|
||||
|
||||
test("getUnreadCount tracks appends", () => {
|
||||
initNotificationStore(tmp);
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
|
||||
appendNotification("msg1", "info");
|
||||
assert.equal(getUnreadCount(), 1);
|
||||
|
||||
appendNotification("msg2", "warning");
|
||||
assert.equal(getUnreadCount(), 2);
|
||||
});
|
||||
|
||||
test("markAllRead sets all entries to read", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
appendNotification("msg2", "warning");
|
||||
|
||||
assert.equal(getUnreadCount(), 2);
|
||||
|
||||
markAllRead();
|
||||
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.ok(entries.every((e) => e.read === true));
|
||||
});
|
||||
|
||||
test("clearNotifications empties the file", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
appendNotification("msg2", "error");
|
||||
|
||||
assert.equal(getLineCount(), 2);
|
||||
|
||||
clearNotifications();
|
||||
|
||||
assert.equal(getLineCount(), 0);
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
assert.equal(readNotifications().length, 0);
|
||||
});
|
||||
|
||||
test("rotation keeps only 500 entries", () => {
|
||||
initNotificationStore(tmp);
|
||||
|
||||
for (let i = 0; i < 510; i++) {
|
||||
appendNotification(`msg-${i}`, "info");
|
||||
}
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.ok(entries.length <= 500, `Expected <= 500 entries, got ${entries.length}`);
|
||||
// Most recent should be msg-509
|
||||
assert.equal(entries[0].message, "msg-509");
|
||||
});
|
||||
|
||||
test("source field is preserved", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("from notify", "info", "notify");
|
||||
appendNotification("from logger", "warning", "workflow-logger");
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.equal(entries[0].source, "workflow-logger");
|
||||
assert.equal(entries[1].source, "notify");
|
||||
});
|
||||
|
||||
test("messages are truncated at 500 chars", () => {
|
||||
initNotificationStore(tmp);
|
||||
const longMsg = "x".repeat(600);
|
||||
appendNotification(longMsg, "info");
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.ok(entries[0].message.length <= 501); // 500 + "…"
|
||||
assert.ok(entries[0].message.endsWith("…"));
|
||||
});
|
||||
|
||||
test("readNotifications with explicit basePath works", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
|
||||
// Read with explicit basePath
|
||||
_resetNotificationStore();
|
||||
const entries = readNotifications(tmp);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].message, "msg1");
|
||||
});
|
||||
|
||||
test("init seeds counters from existing file", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
appendNotification("msg2", "warning");
|
||||
|
||||
// Reset and re-init — should seed from disk
|
||||
_resetNotificationStore();
|
||||
initNotificationStore(tmp);
|
||||
|
||||
assert.equal(getLineCount(), 2);
|
||||
assert.equal(getUnreadCount(), 2);
|
||||
});
|
||||
|
||||
test("no-op when store not initialized", () => {
|
||||
// Should not throw
|
||||
appendNotification("msg", "info");
|
||||
assert.equal(readNotifications().length, 0);
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
});
|
||||
|
||||
test("suppressPersistence prevents writes", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("before", "info");
|
||||
assert.equal(getLineCount(), 1);
|
||||
|
||||
suppressPersistence();
|
||||
appendNotification("suppressed", "info");
|
||||
assert.equal(getLineCount(), 1); // still 1
|
||||
|
||||
unsuppressPersistence();
|
||||
appendNotification("after", "info");
|
||||
assert.equal(getLineCount(), 2); // now 2
|
||||
|
||||
const entries = readNotifications();
|
||||
assert.equal(entries[0].message, "after");
|
||||
assert.equal(entries[1].message, "before");
|
||||
// "suppressed" should not appear
|
||||
assert.ok(!entries.some((e) => e.message === "suppressed"));
|
||||
});
|
||||
|
||||
test("suppressPersistence is ref-counted", () => {
|
||||
initNotificationStore(tmp);
|
||||
suppressPersistence();
|
||||
suppressPersistence();
|
||||
unsuppressPersistence();
|
||||
// Still suppressed (one suppress remaining)
|
||||
appendNotification("still suppressed", "info");
|
||||
assert.equal(getLineCount(), 0);
|
||||
|
||||
unsuppressPersistence();
|
||||
appendNotification("now works", "info");
|
||||
assert.equal(getLineCount(), 1);
|
||||
});
|
||||
|
||||
test("reinit switches to new project path", () => {
|
||||
const tmp2 = mkdtempSync(join(tmpdir(), "gsd-notif-test2-"));
|
||||
mkdirSync(join(tmp2, ".gsd"), { recursive: true });
|
||||
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("project1", "info");
|
||||
|
||||
// Switch to new project
|
||||
initNotificationStore(tmp2);
|
||||
appendNotification("project2", "info");
|
||||
|
||||
// project2 should only have its own entry
|
||||
const entries = readNotifications();
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].message, "project2");
|
||||
|
||||
// project1 should still have its entry
|
||||
const p1Entries = readNotifications(tmp);
|
||||
assert.equal(p1Entries.length, 1);
|
||||
assert.equal(p1Entries[0].message, "project1");
|
||||
|
||||
rmSync(tmp2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("counters resync from disk after markAllRead", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
appendNotification("msg2", "info");
|
||||
assert.equal(getUnreadCount(), 2);
|
||||
assert.equal(getLineCount(), 2);
|
||||
|
||||
markAllRead();
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
assert.equal(getLineCount(), 2); // entries still exist, just marked read
|
||||
});
|
||||
|
||||
test("counters resync from disk after clearNotifications", () => {
|
||||
initNotificationStore(tmp);
|
||||
appendNotification("msg1", "info");
|
||||
appendNotification("msg2", "info");
|
||||
|
||||
clearNotifications();
|
||||
assert.equal(getUnreadCount(), 0);
|
||||
assert.equal(getLineCount(), 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { appendNotification } from "./notification-store.js";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type LogSeverity = "warn" | "error";
|
||||
|
|
@ -245,6 +247,17 @@ function _push(
|
|||
const ctxStr = context ? ` ${JSON.stringify(context)}` : "";
|
||||
process.stderr.write(`[gsd:${component}] ${prefix}: ${message}${ctxStr}\n`);
|
||||
|
||||
// Persist to notification store (both warnings and errors)
|
||||
try {
|
||||
appendNotification(
|
||||
`[${component}] ${message}`,
|
||||
severity === "error" ? "error" : "warning",
|
||||
"workflow-logger",
|
||||
);
|
||||
} catch (notifErr) {
|
||||
process.stderr.write(`[gsd:workflow-logger] notification-store append failed: ${(notifErr as Error).message}\n`);
|
||||
}
|
||||
|
||||
// Buffer for auto-loop to drain
|
||||
_buffer.push(entry);
|
||||
if (_buffer.length > MAX_BUFFER) {
|
||||
|
|
|
|||
143
src/web/notifications-service.ts
Normal file
143
src/web/notifications-service.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// GSD Web — Notifications Service
|
||||
// Loads notification data via a child process that imports the notification store.
|
||||
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts"
|
||||
import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts"
|
||||
|
||||
export interface NotificationsData {
|
||||
entries: Array<{
|
||||
id: string
|
||||
ts: string
|
||||
severity: string
|
||||
message: string
|
||||
source: string
|
||||
read: boolean
|
||||
}>
|
||||
unreadCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const NOTIFICATIONS_MAX_BUFFER = 2 * 1024 * 1024
|
||||
const NOTIFICATIONS_MODULE_ENV = "GSD_NOTIFICATIONS_MODULE"
|
||||
|
||||
function resolveTsLoaderPath(packageRoot: string): string {
|
||||
return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")
|
||||
}
|
||||
|
||||
export async function collectNotificationsData(projectCwdOverride?: string): Promise<NotificationsData> {
|
||||
const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride)
|
||||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts")
|
||||
const modulePath = moduleResolution.modulePath
|
||||
|
||||
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(modulePath))) {
|
||||
throw new Error(
|
||||
`notifications data provider not found; checked=${resolveTsLoader},${modulePath}`,
|
||||
)
|
||||
}
|
||||
if (moduleResolution.useCompiledJs && !existsSync(modulePath)) {
|
||||
throw new Error(`notifications data provider not found; checked=${modulePath}`)
|
||||
}
|
||||
|
||||
const script = [
|
||||
'const { pathToFileURL } = await import("node:url");',
|
||||
`const mod = await import(pathToFileURL(process.env.${NOTIFICATIONS_MODULE_ENV}).href);`,
|
||||
'const basePath = process.env.GSD_NOTIFICATIONS_BASE;',
|
||||
'const entries = mod.readNotifications(basePath);',
|
||||
'const unread = entries.filter(e => !e.read).length;',
|
||||
'const result = { entries, unreadCount: unread, totalCount: entries.length };',
|
||||
'process.stdout.write(JSON.stringify(result));',
|
||||
].join(" ")
|
||||
|
||||
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
|
||||
|
||||
return await new Promise<NotificationsData>((resolveResult, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
...prefixArgs,
|
||||
"--eval",
|
||||
script,
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
[NOTIFICATIONS_MODULE_ENV]: modulePath,
|
||||
GSD_NOTIFICATIONS_BASE: projectCwd,
|
||||
},
|
||||
maxBuffer: NOTIFICATIONS_MAX_BUFFER,
|
||||
timeout: 10_000,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(new Error(`notifications subprocess failed: ${err.message}${stderr ? `\nstderr: ${stderr}` : ""}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as NotificationsData
|
||||
resolveResult(parsed)
|
||||
} catch (parseErr) {
|
||||
reject(new Error(`Failed to parse notifications output: ${(parseErr as Error).message}`))
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearNotificationsData(projectCwdOverride?: string): Promise<void> {
|
||||
const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride)
|
||||
const { packageRoot, projectCwd } = config
|
||||
|
||||
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
|
||||
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts")
|
||||
const modulePath = moduleResolution.modulePath
|
||||
|
||||
if (moduleResolution.useCompiledJs && !existsSync(modulePath)) {
|
||||
throw new Error(`notifications data provider not found; checked=${modulePath}`)
|
||||
}
|
||||
|
||||
const script = [
|
||||
'const { pathToFileURL } = await import("node:url");',
|
||||
`const mod = await import(pathToFileURL(process.env.${NOTIFICATIONS_MODULE_ENV}).href);`,
|
||||
'mod.clearNotifications(process.env.GSD_NOTIFICATIONS_BASE);',
|
||||
'process.stdout.write("ok");',
|
||||
].join(" ")
|
||||
|
||||
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
|
||||
|
||||
return await new Promise<void>((resolveResult, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
...prefixArgs,
|
||||
"--eval",
|
||||
script,
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
[NOTIFICATIONS_MODULE_ENV]: modulePath,
|
||||
GSD_NOTIFICATIONS_BASE: projectCwd,
|
||||
},
|
||||
maxBuffer: NOTIFICATIONS_MAX_BUFFER,
|
||||
timeout: 10_000,
|
||||
},
|
||||
(err, _stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(new Error(`clear notifications subprocess failed: ${err.message}${stderr ? `\nstderr: ${stderr}` : ""}`))
|
||||
return
|
||||
}
|
||||
resolveResult()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
49
web/app/api/notifications/route.ts
Normal file
49
web/app/api/notifications/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { collectNotificationsData, clearNotificationsData } from "../../../../src/web/notifications-service.ts"
|
||||
import { requireProjectCwd } from "../../../../src/web/bridge-service.ts"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectCwd = requireProjectCwd(request);
|
||||
const url = new URL(request.url)
|
||||
const countOnly = url.searchParams.get("countOnly") === "true"
|
||||
|
||||
const payload = await collectNotificationsData(projectCwd)
|
||||
|
||||
if (countOnly) {
|
||||
return Response.json(
|
||||
{ unreadCount: payload.unreadCount },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json(payload, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return Response.json(
|
||||
{ error: message },
|
||||
{ status: 500, headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectCwd = requireProjectCwd(request);
|
||||
await clearNotificationsData(projectCwd)
|
||||
return Response.json(
|
||||
{ ok: true },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return Response.json(
|
||||
{ error: message },
|
||||
{ status: 500, headers: { "Cache-Control": "no-store" } },
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue