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:
7
.changeset/fn-8853-settings-activity-coverage.md
Normal file
7
.changeset/fn-8853-settings-activity-coverage.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
138
packages/core/src/__tests__/settings-activity-log.test.ts
Normal file
138
packages/core/src/__tests__/settings-activity-log.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
95
packages/core/src/task-store/settings-activity.ts
Normal file
95
packages/core/src/task-store/settings-activity.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user