FN-5939: expose operational log retention setting

Expose operational log retention as a project setting in the dashboard.

- add an Operational log retention selector to the Project General settings section with supported retention options
- validate operationalLogRetentionDays in the settings API and cover accepted and rejected values in tests
- document the constrained retention values and assert project-scope/default parity for the setting

Files changed:
 docs/settings-reference.md                         |  2 +-
 packages/core/src/__tests__/settings-parity.test.ts |  7 ++++
 packages/dashboard/app/components/SettingsModal.tsx | 47 ++++++++++------------
 packages/dashboard/app/components/__tests__/SettingsModal.test.tsx |  8 ++++
 packages/dashboard/src/__tests__/routes-settings.test.ts | 21 ++++++++++
 packages/dashboard/src/routes/register-settings-memory-routes.ts | 10 +++++
 6 files changed, 69 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-5939

Fusion-Task-Lineage: 2148dd88-1cef-4c6d-9696-31148fce97d3
This commit is contained in:
gsxdsm
2026-06-03 08:24:12 -07:00
parent ac92174cba
commit 76c18efa92
6 changed files with 69 additions and 26 deletions

View File

@@ -430,7 +430,7 @@ Default notes:
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). |
| `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. |
| `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. |
| `operationalLogRetentionDays` | `number` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). Periodic maintenance prunes rows older than this many days using each row's `timestamp`. Set `0` to disable pruning. |
| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes rows older than this many days using each row's `timestamp`. |
| `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. |
| `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). |
| `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). |

View File

@@ -117,6 +117,13 @@ describe("settings key parity", () => {
expect(PROJECT_SETTINGS_KEYS).toContain("mailAutoCleanupDays");
});
it("defaults operationalLogRetentionDays to 30 and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.operationalLogRetentionDays).toBe(30);
expect(isProjectSettingsKey("operationalLogRetentionDays")).toBe(true);
expect(isGlobalSettingsKey("operationalLogRetentionDays")).toBe(false);
expect(PROJECT_SETTINGS_KEYS).toContain("operationalLogRetentionDays");
});
it("keeps heartbeatScopeDiscipline project-scoped with strict default", () => {
expect(DEFAULT_PROJECT_SETTINGS.heartbeatScopeDiscipline).toBe("strict");
expect(isProjectSettingsKey("heartbeatScopeDiscipline")).toBe(true);

View File

@@ -2451,6 +2451,28 @@ export function SettingsModal({
</select>
<small>Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.</small>
</div>
<div className="form-group">
<label htmlFor="operationalLogRetentionDays">Operational log retention</label>
<select
id="operationalLogRetentionDays"
className="select"
value={form.operationalLogRetentionDays ?? 30}
onChange={(e) =>
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>
Lowering this window means Reliability metrics/charts and the Activity feed will not show history older
than the selected range. Per-task task detail history is unaffected. Default: 30 days.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat Rooms</h4>
<div className="form-group">
<label htmlFor="chatRoomRecentVerbatimMessages">Recent verbatim room messages</label>
@@ -6108,31 +6130,6 @@ export function SettingsModal({
)}
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Database Maintenance</h4>
<div className="form-group">
<label htmlFor="operationalLogRetentionDays">Operational log retention</label>
<select
id="operationalLogRetentionDays"
className="select"
value={form.operationalLogRetentionDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
<option value={365}>365 days</option>
</select>
<small>
Prune append-only operational logs (activity log, agent logs, run audit, heartbeats) older than this
many days during periodic maintenance. Keeps the database from growing without bound — large databases
are slower to checkpoint and more prone to corruption. Default: 30 days.
</small>
</div>
<h4 className="settings-section-heading">Memory Backups</h4>
<div className="form-group">
<label htmlFor="memoryBackupEnabled" className="checkbox-label">

View File

@@ -997,6 +997,14 @@ describe("SettingsModal", () => {
scope: "project",
expectedKey: "chatAutoCleanupDays",
},
{
section: "Project General",
label: "Operational log retention",
kind: "select",
value: 7,
scope: "project",
expectedKey: "operationalLogRetentionDays",
},
])("persists $expectedKey through the expected settings scope", async (input) => {
await expectSettingPersists(input);
});

View File

@@ -810,6 +810,27 @@ describe("PUT /settings", () => {
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("accepts allowed operationalLogRetentionDays values", async () => {
for (const value of [7, 0]) {
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ operationalLogRetentionDays: value }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ operationalLogRetentionDays: value });
}
});
it("rejects invalid operationalLogRetentionDays values", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/settings", JSON.stringify({ operationalLogRetentionDays: 45 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("operationalLogRetentionDays");
expect(store.updateSettings).not.toHaveBeenCalledWith({ operationalLogRetentionDays: 45 });
});
it("accepts partial remoteAccess patches and GET /settings returns merged sibling branches", async () => {
const mergedRemoteAccess = {
enabled: true,

View File

@@ -614,6 +614,16 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
throw badRequest("doneAutoArchiveDays must be an integer between 0 and 3650");
}
}
const operationalLogRetentionDays = clientSettings.operationalLogRetentionDays;
if (operationalLogRetentionDays !== undefined && operationalLogRetentionDays !== null) {
if (
typeof operationalLogRetentionDays !== "number"
|| !Number.isInteger(operationalLogRetentionDays)
|| ![0, 7, 14, 30, 60, 90].includes(operationalLogRetentionDays)
) {
throw badRequest("operationalLogRetentionDays must be one of: 0, 7, 14, 30, 60, 90");
}
}
if (
clientSettings.archiveAgentLogMode !== undefined &&
!["none", "compact", "full"].includes(clientSettings.archiveAgentLogMode)