diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index a197c9632..b9336bcf6 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -132,6 +132,7 @@ import { initMetrics, resetMetrics, } from "./metrics.js"; +import { NOTICE_KIND } from "./notification-store.js"; import { sendDesktopNotification } from "./notifications.js"; import { resolvePreset } from "./operating-model.js"; import { @@ -1262,7 +1263,12 @@ export async function pauseAuto(ctx, _pi, _errorContext) { ctx?.ui.notify( `${s.stepMode ? "Step" : "Autonomous"} mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, "info", - { kind: "terminal", blocking: true, source: "workflow" }, + { + kind: "terminal", + blocking: true, + source: "workflow", + noticeKind: NOTICE_KIND.USER_VISIBLE, + }, ); } /** @@ -1413,6 +1419,7 @@ async function runStartupDoctorFix(ctx, basePath) { ctx.ui.notify( `Startup doctor: applied ${report.fixesApplied.length} fix(es).`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } return report; diff --git a/src/resources/extensions/sf/auto/loop.js b/src/resources/extensions/sf/auto/loop.js index 64acb7123..5306a3ae5 100644 --- a/src/resources/extensions/sf/auto/loop.js +++ b/src/resources/extensions/sf/auto/loop.js @@ -22,6 +22,7 @@ import { } from "../uok/execution-graph.js"; import { resolveUokFlags } from "../uok/flags.js"; import { clearRunawayRecoveredRuntimeRecords } from "../uok/unit-runtime.js"; +import { NOTICE_KIND } from "../notification-store.js"; import { logWarning } from "../workflow-logger.js"; import { COOLDOWN_FALLBACK_WAIT_MS, @@ -1126,6 +1127,10 @@ export async function autoLoop(ctx, pi, s, deps) { ctx.ui.notify( `Autonomous mode paused: model-policy denied dispatch for ${loopErr.unitType}/${loopErr.unitId}. ${msg}`, "error", + { + noticeKind: NOTICE_KIND.SYSTEM_NOTICE, + dedupe_key: `model-policy-blocked:${loopErr.unitType}:${loopErr.unitId}`, + }, ); deps.emitJournalEvent({ ts: new Date().toISOString(), diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index 068d3b5ac..1543cdad7 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -50,7 +50,7 @@ import { observeToolResult, resetToolWatchdog, } from "../tool-watchdog.js"; -import { initNotificationStore } from "../notification-store.js"; +import { NOTICE_KIND, initNotificationStore } from "../notification-store.js"; import { initNotificationWidget } from "../notification-widget.js"; import { isParallelActive, @@ -171,6 +171,7 @@ async function runSessionStartupDoctorFix(ctx) { ctx.ui?.notify?.( `Startup doctor: applied ${report.fixesApplied.length} fix(es).`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } const summary = summarizeDoctorIssues(report.issues); @@ -178,6 +179,7 @@ async function runSessionStartupDoctorFix(ctx) { ctx.ui?.notify?.( `Startup doctor found ${summary.errors} blocking issue(s). Run /doctor audit for details.`, "warning", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } } catch (error) { @@ -343,7 +345,10 @@ export function registerHooks(pi, ecosystemHandlers = []) { killOverBudgetChildren: true, }); if (!flow.ok) { - ctx.ui?.notify?.(`Flow audit: ${flow.recommendedAction}`, "warning"); + ctx.ui?.notify?.(`Flow audit: ${flow.recommendedAction}`, "warning", { + noticeKind: NOTICE_KIND.SYSTEM_NOTICE, + dedupe_key: "flow-audit-recommended-action", + }); } } catch { /* non-fatal — flow audit must never block session start */ @@ -368,6 +373,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `Self-feedback ${id} auto-resolved — milestone is complete.`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } const triage = triageBlockedEntries(process.cwd()); @@ -394,12 +400,14 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `Self-feedback ${e.id} (${e.kind}) auto-resolved — sf bumped past ${e.sfVersion}. Originating unit ${unit} should be re-run.`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } if (triage.stillBlocked.length > 0) { ctx.ui?.notify?.( `${triage.stillBlocked.length} unresolved self-feedback entr${triage.stillBlocked.length === 1 ? "y" : "ies"} require sf fixes. See .sf/SELF-FEEDBACK.md or ~/.sf/agent/upstream-feedback.jsonl.`, "warning", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } // Forge-only: high/critical entries are queued as hidden follow-up repair @@ -413,6 +421,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `${highBlocked.length} high/critical inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/SELF-FEEDBACK.md: ${ids}`, "warning", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); const { dispatchSelfFeedbackInlineFixIfNeeded } = await import( "../self-feedback-drain.js" @@ -433,6 +442,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `Gap audit filed ${filed} new finding${filed === 1 ? "" : "s"} in ${selfFeedbackDestinationLabel(process.cwd())}`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } } catch { @@ -456,6 +466,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `Upstream bridge filed ${filed} rollup${filed === 1 ? "" : "s"} in .sf/SELF-FEEDBACK.md`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } } catch { @@ -473,6 +484,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( `Promoted ${promoted} cluster${promoted === 1 ? "" : "s"} to requirements: ${requirementIds.join(", ")}`, "info", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, ); } } catch { @@ -491,6 +503,10 @@ export function registerHooks(pi, ecosystemHandlers = []) { ctx.ui?.notify?.( "Resuming autonomous mode after compaction.", "info", + { + noticeKind: NOTICE_KIND.SYSTEM_NOTICE, + dedupe_key: "autonomous-resume-after-compaction", + }, ); startAutoDetached(ctx, pi, process.cwd(), marker?.verbose === true, { step: marker?.stepMode === true, diff --git a/src/resources/extensions/sf/tests/notification-store-grouping.test.mjs b/src/resources/extensions/sf/tests/notification-store-grouping.test.mjs new file mode 100644 index 000000000..4435e6d7e --- /dev/null +++ b/src/resources/extensions/sf/tests/notification-store-grouping.test.mjs @@ -0,0 +1,106 @@ +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + _resetNotificationStore, + appendNotification, + initNotificationStore, + NOTICE_KIND, + NOTIFICATION_SCHEMA_VERSION_WRITE, + readNotifications, +} from "../notification-store.js"; + +describe("notification-store long-term grouping (schema v2)", () => { + let testDir; + + beforeEach(() => { + _resetNotificationStore(); + testDir = join(tmpdir(), `sf-notif-group-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + initNotificationStore(testDir); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true }); + } catch { + /* ignore */ + } + _resetNotificationStore(); + }); + + it("merges_duplicate_warnings_with_same_dedupe_key_into_one_row_with_repeatCount", () => { + appendNotification("quota edge", "warning", "notify", { + dedupe_key: "quota-roll", + }); + appendNotification("quota edge", "warning", "notify", { + dedupe_key: "quota-roll", + }); + + const lines = readFileSync( + join(testDir, ".sf", "notifications.jsonl"), + "utf-8", + ) + .trim() + .split("\n") + .filter(Boolean); + expect(lines.length).toBe(1); + const row = JSON.parse(lines[0]); + expect(row.schemaVersion).toBe(NOTIFICATION_SCHEMA_VERSION_WRITE); + expect(row.repeatCount).toBe(2); + expect(row.lastTs).toBeDefined(); + + const [latest] = readNotifications(testDir); + expect(latest.repeatCount).toBe(2); + }); + + it("does_not_merge_when_merge_is_false", () => { + appendNotification("unique audit", "warning", "notify", { + dedupe_key: "audit-x", + merge: false, + }); + appendNotification("unique audit", "warning", "notify", { + dedupe_key: "audit-x", + merge: false, + }); + + const lines = readFileSync( + join(testDir, ".sf", "notifications.jsonl"), + "utf-8", + ) + .trim() + .split("\n") + .filter(Boolean); + expect(lines.length).toBe(2); + }); + + it("does_not_merge_approval_request_kind_distinct_consent_rows", () => { + appendNotification("approve deploy?", "warning", "notify", { + kind: "approval_request", + dedupe_key: "deploy-approve", + }); + appendNotification("approve deploy?", "warning", "notify", { + kind: "approval_request", + dedupe_key: "deploy-approve", + }); + + const lines = readFileSync( + join(testDir, ".sf", "notifications.jsonl"), + "utf-8", + ) + .trim() + .split("\n") + .filter(Boolean); + expect(lines.length).toBe(2); + }); + + it("persists_noticeKind_on_new_rows", () => { + appendNotification("SM offline", "warning", "notify", { + dedupe_key: "sm-health", + noticeKind: NOTICE_KIND.SYSTEM_NOTICE, + }); + const [e] = readNotifications(testDir); + expect(e.noticeKind).toBe(NOTICE_KIND.SYSTEM_NOTICE); + }); +}); diff --git a/src/resources/extensions/sf/uok/trace-writer.js b/src/resources/extensions/sf/uok/trace-writer.js new file mode 100644 index 000000000..0ff139ad6 --- /dev/null +++ b/src/resources/extensions/sf/uok/trace-writer.js @@ -0,0 +1,86 @@ +// Writes typed trace events to .sf/traces/.jsonl +// Updates a `latest` symlink for easy tail -f access. +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + statSync, + symlinkSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { sfRoot } from "../paths.js"; + +function tracesDir(basePath) { + return join(sfRoot(basePath), "traces"); +} + +function tracePath(basePath, traceId) { + return join(tracesDir(basePath), `${traceId}.jsonl`); +} + +export function appendTraceEvent(basePath, traceId, event) { + if (!basePath || !traceId) return; + try { + const dir = tracesDir(basePath); + mkdirSync(dir, { recursive: true }); + const path = tracePath(basePath, traceId); + const line = JSON.stringify({ ts: new Date().toISOString(), ...event }); + if (!existsSync(path)) closeSync(openSync(path, "a")); + appendFileSync(path, `${line}\n`, "utf-8"); + // Update latest symlink + const latestPath = join(dir, "latest"); + try { + unlinkSync(latestPath); + } catch { + /* ok if missing */ + } + symlinkSync(`${traceId}.jsonl`, latestPath); + } catch { + // trace writes must never break orchestration + } +} + +export function readTraceEvents(basePath, type, windowHours = 24) { + // Read all trace files modified within windowHours, filter by event type + // Returns array of matching events + const dir = tracesDir(basePath); + if (!existsSync(dir)) return []; + const cutoff = Date.now() - windowHours * 60 * 60 * 1000; + const results = []; + let files; + try { + files = readdirSync(dir).filter( + (f) => f.endsWith(".jsonl") && f !== "latest", + ); + } catch { + return []; + } + for (const file of files) { + try { + const filePath = join(dir, file); + if (statSync(filePath).mtimeMs < cutoff) continue; + const lines = readFileSync(filePath, "utf-8") + .split("\n") + .filter(Boolean); + for (const line of lines) { + try { + const ev = JSON.parse(line); + if (!type || ev.type === type) { + if (!ev.ts || new Date(ev.ts).getTime() >= cutoff) + results.push(ev); + } + } catch { + /* skip malformed lines */ + } + } + } catch { + /* skip unreadable files */ + } + } + return results; +} diff --git a/src/web/notifications-service.ts b/src/web/notifications-service.ts index 0ba89cf2f..41a117bfe 100644 --- a/src/web/notifications-service.ts +++ b/src/web/notifications-service.ts @@ -20,6 +20,10 @@ export interface NotificationsData { message: string; source: string; read: boolean; + /** Present when duplicate notices were merged (notification-store v2). */ + repeatCount?: number; + lastTs?: string; + noticeKind?: string; }>; unreadCount: number; totalCount: number;