Files
fusion/scripts/audit-task-id-collisions.mjs
Fusion ab4242257a feat(FN-4055): add task ID collision audit script and recovery documentatio
Adds a standalone audit script for detecting task ID collisions, a regression test for store creation collision handling, and documentation of the recovery path; also applies minor UX refinements to ChatView and TaskDetailModal.

Fusion-Task-Id: FN-4055
2026-05-12 17:04:33 -07:00

301 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
function parseArgs(argv) {
const args = { projectRoot: process.cwd(), json: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--project-root") {
args.projectRoot = path.resolve(argv[i + 1] ?? process.cwd());
i += 1;
} else if (arg === "--json") {
args.json = true;
}
}
return args;
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
...options,
}).trim();
}
function sqliteJson(dbPath, sql) {
const output = run("sqlite3", ["-json", dbPath, sql]);
return output ? JSON.parse(output) : [];
}
function safeSqliteJson(dbPath, sql) {
try {
return sqliteJson(dbPath, sql);
} catch {
return [];
}
}
function resolveMainRef(projectRoot) {
try {
run("git", ["rev-parse", "--verify", "origin/main"], { cwd: projectRoot });
return "origin/main";
} catch {
return "main";
}
}
function normalizeTitle(text) {
return String(text ?? "")
.replace(/^#\s+/, "")
.replace(/^Task:\s*/i, "")
.replace(/^[A-Z]+-\d+\s*[:-]\s*/i, "")
.replace(/\s*\[via:[^\]]+\]\s*$/i, "")
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function firstHeading(promptText) {
const line = String(promptText ?? "").split(/\r?\n/).find((entry) => entry.trim().startsWith("#"));
if (!line) return null;
return line.replace(/^#+\s*/, "").trim();
}
const STOP_WORDS = new Set([
"the", "and", "with", "from", "that", "this", "into", "over", "under", "after", "before", "while",
"your", "their", "have", "has", "had", "make", "task", "tasks", "agent", "dashboard", "api", "via",
"fix", "add", "create", "update", "investigate", "restore", "support", "allow", "keep", "show", "same",
]);
function significantTokens(text) {
return new Set(
normalizeTitle(text)
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= 4 && !STOP_WORDS.has(token)),
);
}
function tokenOverlap(left, right) {
const leftTokens = significantTokens(left);
const rightTokens = significantTokens(right);
if (leftTokens.size === 0 || rightTokens.size === 0) {
return { shared: [], ratio: 1 };
}
const shared = [...leftTokens].filter((token) => rightTokens.has(token));
const ratio = shared.length / Math.max(leftTokens.size, rightTokens.size);
return { shared, ratio };
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function extractHistoricalCreatedAt(taskJson) {
const candidates = [];
if (Array.isArray(taskJson?.history)) {
for (const entry of taskJson.history) {
if (!entry || typeof entry !== "object") continue;
if (typeof entry.createdAt === "string") candidates.push(entry.createdAt);
if (typeof entry.timestamp === "string") candidates.push(entry.timestamp);
}
}
if (taskJson?.history && typeof taskJson.history === "object" && !Array.isArray(taskJson.history)) {
if (typeof taskJson.history.createdAt === "string") candidates.push(taskJson.history.createdAt);
if (typeof taskJson.history.timestamp === "string") candidates.push(taskJson.history.timestamp);
}
return candidates.sort()[0] ?? null;
}
function getLatestTaskCommit(projectRoot, mainRef, taskId) {
try {
const output = run(
"git",
["log", mainRef, "--format=%H%x09%cI%x09%s%x09%(trailers:key=Fusion-Task-Id,valueonly)", "--all"],
{ cwd: projectRoot },
);
if (!output) return null;
for (const line of output.split("\n")) {
const [sha, committedAt, subject, trailer] = line.split("\t");
if ((trailer ?? "").trim() === taskId) {
return { sha, committedAt, subject };
}
}
return null;
} catch {
return null;
}
}
function buildReport(projectRoot) {
const dbPath = path.join(projectRoot, ".fusion", "fusion.db");
const tasksDir = path.join(projectRoot, ".fusion", "tasks");
const mainRef = resolveMainRef(projectRoot);
if (!existsSync(dbPath)) {
throw new Error(`Database not found: ${dbPath}`);
}
if (!existsSync(tasksDir)) {
throw new Error(`Tasks directory not found: ${tasksDir}`);
}
const activeTasks = sqliteJson(
dbPath,
"SELECT id, title, createdAt, updatedAt, \"column\" AS columnName FROM tasks ORDER BY id",
);
const archivedDupes = safeSqliteJson(
dbPath,
"SELECT t.id AS id, t.title AS activeTitle, a.archivedAt AS archivedAt FROM tasks t INNER JOIN archivedTasks a ON a.id = t.id ORDER BY t.id",
);
const candidates = [];
let historyUnavailableCount = 0;
for (const task of activeTasks) {
const taskDir = path.join(tasksDir, task.id);
const taskJsonPath = path.join(taskDir, "task.json");
const promptPath = path.join(taskDir, "PROMPT.md");
const signals = [];
let taskJson = null;
if (existsSync(taskJsonPath)) {
taskJson = readJson(taskJsonPath);
const historicalCreatedAt = extractHistoricalCreatedAt(taskJson);
if (historicalCreatedAt && historicalCreatedAt < task.createdAt) {
signals.push({
type: "history-created-before-db-createdAt",
detail: `history createdAt ${historicalCreatedAt} < db createdAt ${task.createdAt}`,
});
}
if (!historicalCreatedAt) {
historyUnavailableCount += 1;
}
}
if (existsSync(promptPath)) {
const prompt = readFileSync(promptPath, "utf8");
const heading = firstHeading(prompt);
if (heading) {
const normalizedHeading = normalizeTitle(heading);
const normalizedTitleText = normalizeTitle(task.title);
if (
normalizedTitleText &&
normalizedHeading &&
normalizedTitleText !== normalizedHeading &&
!normalizedHeading.includes(normalizedTitleText) &&
!normalizedTitleText.includes(normalizedHeading)
) {
signals.push({
type: "prompt-heading-mismatch",
detail: `db title="${task.title}" vs prompt heading="${heading}"`,
});
}
}
}
const commit = getLatestTaskCommit(projectRoot, mainRef, task.id);
if (commit) {
const overlap = tokenOverlap(task.title, commit.subject.replace(/^.*?:\s*/, ""));
if (overlap.ratio === 0) {
signals.push({
type: "commit-subject-mismatch",
detail: `${commit.sha.slice(0, 9)} ${commit.subject}`,
committedAt: commit.committedAt,
});
}
}
if (signals.length > 0) {
candidates.push({
id: task.id,
title: task.title,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
column: task.columnName,
taskDirExists: existsSync(taskDir),
taskDirMtime: existsSync(taskDir) ? statSync(taskDir).mtime.toISOString() : null,
signals,
});
}
}
for (const duplicate of archivedDupes) {
const existing = candidates.find((candidate) => candidate.id === duplicate.id);
const signal = {
type: "active-archive-duplicate-id",
detail: `active task shares ID with archivedTasks row (archivedAt ${duplicate.archivedAt})`,
};
if (existing) {
existing.signals.push(signal);
} else {
candidates.push({
id: duplicate.id,
title: duplicate.activeTitle,
createdAt: null,
updatedAt: null,
column: "active+archived",
taskDirExists: existsSync(path.join(tasksDir, duplicate.id)),
taskDirMtime: existsSync(path.join(tasksDir, duplicate.id)) ? statSync(path.join(tasksDir, duplicate.id)).mtime.toISOString() : null,
signals: [signal],
});
}
}
candidates.sort((a, b) => a.id.localeCompare(b.id));
return {
projectRoot,
dbPath,
tasksDir,
mainRef,
scannedActiveTasks: activeTasks.length,
candidateCount: candidates.length,
historyUnavailableCount,
candidates,
};
}
function toMarkdown(report) {
const lines = [];
lines.push("# Task ID collision audit report");
lines.push("");
lines.push(`- Project root: \
\`${report.projectRoot}\``);
lines.push(`- Database: \
\`${report.dbPath}\``);
lines.push(`- Git ref used for commit checks: \
\`${report.mainRef}\``);
lines.push(`- Active tasks scanned: **${report.scannedActiveTasks}**`);
lines.push(`- Candidates flagged: **${report.candidateCount}**`);
lines.push(`- Tasks without usable \`task.json.history\` signal: **${report.historyUnavailableCount}**`);
lines.push("");
if (report.candidates.length === 0) {
lines.push("No candidates flagged by the configured heuristics.");
return lines.join("\n");
}
for (const candidate of report.candidates) {
lines.push(`## ${candidate.id}${candidate.title}`);
lines.push(`- Column: ${candidate.column}`);
if (candidate.createdAt) lines.push(`- DB createdAt: ${candidate.createdAt}`);
if (candidate.updatedAt) lines.push(`- DB updatedAt: ${candidate.updatedAt}`);
if (candidate.taskDirMtime) lines.push(`- Task dir mtime: ${candidate.taskDirMtime}`);
for (const signal of candidate.signals) {
lines.push(`- [${signal.type}] ${signal.detail}`);
}
lines.push("");
}
return lines.join("\n");
}
const args = parseArgs(process.argv.slice(2));
const report = buildReport(args.projectRoot);
process.stdout.write(args.json ? `${JSON.stringify(report, null, 2)}\n` : `${toMarkdown(report)}\n`);