feat(notifications): tag remaining auto/loop/register-hooks notices + trace-writer
- auto.js, auto/loop.js, bootstrap/register-hooks.js: tag all autonomous-mode system notices with NOTICE_KIND.SYSTEM_NOTICE; add dedupe_key to loop-level model-policy and flow-audit notices - web/notifications-service.ts: add repeatCount/lastTs/noticeKind to Notification type (schema v2 fields) - uok/trace-writer.js: new unit trace writer - tests/notification-store-grouping.test.mjs: grouping test coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d33e30e885
commit
62fcf8fd20
6 changed files with 227 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
86
src/resources/extensions/sf/uok/trace-writer.js
Normal file
86
src/resources/extensions/sf/uok/trace-writer.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Writes typed trace events to .sf/traces/<traceId>.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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue