feat(FN-4192): add task ID integrity detection and dashboard alerts
Implements task ID integrity detection across the stack (FN-4192): a new `task-id-integrity` module in core provides detector logic wired into the store, integrity health is surfaced via the dashboard API and legacy endpoint, and a banner component surfaces alerts in the UI — backed by comprehensive Fusion-Task-Id: FN-4192
This commit is contained in:
111
packages/core/src/__tests__/store-task-id-integrity.test.ts
Normal file
111
packages/core/src/__tests__/store-task-id-integrity.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Database } from "../db.js";
|
||||
import { TaskStore } from "../store.js";
|
||||
import { makeTmpDir } from "./store-test-helpers.js";
|
||||
|
||||
async function seedIntegrityPrecondition(rootDir: string): Promise<void> {
|
||||
const fusionDir = join(rootDir, ".fusion");
|
||||
await mkdir(fusionDir, { recursive: true });
|
||||
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
|
||||
).run("FN-100", now, now);
|
||||
db.prepare(
|
||||
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 100, 0, null, now);
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("TaskStore task ID integrity wiring", () => {
|
||||
let rootDir = "";
|
||||
let globalDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = makeTmpDir();
|
||||
globalDir = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("constructs cleanly and exposes an ok integrity report by default", async () => {
|
||||
const store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
const report = store.getTaskIdIntegrityReport();
|
||||
|
||||
expect(report.status).toBe("ok");
|
||||
expect(report.anomalies).toEqual([]);
|
||||
expect(report.checkedAt).toEqual(expect.any(String));
|
||||
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("logs a structured core error and exposes anomaly status when startup detects corruption preconditions", async () => {
|
||||
await seedIntegrityPrecondition(rootDir);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
const report = store.getTaskIdIntegrityReport();
|
||||
expect(report.status).toBe("anomaly");
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[core] [task-id-integrity] anomaly detected"),
|
||||
expect.objectContaining({
|
||||
anomalies: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("refreshTaskIdIntegrityReport picks up newly introduced anomalies", async () => {
|
||||
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
const db = store.getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
|
||||
).run("FN-100", now, now);
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 100, 0, null, now);
|
||||
|
||||
const report = store.refreshTaskIdIntegrityReport();
|
||||
|
||||
expect(report.status).toBe("anomaly");
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
);
|
||||
expect(store.getTaskIdIntegrityReport()).toEqual(report);
|
||||
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
154
packages/core/src/__tests__/task-id-integrity.test.ts
Normal file
154
packages/core/src/__tests__/task-id-integrity.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Database } from "../db.js";
|
||||
import { detectTaskIdIntegrityAnomalies } from "../task-id-integrity.js";
|
||||
|
||||
function createDb(): Database {
|
||||
const db = new Database("/tmp/fusion-task-id-integrity-test", { inMemory: true });
|
||||
db.init();
|
||||
return db;
|
||||
}
|
||||
|
||||
function insertTask(db: Database, id: string): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
|
||||
).run(id, now, now);
|
||||
}
|
||||
|
||||
describe("detectTaskIdIntegrityAnomalies", () => {
|
||||
it("returns ok for a clean database", () => {
|
||||
const db = createDb();
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.status).toBe("ok");
|
||||
expect(report.checkedAt).toEqual(expect.any(String));
|
||||
expect(report.anomalies).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns ok when allocator tables are missing", () => {
|
||||
const db = createDb();
|
||||
db.exec("DROP TABLE distributed_task_id_reservations");
|
||||
db.exec("DROP TABLE distributed_task_id_state");
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.status).toBe("ok");
|
||||
expect(report.anomalies).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects duplicate active task IDs", () => {
|
||||
const db = createDb();
|
||||
db.exec("ALTER TABLE tasks RENAME TO tasks_original");
|
||||
db.exec("CREATE TABLE tasks (id TEXT NOT NULL, description TEXT, \"column\" TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||
db.exec(`
|
||||
INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES
|
||||
('FN-101', '', 'todo', '2026-05-12T00:00:00.000Z', '2026-05-12T00:00:00.000Z'),
|
||||
('FN-101', '', 'todo', '2026-05-12T00:00:01.000Z', '2026-05-12T00:00:01.000Z')
|
||||
`);
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.status).toBe("anomaly");
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "duplicate_active_id",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-101"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects IDs present in both active and archived storage", () => {
|
||||
const db = createDb();
|
||||
insertTask(db, "FN-102");
|
||||
db.prepare("INSERT INTO archivedTasks (id, data, archivedAt) VALUES (?, ?, ?)").run(
|
||||
"FN-102",
|
||||
JSON.stringify({ id: "FN-102" }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "id_in_active_and_archived",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-102"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects stale nextSequence values at or below an existing used sequence", () => {
|
||||
const db = createDb();
|
||||
const now = new Date().toISOString();
|
||||
insertTask(db, "FN-100");
|
||||
db.prepare(
|
||||
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 100, 0, null, now);
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects committed reservations that target existing task IDs", () => {
|
||||
const db = createDb();
|
||||
const now = new Date().toISOString();
|
||||
insertTask(db, "FN-103");
|
||||
db.prepare(
|
||||
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 104, 1, "FN-103", now);
|
||||
db.prepare(
|
||||
`INSERT INTO distributed_task_id_reservations (
|
||||
reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, committedAt, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, 'committed', NULL, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
"res-103",
|
||||
"FN",
|
||||
"node-a",
|
||||
103,
|
||||
"FN-103",
|
||||
new Date(Date.now() + 60_000).toISOString(),
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "committed_reservation_for_existing_id",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-103"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects active task rows whose prefix is outside distributed state", () => {
|
||||
const db = createDb();
|
||||
const now = new Date().toISOString();
|
||||
insertTask(db, "KB-001");
|
||||
db.prepare(
|
||||
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 2, 0, null, now);
|
||||
|
||||
const report = detectTaskIdIntegrityAnomalies(db);
|
||||
|
||||
expect(report.anomalies).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "task_row_outside_known_prefix",
|
||||
prefix: "KB",
|
||||
affectedIds: ["KB-001"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,14 @@ export type {
|
||||
AgentProvisioningPolicyDecision,
|
||||
} from "./agent-provisioning-policy.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export {
|
||||
detectTaskIdIntegrityAnomalies,
|
||||
} from "./task-id-integrity.js";
|
||||
export type {
|
||||
TaskIdIntegrityAnomaly,
|
||||
TaskIdIntegrityAnomalyKind,
|
||||
TaskIdIntegrityReport,
|
||||
} from "./task-id-integrity.js";
|
||||
export {
|
||||
FUSION_TASK_LINEAGE_TRAILER_KEY,
|
||||
buildTaskLineageTrailer,
|
||||
|
||||
@@ -29,6 +29,10 @@ import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
|
||||
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||
import {
|
||||
detectTaskIdIntegrityAnomalies,
|
||||
type TaskIdIntegrityReport,
|
||||
} from "./task-id-integrity.js";
|
||||
import {
|
||||
buildBootstrapPrompt,
|
||||
replicationCollisionError,
|
||||
@@ -279,6 +283,7 @@ const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE =
|
||||
"\n\n[tool output truncated to keep dashboard log views responsive]";
|
||||
const AGENT_LOG_TOOL_TYPES = new Set<AgentLogEntry["type"]>(["tool", "tool_result", "tool_error"]);
|
||||
const storeLog = createLogger("task-store");
|
||||
const coreLog = createLogger("core");
|
||||
|
||||
/**
|
||||
* Reject branch names that would be unsafe to interpolate into a shell command.
|
||||
@@ -587,6 +592,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private configLock: Promise<void> = Promise.resolve();
|
||||
/** Startup/open guard for distributed_task_id_state reconciliation. */
|
||||
private taskIdStateReconciled = false;
|
||||
/** Cached startup/refresh integrity report for allocator-related task ID anomalies. */
|
||||
private taskIdIntegrityReport: TaskIdIntegrityReport = {
|
||||
status: "ok",
|
||||
checkedAt: new Date().toISOString(),
|
||||
anomalies: [],
|
||||
};
|
||||
/** Prevent duplicate anomaly logs when the report content has not changed. */
|
||||
private lastTaskIdIntegrityLogSignature: string | null = null;
|
||||
/** Cached workflow steps — invalidated on create/update/delete */
|
||||
private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null;
|
||||
/** Plugin-contributed workflow step templates injected by engine runtime. */
|
||||
@@ -708,11 +721,74 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return this._archiveDb;
|
||||
}
|
||||
|
||||
private buildTaskIdIntegrityFallbackReport(): TaskIdIntegrityReport {
|
||||
return {
|
||||
status: "ok",
|
||||
checkedAt: new Date().toISOString(),
|
||||
anomalies: [],
|
||||
};
|
||||
}
|
||||
|
||||
private detectAndCacheTaskIdIntegrityReport(): TaskIdIntegrityReport {
|
||||
const report = detectTaskIdIntegrityAnomalies(this.db);
|
||||
this.taskIdIntegrityReport = report;
|
||||
const signature = report.status === "anomaly" ? JSON.stringify(report.anomalies) : null;
|
||||
if (report.status === "anomaly" && signature !== this.lastTaskIdIntegrityLogSignature) {
|
||||
coreLog.error("[task-id-integrity] anomaly detected", { anomalies: report.anomalies });
|
||||
}
|
||||
this.lastTaskIdIntegrityLogSignature = signature;
|
||||
return report;
|
||||
}
|
||||
|
||||
private mergeTaskIdIntegrityReports(...reports: TaskIdIntegrityReport[]): TaskIdIntegrityReport {
|
||||
const checkedAt = reports[reports.length - 1]?.checkedAt ?? new Date().toISOString();
|
||||
const seen = new Set<string>();
|
||||
const anomalies = reports.flatMap((report) => report.anomalies).filter((anomaly) => {
|
||||
const key = JSON.stringify(anomaly);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
status: anomalies.length > 0 ? "anomaly" : "ok",
|
||||
checkedAt,
|
||||
anomalies,
|
||||
};
|
||||
}
|
||||
|
||||
refreshTaskIdIntegrityReport(): TaskIdIntegrityReport {
|
||||
try {
|
||||
return this.detectAndCacheTaskIdIntegrityReport();
|
||||
} catch (error) {
|
||||
const fallback = this.buildTaskIdIntegrityFallbackReport();
|
||||
this.taskIdIntegrityReport = fallback;
|
||||
this.lastTaskIdIntegrityLogSignature = null;
|
||||
coreLog.warn("[task-id-integrity] detector failed; degrading to healthy report", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
getTaskIdIntegrityReport(): TaskIdIntegrityReport {
|
||||
return this.taskIdIntegrityReport;
|
||||
}
|
||||
|
||||
private reconcileDistributedTaskIdStateOnOpen(): void {
|
||||
if (this.taskIdStateReconciled) {
|
||||
return;
|
||||
}
|
||||
const previousReport = this.taskIdIntegrityReport;
|
||||
const preReconcileReport = this.refreshTaskIdIntegrityReport();
|
||||
reconcileTaskIdState(this.db);
|
||||
const postReconcileReport = this.refreshTaskIdIntegrityReport();
|
||||
this.taskIdIntegrityReport = this.mergeTaskIdIntegrityReports(
|
||||
previousReport,
|
||||
preReconcileReport,
|
||||
postReconcileReport,
|
||||
);
|
||||
this.taskIdStateReconciled = true;
|
||||
}
|
||||
|
||||
|
||||
240
packages/core/src/task-id-integrity.ts
Normal file
240
packages/core/src/task-id-integrity.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { Database } from "./db.js";
|
||||
|
||||
const TASK_ID_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/;
|
||||
|
||||
export type TaskIdIntegrityAnomalyKind =
|
||||
| "duplicate_active_id"
|
||||
| "id_in_active_and_archived"
|
||||
| "next_sequence_at_or_below_used"
|
||||
| "committed_reservation_for_existing_id"
|
||||
| "task_row_outside_known_prefix";
|
||||
|
||||
export interface TaskIdIntegrityAnomaly {
|
||||
kind: TaskIdIntegrityAnomalyKind;
|
||||
prefix: string;
|
||||
affectedIds: string[];
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface TaskIdIntegrityReport {
|
||||
status: "ok" | "anomaly";
|
||||
checkedAt: string;
|
||||
anomalies: TaskIdIntegrityAnomaly[];
|
||||
}
|
||||
|
||||
type TaskRow = { id: string; source: "tasks" | "archivedTasks" };
|
||||
type StateRow = { prefix: string; nextSequence: number };
|
||||
type ReservationRow = { prefix: string; taskId: string; status: string };
|
||||
type DuplicateRow = { id: string; duplicateCount: number };
|
||||
|
||||
function parseTaskId(taskId: string): { prefix: string; sequence: number } | null {
|
||||
const match = taskId.trim().toUpperCase().match(TASK_ID_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sequence = Number.parseInt(match[2], 10);
|
||||
if (!Number.isFinite(sequence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { prefix: match[1], sequence };
|
||||
}
|
||||
|
||||
function hasTable(db: Database, table: string): boolean {
|
||||
try {
|
||||
const rows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.all(table) as Array<{ name: string }>;
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readTaskRows(db: Database, table: "tasks" | "archivedTasks"): TaskRow[] {
|
||||
if (!hasTable(db, table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return db
|
||||
.prepare(`SELECT id FROM ${table}`)
|
||||
.all()
|
||||
.map((row) => ({ id: String((row as { id?: unknown }).id ?? ""), source: table }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readStateRows(db: Database): StateRow[] {
|
||||
if (!hasTable(db, "distributed_task_id_state")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return db.prepare("SELECT prefix, nextSequence FROM distributed_task_id_state").all() as StateRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readCommittedReservations(db: Database): ReservationRow[] {
|
||||
if (!hasTable(db, "distributed_task_id_reservations")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return db
|
||||
.prepare("SELECT prefix, taskId, status FROM distributed_task_id_reservations WHERE status = 'committed'")
|
||||
.all() as ReservationRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readDuplicateActiveIds(db: Database): DuplicateRow[] {
|
||||
if (!hasTable(db, "tasks")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return db.prepare("SELECT id, COUNT(*) AS duplicateCount FROM tasks GROUP BY id HAVING COUNT(*) > 1").all() as DuplicateRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Iterable<string>): string[] {
|
||||
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function buildReport(checkedAt: string, anomalies: TaskIdIntegrityAnomaly[]): TaskIdIntegrityReport {
|
||||
return {
|
||||
status: anomalies.length > 0 ? "anomaly" : "ok",
|
||||
checkedAt,
|
||||
anomalies,
|
||||
};
|
||||
}
|
||||
|
||||
export function detectTaskIdIntegrityAnomalies(db: Database): TaskIdIntegrityReport {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const anomalies: TaskIdIntegrityAnomaly[] = [];
|
||||
const activeRows = readTaskRows(db, "tasks");
|
||||
const archivedRows = readTaskRows(db, "archivedTasks");
|
||||
const allRows = [...activeRows, ...archivedRows];
|
||||
|
||||
for (const row of readDuplicateActiveIds(db)) {
|
||||
const parsed = parseTaskId(row.id);
|
||||
anomalies.push({
|
||||
kind: "duplicate_active_id",
|
||||
prefix: parsed?.prefix ?? "unknown",
|
||||
affectedIds: [row.id],
|
||||
details: `Active tasks contains ${row.duplicateCount} rows for ${row.id}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const archivedIds = new Set(archivedRows.map((row) => row.id));
|
||||
const activeAndArchived = uniqueSorted(activeRows.map((row) => row.id).filter((id) => archivedIds.has(id)));
|
||||
if (activeAndArchived.length > 0) {
|
||||
const byPrefix = new Map<string, string[]>();
|
||||
for (const taskId of activeAndArchived) {
|
||||
const prefix = parseTaskId(taskId)?.prefix ?? "unknown";
|
||||
byPrefix.set(prefix, [...(byPrefix.get(prefix) ?? []), taskId]);
|
||||
}
|
||||
for (const [prefix, affectedIds] of byPrefix) {
|
||||
anomalies.push({
|
||||
kind: "id_in_active_and_archived",
|
||||
prefix,
|
||||
affectedIds,
|
||||
details: `Task IDs exist in both active and archived storage for prefix ${prefix}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const maxUsedSequenceByPrefix = new Map<string, { maxSequence: number; taskIds: string[] }>();
|
||||
for (const row of allRows) {
|
||||
const parsed = parseTaskId(row.id);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const existing = maxUsedSequenceByPrefix.get(parsed.prefix);
|
||||
if (!existing || parsed.sequence > existing.maxSequence) {
|
||||
maxUsedSequenceByPrefix.set(parsed.prefix, { maxSequence: parsed.sequence, taskIds: [row.id] });
|
||||
continue;
|
||||
}
|
||||
if (parsed.sequence === existing.maxSequence) {
|
||||
existing.taskIds.push(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const stateRow of readStateRows(db)) {
|
||||
const prefix = stateRow.prefix.trim().toUpperCase();
|
||||
const maxUsed = maxUsedSequenceByPrefix.get(prefix);
|
||||
if (!maxUsed) {
|
||||
continue;
|
||||
}
|
||||
if (stateRow.nextSequence <= maxUsed.maxSequence) {
|
||||
anomalies.push({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix,
|
||||
affectedIds: uniqueSorted(maxUsed.taskIds),
|
||||
details: `distributed_task_id_state.nextSequence=${stateRow.nextSequence} is at or below existing sequence ${maxUsed.maxSequence} for prefix ${prefix}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTable(db, "distributed_task_id_state")) {
|
||||
const knownPrefixes = new Set(
|
||||
readStateRows(db)
|
||||
.map((row) => row.prefix.trim().toUpperCase())
|
||||
.filter((prefix) => prefix.length > 0),
|
||||
);
|
||||
if (knownPrefixes.size > 0) {
|
||||
const outsideKnownPrefix = new Map<string, string[]>();
|
||||
for (const row of activeRows) {
|
||||
const parsed = parseTaskId(row.id);
|
||||
const prefix = parsed?.prefix ?? "unknown";
|
||||
if (!parsed || !knownPrefixes.has(prefix)) {
|
||||
outsideKnownPrefix.set(prefix, [...(outsideKnownPrefix.get(prefix) ?? []), row.id]);
|
||||
}
|
||||
}
|
||||
for (const [prefix, affectedIds] of outsideKnownPrefix) {
|
||||
anomalies.push({
|
||||
kind: "task_row_outside_known_prefix",
|
||||
prefix,
|
||||
affectedIds: uniqueSorted(affectedIds),
|
||||
details:
|
||||
prefix === "unknown"
|
||||
? "Active task rows contain IDs that do not match the expected PREFIX-123 format."
|
||||
: `Active task rows use prefix ${prefix}, which is not declared in distributed_task_id_state.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingIds = new Set(allRows.map((row) => row.id));
|
||||
const committedReservationsByPrefix = new Map<string, string[]>();
|
||||
for (const row of readCommittedReservations(db)) {
|
||||
if (!existingIds.has(row.taskId)) {
|
||||
continue;
|
||||
}
|
||||
const prefix = row.prefix.trim().toUpperCase() || parseTaskId(row.taskId)?.prefix || "unknown";
|
||||
committedReservationsByPrefix.set(prefix, [...(committedReservationsByPrefix.get(prefix) ?? []), row.taskId]);
|
||||
}
|
||||
for (const [prefix, affectedIds] of committedReservationsByPrefix) {
|
||||
anomalies.push({
|
||||
kind: "committed_reservation_for_existing_id",
|
||||
prefix,
|
||||
affectedIds: uniqueSorted(affectedIds),
|
||||
details: `Committed reservation rows still reference task IDs that already exist for prefix ${prefix}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return buildReport(checkedAt, anomalies);
|
||||
} catch {
|
||||
return buildReport(checkedAt, []);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
|
||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
|
||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||
@@ -64,8 +65,8 @@ import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingMo
|
||||
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
||||
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
|
||||
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { api, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||
import type { AiSessionSummary, DashboardHealthResponse } from "./api";
|
||||
import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
import { subscribeSse } from "./sse-bus";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||
@@ -651,6 +652,7 @@ function AppInner() {
|
||||
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [quickChatOpen, setQuickChatOpen] = useState(false);
|
||||
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
|
||||
const [dashboardHealth, setDashboardHealth] = useState<DashboardHealthResponse | null>(null);
|
||||
const [setupWarningDismissed, setSetupWarningDismissed] = useState(
|
||||
() => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
|
||||
);
|
||||
@@ -661,6 +663,26 @@ function AppInner() {
|
||||
);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchDashboardHealth()
|
||||
.then((health) => {
|
||||
if (!cancelled) {
|
||||
setDashboardHealth(health);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDashboardHealth(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDaemonAuthFailure = () => {
|
||||
setAuthTokenRecoveryOpen(true);
|
||||
@@ -1602,6 +1624,27 @@ function AppInner() {
|
||||
onDismiss={dismissUpdateBanner}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && (
|
||||
<TaskIdIntegrityBanner
|
||||
report={dashboardHealth.taskIdIntegrity}
|
||||
recommendedAction={dashboardHealth.taskIdIntegrity.recommendedAction}
|
||||
onRefresh={(report, recommendedAction) => {
|
||||
setDashboardHealth((current) => {
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
status: report.status === "anomaly" || !current.database.healthy ? "degraded" : "ok",
|
||||
taskIdIntegrity: {
|
||||
...report,
|
||||
recommendedAction,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={hasAiProvider}
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
DockerNodeStatus,
|
||||
ProjectNodePathMapping,
|
||||
ApprovalRequestStatus,
|
||||
TaskIdIntegrityReport,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
@@ -204,12 +205,19 @@ export interface DashboardHealthResponse {
|
||||
lastCheckedAt: string | null;
|
||||
isRunning: boolean;
|
||||
};
|
||||
taskIdIntegrity: TaskIdIntegrityReport & {
|
||||
recommendedAction: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
||||
return api<DashboardHealthResponse>("/health");
|
||||
}
|
||||
|
||||
export function refreshDashboardHealth(): Promise<DashboardHealthResponse> {
|
||||
return api<DashboardHealthResponse>("/health/refresh", { method: "POST" });
|
||||
}
|
||||
|
||||
export function checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
return api<UpdateCheckResponse>("/updates/check");
|
||||
}
|
||||
|
||||
98
packages/dashboard/app/components/TaskIdIntegrityBanner.css
Normal file
98
packages/dashboard/app/components/TaskIdIntegrityBanner.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* === TaskIdIntegrityBanner === */
|
||||
.task-id-integrity-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 24%, transparent);
|
||||
border-inline-start: var(--space-xs) solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__headline-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__headline {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__body,
|
||||
.task-id-integrity-banner__footer,
|
||||
.task-id-integrity-banner__error,
|
||||
.task-id-integrity-banner__item-detail {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding-inline-start: calc(var(--space-lg) + var(--space-xs));
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__item-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__ids {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__refresh {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__refresh-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__refresh-icon--spinning {
|
||||
animation: status-dot-pulse var(--transition-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-id-integrity-banner {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-id-integrity-banner__refresh {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
93
packages/dashboard/app/components/TaskIdIntegrityBanner.tsx
Normal file
93
packages/dashboard/app/components/TaskIdIntegrityBanner.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { TaskIdIntegrityReport } from "@fusion/core";
|
||||
import { refreshDashboardHealth } from "../api";
|
||||
import "./TaskIdIntegrityBanner.css";
|
||||
|
||||
interface TaskIdIntegrityBannerProps {
|
||||
report: TaskIdIntegrityReport;
|
||||
recommendedAction: string;
|
||||
onRefresh?: (report: TaskIdIntegrityReport, recommendedAction: string | null) => void;
|
||||
}
|
||||
|
||||
const ANOMALY_LABELS: Record<TaskIdIntegrityReport["anomalies"][number]["kind"], string> = {
|
||||
duplicate_active_id: "Duplicate active task ID",
|
||||
id_in_active_and_archived: "Task ID present in active and archived storage",
|
||||
next_sequence_at_or_below_used: "Allocator next sequence overlaps an existing task ID",
|
||||
committed_reservation_for_existing_id: "Committed reservation still points at an existing task ID",
|
||||
task_row_outside_known_prefix: "Task row uses a prefix outside allocator state",
|
||||
};
|
||||
|
||||
function formatAffectedIds(affectedIds: string[]): string {
|
||||
if (affectedIds.length <= 5) {
|
||||
return affectedIds.join(", ");
|
||||
}
|
||||
|
||||
const visible = affectedIds.slice(0, 5).join(", ");
|
||||
return `${visible} +${affectedIds.length - 5} more`;
|
||||
}
|
||||
|
||||
export function TaskIdIntegrityBanner({ report, recommendedAction, onRefresh }: TaskIdIntegrityBannerProps) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
|
||||
if (report.status !== "anomaly") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
setRefreshError(null);
|
||||
try {
|
||||
const health = await refreshDashboardHealth();
|
||||
onRefresh?.(health.taskIdIntegrity, health.taskIdIntegrity.recommendedAction);
|
||||
} catch (error) {
|
||||
setRefreshError(error instanceof Error ? error.message : "Failed to refresh integrity status.");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="task-id-integrity-banner" role="alert" aria-live="assertive">
|
||||
<div className="task-id-integrity-banner__header">
|
||||
<div className="task-id-integrity-banner__headline-wrap">
|
||||
<span className="status-dot status-dot--error" aria-hidden="true" />
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<h2 className="task-id-integrity-banner__headline">Task ID integrity anomaly detected</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm task-id-integrity-banner__refresh"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={refreshing ? "task-id-integrity-banner__refresh-icon task-id-integrity-banner__refresh-icon--spinning" : "task-id-integrity-banner__refresh-icon"} aria-hidden="true" />
|
||||
{refreshing ? "Re-checking…" : "Re-check"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="task-id-integrity-banner__body">
|
||||
Fusion found allocator state that can cause task IDs to be reused or overwrite live task records.
|
||||
</p>
|
||||
|
||||
<ul className="task-id-integrity-banner__list">
|
||||
{report.anomalies.map((anomaly) => (
|
||||
<li
|
||||
key={`${anomaly.kind}:${anomaly.prefix}:${anomaly.affectedIds.join(",")}`}
|
||||
className="task-id-integrity-banner__item"
|
||||
>
|
||||
<strong className="task-id-integrity-banner__item-title">{ANOMALY_LABELS[anomaly.kind]}</strong>
|
||||
<span className="task-id-integrity-banner__item-detail">{anomaly.details}</span>
|
||||
<code className="task-id-integrity-banner__ids">{formatAffectedIds(anomaly.affectedIds)}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="task-id-integrity-banner__footer">{recommendedAction}</p>
|
||||
{refreshError ? <p className="task-id-integrity-banner__error">{refreshError}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,13 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
||||
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
||||
fetchDashboardHealth: vi.fn(() => Promise.resolve({
|
||||
status: "ok",
|
||||
version: "1.0.0",
|
||||
uptime: 1,
|
||||
database: { healthy: true, lastCheckedAt: null, isRunning: false },
|
||||
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
|
||||
})),
|
||||
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
||||
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
||||
globalPause: false,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TaskIdIntegrityBanner } from "../TaskIdIntegrityBanner";
|
||||
|
||||
const mockRefreshDashboardHealth = vi.fn();
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...original,
|
||||
refreshDashboardHealth: (...args: unknown[]) => mockRefreshDashboardHealth(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const anomalyReport = {
|
||||
status: "anomaly" as const,
|
||||
checkedAt: "2026-05-12T10:00:00.000Z",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "next_sequence_at_or_below_used" as const,
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100", "FN-101", "FN-102", "FN-103", "FN-104", "FN-105"],
|
||||
details: "Allocator state overlaps an existing task.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("TaskIdIntegrityBanner", () => {
|
||||
it("renders nothing when the report is healthy", () => {
|
||||
const { container } = render(
|
||||
<TaskIdIntegrityBanner
|
||||
report={{ status: "ok", checkedAt: "2026-05-12T10:00:00.000Z", anomalies: [] }}
|
||||
recommendedAction="Pause task delegation."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the alert headline, affected IDs, and recommended action for anomalies", () => {
|
||||
render(
|
||||
<TaskIdIntegrityBanner
|
||||
report={anomalyReport}
|
||||
recommendedAction="Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task ID integrity anomaly detected")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-100, FN-101, FN-102, FN-103, FN-104 +1 more")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("re-checks health on demand", async () => {
|
||||
mockRefreshDashboardHealth.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
version: "1.0.0",
|
||||
uptime: 1,
|
||||
database: { healthy: true, lastCheckedAt: null, isRunning: false },
|
||||
taskIdIntegrity: {
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T10:05:00.000Z",
|
||||
anomalies: [],
|
||||
recommendedAction: null,
|
||||
},
|
||||
});
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskIdIntegrityBanner
|
||||
report={anomalyReport}
|
||||
recommendedAction="Pause task delegation."
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Re-check" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshDashboardHealth).toHaveBeenCalledTimes(1);
|
||||
expect(onRefresh).toHaveBeenCalledWith(
|
||||
{ status: "ok", checkedAt: "2026-05-12T10:05:00.000Z", anomalies: [], recommendedAction: null },
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,18 @@ class MockStore extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
getTaskIdIntegrityReport() {
|
||||
return {
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||
anomalies: [],
|
||||
};
|
||||
}
|
||||
|
||||
refreshTaskIdIntegrityReport() {
|
||||
return this.getTaskIdIntegrityReport();
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express from "express";
|
||||
import { createServer, setupTerminalWebSocket } from "../server.js";
|
||||
import { toSessionTag } from "../terminal-websocket-diagnostics.js";
|
||||
import { RATE_LIMITS } from "../rate-limit.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { Database, TaskStore } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
|
||||
// Mock terminal-service before any imports that use it
|
||||
@@ -44,6 +46,24 @@ const CLI_PACKAGE_VERSION = (() => {
|
||||
return typeof packageJson.version === "string" ? packageJson.version : "";
|
||||
})();
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-server-test-"));
|
||||
}
|
||||
|
||||
async function seedTaskIdIntegrityPrecondition(rootDir: string): Promise<void> {
|
||||
const fusionDir = join(rootDir, ".fusion");
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
|
||||
).run("FN-100", now, now);
|
||||
db.prepare(
|
||||
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("FN", 100, 0, null, now);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
@@ -73,6 +93,16 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
isRunning: false,
|
||||
lastCheckedAt: null,
|
||||
}),
|
||||
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||
anomalies: [],
|
||||
}),
|
||||
refreshTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||
anomalies: [],
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
@@ -317,6 +347,12 @@ describe("createServer health and headless mode", () => {
|
||||
isRunning: false,
|
||||
lastCheckedAt: null,
|
||||
},
|
||||
taskIdIntegrity: {
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||
anomalies: [],
|
||||
recommendedAction: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,9 +378,172 @@ describe("createServer health and headless mode", () => {
|
||||
isRunning: false,
|
||||
lastCheckedAt: "2026-05-11T10:00:00.000Z",
|
||||
},
|
||||
taskIdIntegrity: {
|
||||
status: "ok",
|
||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||
anomalies: [],
|
||||
recommendedAction: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports degraded status when task ID integrity anomalies are present", async () => {
|
||||
const store = createMockStore({
|
||||
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
||||
status: "anomaly",
|
||||
checkedAt: "2026-05-12T10:00:00.000Z",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
details: "state drift",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const app = createServer(store);
|
||||
|
||||
const res = await GET(app, "/api/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: "degraded",
|
||||
version: CLI_PACKAGE_VERSION,
|
||||
uptime: expect.any(Number),
|
||||
database: {
|
||||
healthy: true,
|
||||
isRunning: false,
|
||||
lastCheckedAt: null,
|
||||
},
|
||||
taskIdIntegrity: {
|
||||
status: "anomaly",
|
||||
checkedAt: "2026-05-12T10:00:00.000Z",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
details: "state drift",
|
||||
},
|
||||
],
|
||||
recommendedAction:
|
||||
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes task ID integrity health on demand", async () => {
|
||||
const store = createMockStore({
|
||||
refreshTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
||||
status: "anomaly",
|
||||
checkedAt: "2026-05-12T11:00:00.000Z",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "committed_reservation_for_existing_id",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-103"],
|
||||
details: "reservation collision",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const app = createServer(store);
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/health/refresh");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.refreshTaskIdIntegrityReport).toHaveBeenCalledTimes(1);
|
||||
expect(res.body).toEqual({
|
||||
status: "degraded",
|
||||
version: CLI_PACKAGE_VERSION,
|
||||
uptime: expect.any(Number),
|
||||
database: {
|
||||
healthy: true,
|
||||
isRunning: false,
|
||||
lastCheckedAt: null,
|
||||
},
|
||||
taskIdIntegrity: {
|
||||
status: "anomaly",
|
||||
checkedAt: "2026-05-12T11:00:00.000Z",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "committed_reservation_for_existing_id",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-103"],
|
||||
details: "reservation collision",
|
||||
},
|
||||
],
|
||||
recommendedAction:
|
||||
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces startup-detected nextSequence collisions end-to-end through /api/health", async () => {
|
||||
const rootDir = makeTmpDir();
|
||||
const globalDir = makeTmpDir();
|
||||
await seedTaskIdIntegrityPrecondition(rootDir);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const store = new TaskStore(rootDir, globalDir);
|
||||
try {
|
||||
await store.init();
|
||||
const app = createServer(store, { headless: true });
|
||||
|
||||
expect(store.getTaskIdIntegrityReport()).toMatchObject({
|
||||
status: "anomaly",
|
||||
anomalies: [
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[core] [task-id-integrity] anomaly detected"),
|
||||
expect.objectContaining({
|
||||
anomalies: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
affectedIds: ["FN-100"],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await GET(app, "/api/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: "degraded",
|
||||
database: {
|
||||
healthy: true,
|
||||
isRunning: expect.any(Boolean),
|
||||
lastCheckedAt: null,
|
||||
},
|
||||
taskIdIntegrity: {
|
||||
status: "anomaly",
|
||||
anomalies: [
|
||||
{
|
||||
kind: "next_sequence_at_or_below_used",
|
||||
prefix: "FN",
|
||||
affectedIds: ["FN-100"],
|
||||
details: expect.any(String),
|
||||
},
|
||||
],
|
||||
recommendedAction:
|
||||
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
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("does not return stale hardcoded 0.4.0 unless package version is 0.4.0", async () => {
|
||||
const store = createMockStore();
|
||||
const app = createServer(store);
|
||||
|
||||
@@ -5,7 +5,17 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2";
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, CentralCore, MessageStore, AgentLogEntry } from "@fusion/core";
|
||||
import type {
|
||||
Task,
|
||||
TaskStore,
|
||||
MergeResult,
|
||||
AutomationStore,
|
||||
RoutineStore,
|
||||
CentralCore,
|
||||
MessageStore,
|
||||
AgentLogEntry,
|
||||
TaskIdIntegrityReport,
|
||||
} from "@fusion/core";
|
||||
import { AgentStore, ChatStore } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
@@ -61,6 +71,30 @@ function parseVersion(version: string): number[] {
|
||||
.map((value) => (Number.isFinite(value) ? value : 0));
|
||||
}
|
||||
|
||||
function buildTaskIdIntegrityHealth(report: TaskIdIntegrityReport) {
|
||||
return {
|
||||
status: report.status,
|
||||
checkedAt: report.checkedAt,
|
||||
anomalies: report.anomalies,
|
||||
recommendedAction:
|
||||
report.status === "anomaly"
|
||||
? "Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHealthPayload(store: TaskStore, cliPackageVersion: string) {
|
||||
const database = store.getDatabaseHealth();
|
||||
const taskIdIntegrity = buildTaskIdIntegrityHealth(store.getTaskIdIntegrityReport());
|
||||
return {
|
||||
status: !database.healthy || taskIdIntegrity.status === "anomaly" ? "degraded" : "ok",
|
||||
version: cliPackageVersion,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
database,
|
||||
taskIdIntegrity,
|
||||
};
|
||||
}
|
||||
|
||||
function isRemoteVersionNewer(remoteVersion: string, currentVersion: string): boolean {
|
||||
const remote = parseVersion(remoteVersion);
|
||||
const current = parseVersion(currentVersion);
|
||||
@@ -1062,12 +1096,18 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
}
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json(buildHealthPayload(store, cliPackageVersion));
|
||||
});
|
||||
|
||||
app.post("/api/health/refresh", (_req, res) => {
|
||||
const report = store.refreshTaskIdIntegrityReport();
|
||||
const database = store.getDatabaseHealth();
|
||||
res.json({
|
||||
status: database.healthy ? "ok" : "degraded",
|
||||
status: !database.healthy || report.status === "anomaly" ? "degraded" : "ok",
|
||||
version: cliPackageVersion,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
database,
|
||||
taskIdIntegrity: buildTaskIdIntegrityHealth(report),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user