feat(FN-5577): add github-tracking reconciler for deleted archived tasks

Added GitHub tracking reconciliation to sync hidden and deleted archived tasks on engine startup, spanning a new reconcile task listing method in the core store, a reconciler pass in the dashboard, and comprehensive test coverage for both the store listing and the deleted-archived reconciliation log

Fusion-Task-Id: FN-5577

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5577
This commit is contained in:
gsxdsm
2026-05-23 21:11:08 -07:00
parent 13b3f53dea
commit 41727cd3bb
5 changed files with 288 additions and 9 deletions

View File

@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-github-reconcile-test-"));
}
describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("returns soft-deleted and archived tasks with github tracking", async () => {
const softDeleted = await store.createTask({ description: "soft deleted" });
await store.updateGithubTracking(softDeleted.id, { enabled: true });
await store.deleteTask(softDeleted.id);
const archivedDone = await store.createTask({ description: "archived done" });
await store.updateGithubTracking(archivedDone.id, { enabled: true });
await store.moveTask(archivedDone.id, "todo");
await store.moveTask(archivedDone.id, "in-progress");
await store.moveTask(archivedDone.id, "in-review");
await store.moveTask(archivedDone.id, "done");
await store.archiveTask(archivedDone.id);
const archivedTodo = await store.createTask({ description: "archived todo" });
await store.updateGithubTracking(archivedTodo.id, { enabled: true });
await store.moveTask(archivedTodo.id, "todo");
await store.moveTask(archivedTodo.id, "in-progress");
await store.moveTask(archivedTodo.id, "in-review");
await store.moveTask(archivedTodo.id, "done");
await store.archiveTask(archivedTodo.id);
const archivedTodoEntry = (store as unknown as {
archiveDb: { get: (id: string) => { executionCompletedAt?: string } | undefined; upsert: (entry: Record<string, unknown>) => void };
}).archiveDb.get(archivedTodo.id);
if (archivedTodoEntry) {
(store as unknown as {
archiveDb: { upsert: (entry: Record<string, unknown>) => void };
}).archiveDb.upsert({ ...archivedTodoEntry, executionCompletedAt: undefined });
}
const activeTracked = await store.createTask({ description: "active tracked" });
await store.updateGithubTracking(activeTracked.id, { enabled: true });
const softDeletedWithoutTracking = await store.createTask({ description: "soft deleted no tracking" });
await store.deleteTask(softDeletedWithoutTracking.id);
const tasks = await store.listTasksForGithubTrackingReconcile();
const byId = new Map(tasks.map((task) => [task.id, task]));
expect(byId.has(softDeleted.id)).toBe(true);
expect(byId.has(archivedDone.id)).toBe(true);
expect(byId.has(archivedTodo.id)).toBe(true);
expect(byId.get(archivedDone.id)?.executionCompletedAt).toBeTruthy();
expect(byId.get(archivedTodo.id)?.executionCompletedAt).toBeFalsy();
expect(byId.has(activeTracked.id)).toBe(false);
expect(byId.has(softDeletedWithoutTracking.id)).toBe(false);
});
it("returns empty results when nothing matches", async () => {
const task = await store.createTask({ description: "no tracking" });
await store.moveTask(task.id, "todo");
const tasks = await store.listTasksForGithubTrackingReconcile();
expect(tasks).toEqual([]);
});
});

View File

@@ -4282,6 +4282,37 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
async listTasksForGithubTrackingReconcile(): Promise<Task[]> {
const reconcileScanLimit = 200;
const selectClause = this.getTaskSelectClause(true);
// FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows,
// so this query intentionally bypasses ACTIVE_TASKS_WHERE.
const deletedRows = this.db.prepare(
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ?`,
).all(reconcileScanLimit) as unknown as TaskRow[];
const deletedTasks = deletedRows.map((row) => {
const task = this.rowToTask(row);
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
return task;
});
let archivedTasks: Task[] = [];
try {
archivedTasks = this.archiveDb
.list()
.map((entry) => this.archiveEntryToTask(entry, true))
.filter((task) => Boolean(task.githubTracking))
.slice(0, reconcileScanLimit);
} catch {
archivedTasks = [];
}
return [...deletedTasks, ...archivedTasks].slice(0, reconcileScanLimit);
}
async listStrandedRefinements(options?: {
freshnessThresholdMs?: number;
}): Promise<Array<{