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, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user