FN-8853: record all settings changes in Activity Log

Record all meaningful settings changes in the Activity Log.

- Replace the four-key listener allowlist with safe generic setting diffs.
- Exclude engine churn, redact sensitive values, and cap displayed summaries.
- Add regression coverage, diagnostics guidance, and a patch changeset.

Files changed: .changeset/fn-8853-settings-activity-coverage.md   |   7 ++
 docs/diagnostics.md                                |   4 +
 .../src/__tests__/settings-activity-log.test.ts    | 138 +++++++++++++++++++++
 packages/core/src/task-store/lifecycle-ops.ts      |  40 +++---
 packages/core/src/task-store/settings-activity.ts  |  95 ++++++++++++++
 5 files changed, 260 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-8853

Fusion-Task-Lineage: ea072564-0bf1-4a97-8dd8-cde5844b1055

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 21:10:13 -07:00
parent 7611ce7baa
commit 1e5d80dd4d
5 changed files with 260 additions and 24 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<string, unknown>;
};
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");
});
});

View File

@@ -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<void> {
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) });
}
});

View File

@@ -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<string> = 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<string, unknown>;
const nextValues = next as Record<string, unknown>;
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<string, unknown> } | 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,
},
};
}