diff --git a/.changeset/fn-8853-settings-activity-coverage.md b/.changeset/fn-8853-settings-activity-coverage.md new file mode 100644 index 0000000000..58745ffef4 --- /dev/null +++ b/.changeset/fn-8853-settings-activity-coverage.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Activity Log now records every settings change, not just four keys. +category: fix +dev: Adds the settings-activity.ts seam with engine-churn exclusions, value redaction, and bounded summaries; legacy details now use generic key: from → to wording. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 207cf3d1cd..8787a2aca7 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1,5 +1,9 @@ # Diagnostics +## Settings changes in the Activity Log + +Every changed project or global setting produces one `settings:updated` Activity Log entry. Engine-owned churn keys (`engineLastActiveAt` and `engineActiveSinceMs`) are deliberately excluded so heartbeat polling does not flood the log, and secret-bearing values are redacted. Workflow setting values, including `requirePrApproval`, are not covered because they never appear in the `settings:updated` payload; use `GET /api/config/revisions` for those changes. The broader audit-trail documentation work remains tracked by FN-8854. + ## Debug-level diagnostics (`FUSION_DEBUG`) Engine and core subsystem loggers (`createLogger`) expose a `debug()` level for routine diagnostics. It is **off by default** so the TUI log pane and engine stderr show state *changes* rather than repeated resting-state chatter. diff --git a/packages/core/src/__tests__/settings-activity-log.test.ts b/packages/core/src/__tests__/settings-activity-log.test.ts new file mode 100644 index 0000000000..832122e27c --- /dev/null +++ b/packages/core/src/__tests__/settings-activity-log.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { + diffSettingsForActivity, + formatSettingsActivity, + isSensitiveSettingsKey, + summarizeSettingsValue, +} from "../task-store/settings-activity.js"; +import { setupActivityLogListenersImpl } from "../task-store/lifecycle-ops.js"; + +type ActivityEntry = { + type: string; + details: string; + metadata?: Record; +}; + +function wireSettingsListener() { + const events = new EventEmitter(); + const rows: ActivityEntry[] = []; + const store = { + activityListenersWired: false, + on: events.on.bind(events), + recordActivityFromListener: vi.fn((entry: ActivityEntry) => rows.push(entry)), + }; + + setupActivityLogListenersImpl(store as never); + return { + emit: (settings: object, previous: object) => events.emit("settings:updated", { settings, previous }), + rows, + }; +} + +describe("settings activity formatting", () => { + it("ignores no-op and engine-churn-only updates", () => { + expect(diffSettingsForActivity({ autoMerge: true }, { autoMerge: true })).toEqual([]); + expect(diffSettingsForActivity( + { engineLastActiveAt: "before", engineActiveSinceMs: 1 }, + { engineLastActiveAt: "after", engineActiveSinceMs: 2 }, + )).toEqual([]); + expect(formatSettingsActivity([])).toBeNull(); + }); + + it("renders additions, removals, nested values, and long strings safely", () => { + const changes = diffSettingsForActivity( + { removed: "value", nested: { one: true }, longValue: "short" }, + { added: "value", nested: { one: false }, longValue: " x".repeat(50) }, + ); + expect(changes).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: "added", from: "unset", to: "value" }), + expect.objectContaining({ key: "removed", from: "value", to: "unset" }), + expect.objectContaining({ key: "nested", from: "{…}", to: "{…}" }), + ])); + expect(changes.find((change) => change.key === "longValue")?.to).toHaveLength(81); + }); + + it("uses stable deep equality for unchanged nested settings", () => { + expect(diffSettingsForActivity( + { modelPresets: [{ id: "default", options: { enabled: true } }], experimentalFeatures: { alpha: true } }, + { experimentalFeatures: { alpha: true }, modelPresets: [{ options: { enabled: true }, id: "default" }] }, + )).toEqual([]); + }); + + it("redacts every secret-bearing setting value", () => { + const sensitiveKeys = [ + "ntfyAccessToken", + "gitlabAuthToken", + "githubAuthToken", + "daemonToken", + "researchGlobalBraveApiKey", + "researchGlobalGoogleSearchApiKey", + "researchGlobalTavilyApiKey", + ]; + for (const key of sensitiveKeys) { + expect(isSensitiveSettingsKey(key)).toBe(true); + const [change] = diffSettingsForActivity({ [key]: "old-secret" }, { [key]: "new-secret" }); + expect(change).toMatchObject({ from: "«redacted»", to: "«redacted»", sensitive: true }); + } + expect(summarizeSettingsValue("plainValue", null)).toBe("unset"); + }); + + it("caps displayed and metadata changes while retaining the total", () => { + const previous = Object.fromEntries(Array.from({ length: 10 }, (_, index) => [`key${index}`, false])); + const next = Object.fromEntries(Array.from({ length: 10 }, (_, index) => [`key${index}`, true])); + const activity = formatSettingsActivity(diffSettingsForActivity(previous, next)); + expect(activity?.details).toContain("+2 more"); + expect(activity?.metadata).toMatchObject({ changedCount: 10 }); + expect(activity?.metadata?.changes).toHaveLength(10); + }); +}); + +describe("settings:updated activity listener", () => { + it.each([ + ["autoMerge", true, false], + ["mergeStrategy", "direct", "pull-request"], + ["maxAutoMergeRetries", 3, 4], + ["integrationBranch", "main", "release"], + ["ntfyEnabled", true, false], + ["ntfyTopic", "old", "new"], + ["globalPause", true, false], + ["enginePaused", true, false], + ])("records each operator-visible %s setting change", (key, before, after) => { + const listener = wireSettingsListener(); + listener.emit({ [key]: after }, { [key]: before }); + expect(listener.rows).toHaveLength(1); + expect(listener.rows[0]).toMatchObject({ type: "settings:updated" }); + expect(listener.rows[0].details).toContain(key); + expect(listener.rows[0].details).toContain(summarizeSettingsValue(key, before)); + expect(listener.rows[0].details).toContain(summarizeSettingsValue(key, after)); + }); + + it("omits churn, retains real settings in mixed updates, and ignores rollback no-ops", () => { + const listener = wireSettingsListener(); + listener.emit({ engineLastActiveAt: "after" }, { engineLastActiveAt: "before" }); + listener.emit({ engineActiveSinceMs: 2 }, { engineActiveSinceMs: 1 }); + listener.emit({ engineLastActiveAt: "after", autoMerge: false }, { engineLastActiveAt: "before", autoMerge: true }); + listener.emit({ modelPresets: [{ id: "default" }] }, { modelPresets: [{ id: "default" }] }); + + expect(listener.rows).toHaveLength(1); + expect(listener.rows[0].details).toContain("autoMerge"); + expect(listener.rows[0].details).not.toContain("engineLastActiveAt"); + }); + + it("never records secret values in details or metadata", () => { + const listener = wireSettingsListener(); + listener.emit( + { ntfyAccessToken: "new-ntfy-secret", githubAuthToken: "new-github-secret" }, + { ntfyAccessToken: "old-ntfy-secret", githubAuthToken: "old-github-secret" }, + ); + + expect(listener.rows).toHaveLength(1); + expect(JSON.stringify(listener.rows[0])).not.toContain("old-ntfy-secret"); + expect(JSON.stringify(listener.rows[0])).not.toContain("new-ntfy-secret"); + expect(JSON.stringify(listener.rows[0])).not.toContain("old-github-secret"); + expect(JSON.stringify(listener.rows[0])).not.toContain("new-github-secret"); + }); +}); diff --git a/packages/core/src/task-store/lifecycle-ops.ts b/packages/core/src/task-store/lifecycle-ops.ts index 93593f9baf..be504967e1 100644 --- a/packages/core/src/task-store/lifecycle-ops.ts +++ b/packages/core/src/task-store/lifecycle-ops.ts @@ -35,6 +35,7 @@ import {ACTIVE_TASK_FILTER, insertTaskRowInTransaction, isTaskIdConflictError as import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js"; import {recordRunAuditEventWithinTransaction} from "../postgres/data-layer.js"; import * as schema from "../postgres/schema/index.js"; +import {diffSettingsForActivity, formatSettingsActivity} from "./settings-activity.js"; export async function initImpl(store: TaskStore): Promise { store.closing = false; @@ -333,31 +334,22 @@ export function setupActivityLogListenersImpl(store: TaskStore): void { } }); - // Settings updated (log important changes) + /* + FNXC:SettingsAuditTrail 2026-08-09-02:05: + Keep settings activity policy in settings-activity.ts so every settings:updated emitter + gets one safe, generic diff without reintroducing a partial listener allowlist. + */ store.on("settings:updated", (data) => { - const importantChanges: string[] = []; - if (data.settings.ntfyEnabled !== data.previous.ntfyEnabled) { - importantChanges.push(`ntfy ${data.settings.ntfyEnabled ? "enabled" : "disabled"}`); - } - if (data.settings.ntfyTopic !== data.previous.ntfyTopic) { - importantChanges.push(`ntfy topic changed to ${data.settings.ntfyTopic}`); - } - if (data.settings.globalPause !== data.previous.globalPause) { - importantChanges.push(`global pause ${data.settings.globalPause ? "enabled" : "disabled"}`); - } - if (data.settings.enginePaused !== data.previous.enginePaused) { - importantChanges.push(`engine pause ${data.settings.enginePaused ? "enabled" : "disabled"}`); - } - - if (importantChanges.length > 0) { - store.recordActivityFromListener( - { - type: "settings:updated", - details: `Settings updated: ${importantChanges.join(", ")}`, - metadata: { changes: importantChanges }, - }, - "settings:updated", - ); + try { + const activity = formatSettingsActivity(diffSettingsForActivity(data.previous, data.settings)); + if (activity) { + store.recordActivityFromListener( + { type: "settings:updated", details: activity.details, metadata: activity.metadata }, + "settings:updated", + ); + } + } catch (error) { + storeLog.warn("Failed to format settings activity", { error: getErrorMessage(error) }); } }); diff --git a/packages/core/src/task-store/settings-activity.ts b/packages/core/src/task-store/settings-activity.ts new file mode 100644 index 0000000000..d2d3eae2b3 --- /dev/null +++ b/packages/core/src/task-store/settings-activity.ts @@ -0,0 +1,95 @@ +import { isPlainObject } from "./settings-helpers.js"; + +export interface SettingsActivityChange { + key: string; + from: string; + to: string; + sensitive: boolean; +} + +export const SETTINGS_ACTIVITY_EXCLUDED_KEYS: ReadonlySet = new Set([ + "engineLastActiveAt", + "engineActiveSinceMs", + "postgresMigrationInboxMessageSentAt", + "secretsSyncPassphraseConfigured", +]); + +const MAX_LISTED_CHANGES = 8; +const MAX_METADATA_CHANGES = 50; +const SENSITIVE_KEY_PATTERN = /token|secret|passphrase|password|apikey|credential/i; + +/* +FNXC:SettingsAuditTrail 2026-08-09-02:05: +FN-8853 removes the four-key allowlist after an incident where the Activity Log answered +"never changed" for autoMerge. packages/engine/src/scheduler.ts:2061 writes its heartbeat +on every poll, so engine-owned churn is excluded instead of abandoning generic coverage. +Values are redacted or summarized to keep secrets and large configuration out of the user-visible +log. Workflow settings such as requirePrApproval are MOVED_SETTINGS_KEYS and never occur in this +payload, so they remain outside this activity surface. +*/ +export function isSensitiveSettingsKey(key: string): boolean { + return SENSITIVE_KEY_PATTERN.test(key); +} + +export function summarizeSettingsValue(key: string, value: unknown): string { + if (value === undefined || value === null) return "unset"; + if (isSensitiveSettingsKey(key)) return "«redacted»"; + if (typeof value === "boolean") return value ? "enabled" : "disabled"; + if (typeof value === "number") return String(value); + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed; + } + if (Array.isArray(value)) return `${value.length} item(s)`; + if (isPlainObject(value)) return "{…}"; + return String(value); +} + +function stableValue(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableValue).join(",")}]`; + if (isPlainObject(value)) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableValue(value[key])}`).join(",")}}`; + } + return Object.prototype.toString.call(value); +} + +export function diffSettingsForActivity( + previous: object, + next: object, +): SettingsActivityChange[] { + const previousValues = previous as Record; + const nextValues = next as Record; + const keys = new Set([...Object.keys(previousValues), ...Object.keys(nextValues)]); + return [...keys] + .filter((key) => !SETTINGS_ACTIVITY_EXCLUDED_KEYS.has(key) && stableValue(previousValues[key]) !== stableValue(nextValues[key])) + .sort() + .map((key) => ({ + key, + from: summarizeSettingsValue(key, previousValues[key]), + to: summarizeSettingsValue(key, nextValues[key]), + sensitive: isSensitiveSettingsKey(key), + })); +} + +export function formatSettingsActivity( + changes: SettingsActivityChange[], +): { details: string; metadata: Record } | null { + if (changes.length === 0) return null; + const listed = changes + .slice(0, MAX_LISTED_CHANGES) + .map((change) => `${change.key}: ${change.from} → ${change.to}`); + const details = `Settings updated: ${listed.join(", ")}${changes.length > MAX_LISTED_CHANGES ? `, +${changes.length - MAX_LISTED_CHANGES} more` : ""}`; + const metadataChanges = changes + .slice(0, MAX_METADATA_CHANGES) + .map((change) => `${change.key}: ${change.from} → ${change.to}`); + return { + details, + metadata: { + changes: metadataChanges, + changedKeys: changes.slice(0, MAX_METADATA_CHANGES).map((change) => change.key), + changedCount: changes.length, + }, + }; +}