feat(FN-1952): move archived tasks to cold database

This commit is contained in:
gsxdsm
2026-04-16 19:49:07 -07:00
parent 4a576c028c
commit e2560981fe
13 changed files with 825 additions and 187 deletions

View File

@@ -91,6 +91,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
logEntry: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
mergeTask: vi.fn().mockResolvedValue(undefined),
archiveTaskAndCleanup: vi.fn().mockResolvedValue({} as Task),
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }),
listTasks: vi.fn().mockResolvedValue([]),
getRootDir: vi.fn().mockReturnValue("/tmp/test-project"),
@@ -562,6 +563,51 @@ describe("SelfHealingManager", () => {
});
});
// ── Auto-archive ────────────────────────────────────────────────────
describe("archiveStaleDoneTasks", () => {
it("skips when auto-archive is disabled", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoArchiveDoneTasksEnabled: false,
} as unknown as Settings);
const result = await manager.archiveStaleDoneTasks();
expect(result).toBe(0);
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.archiveTaskAndCleanup).not.toHaveBeenCalled();
});
it("archives stale done tasks with cleanup using the configured age", async () => {
vi.setSystemTime(new Date("2026-01-04T00:00:00.000Z"));
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoArchiveDoneTasksEnabled: true,
autoArchiveDoneAfterMs: 24 * 60 * 60 * 1000,
} as unknown as Settings);
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-001",
column: "done",
columnMovedAt: "2026-01-02T23:59:00.000Z",
updatedAt: "2026-01-02T23:59:00.000Z",
},
{
id: "FN-002",
column: "done",
columnMovedAt: "2026-01-03T12:00:00.000Z",
updatedAt: "2026-01-03T12:00:00.000Z",
},
]);
const result = await manager.archiveStaleDoneTasks();
expect(result).toBe(1);
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: false });
expect(store.archiveTaskAndCleanup).toHaveBeenCalledWith("FN-001");
expect(store.archiveTaskAndCleanup).not.toHaveBeenCalledWith("FN-002");
});
});
// ── Completed task recovery ─────────────────────────────────────────
describe("recoverCompletedTasks", () => {

View File

@@ -476,20 +476,29 @@ export class SelfHealingManager {
// ── Auto-archive of stale done tasks ──────────────────────────────
/**
* Auto-archive done tasks older than 48 hours so the dashboard board view
* stops accumulating thousands of completed tasks. Data remains in SQLite —
* the task is moved from `done` to `archived`, which the slim list endpoint
* excludes by default. Users can still expand the archived column or unarchive.
* Auto-archive done tasks older than the project retention setting so the
* active task database does not accumulate completed task payloads forever.
* Archived task metadata is retained in the separate archive database and can
* be restored by unarchiving.
*/
private static readonly AUTO_ARCHIVE_AFTER_MS = 48 * 60 * 60 * 1000;
async archiveStaleDoneTasks(): Promise<number> {
try {
const settings = await this.store.getSettings();
if (settings.autoArchiveDoneTasksEnabled === false) {
return 0;
}
const archiveAfterMs = settings.autoArchiveDoneAfterMs ?? SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
if (!Number.isFinite(archiveAfterMs) || archiveAfterMs <= 0) {
return 0;
}
// Slim listing — we only need id/column/columnMovedAt/updatedAt to decide
// staleness. Pulling full task payloads (logs, comments, steps) here used
// to drag in tens of MB on busy boards and stalled the maintenance loop.
const tasks = await this.store.listTasks({ slim: true });
const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
const cutoff = Date.now() - archiveAfterMs;
const stale = tasks.filter((t) => {
if (t.column !== "done") return false;
@@ -503,12 +512,12 @@ export class SelfHealingManager {
if (stale.length === 0) return 0;
log.log(`Auto-archiving ${stale.length} done task(s) older than 48h`);
log.log(`Auto-archiving ${stale.length} done task(s) older than ${archiveAfterMs}ms`);
let archived = 0;
for (const task of stale) {
try {
await this.store.archiveTask(task.id);
await this.store.archiveTaskAndCleanup(task.id);
archived++;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Failed to auto-archive ${task.id}: ${errorMessage}`);