feat(FN-4194): add reconciliation script for identity resolution
Adds a reconciliation script for FN-3909 identity issues, includes comprehensive tests verifying the workflow, and documents the process in the storage guide. Fusion-Task-Id: FN-4194
This commit is contained in:
234
scripts/__tests__/reconcile-fn-3909-identity.test.mjs
Normal file
234
scripts/__tests__/reconcile-fn-3909-identity.test.mjs
Normal file
@@ -0,0 +1,234 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
main,
|
||||
RECONCILIATION_ACTION,
|
||||
} from "../reconcile-fn-3909-identity.mjs";
|
||||
|
||||
const STALE_TITLE = "Implement heartbeat scope discipline for default agent promp";
|
||||
const STALE_DESCRIPTION = "Pick up FN-3884 (Heartbeat scope discipline for default agent prompts) — it's fully unblocked, spec-approved, size S. Update HEARTBEAT_PROCEDURE and HEARTBEAT_NO_TASK_PROCEDURE constants with scope-discipline guidance.";
|
||||
const CANONICAL_HEADING = "# Task: FN-3909 - Restore icons + width-aware labels on agent card buttons\n";
|
||||
const cloneValue = globalThis.structuredClone
|
||||
? (value) => globalThis.structuredClone(value)
|
||||
: (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function createTempDir() {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "fn-4194-"));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function createTaskFixture() {
|
||||
return {
|
||||
id: "FN-3909",
|
||||
title: STALE_TITLE,
|
||||
description: STALE_DESCRIPTION,
|
||||
column: "done",
|
||||
log: [{ timestamp: "2026-05-10T05:40:52.625Z", action: "Task created" }],
|
||||
steps: [{ name: "Preflight", status: "done" }],
|
||||
mergeDetails: {
|
||||
commitSha: "3cdc2d5a5d7525a2e142f351dbdc6353892be2f4",
|
||||
mergeCommitMessage: "Added a details button icon to agent cards in the AgentsView with responsive container query styling for action labels, accompanied by regression tests.",
|
||||
mergeConfirmed: true,
|
||||
},
|
||||
modifiedFiles: ["packages/dashboard/app/components/AgentsView.tsx"],
|
||||
createdAt: "2026-05-10T05:40:52.625Z",
|
||||
updatedAt: "2026-05-12T02:28:16.959Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture({ promptHeading = CANONICAL_HEADING, rawPrompt, task = createTaskFixture() } = {}) {
|
||||
const root = createTempDir();
|
||||
const fusionDir = path.join(root, ".fusion");
|
||||
const taskDir = path.join(fusionDir, "tasks", "FN-3909");
|
||||
mkdirSync(taskDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(taskDir, "PROMPT.md"),
|
||||
rawPrompt ?? `${promptHeading}\n## Mission\n\nFix two related defects in AgentsView.\n`,
|
||||
);
|
||||
writeJson(path.join(taskDir, "task.json"), task);
|
||||
|
||||
const unrelatedTask = {
|
||||
id: "FN-9999",
|
||||
title: "Unrelated task",
|
||||
description: "Should stay untouched",
|
||||
column: "done",
|
||||
log: [{ timestamp: "2026-05-12T00:00:00.000Z", action: "Task created" }],
|
||||
createdAt: "2026-05-12T00:00:00.000Z",
|
||||
updatedAt: "2026-05-12T00:00:00.000Z",
|
||||
};
|
||||
const tasks = new Map([
|
||||
[task.id, cloneValue(task)],
|
||||
[unrelatedTask.id, cloneValue(unrelatedTask)],
|
||||
]);
|
||||
|
||||
const calls = { updateTask: 0, logEntry: 0 };
|
||||
const store = {
|
||||
calls,
|
||||
async getTask(id) {
|
||||
const taskState = tasks.get(id);
|
||||
return taskState ? cloneValue(taskState) : null;
|
||||
},
|
||||
async updateTask(id, updates) {
|
||||
calls.updateTask += 1;
|
||||
const taskState = tasks.get(id);
|
||||
assert.ok(taskState, `missing task ${id}`);
|
||||
Object.assign(taskState, updates, { updatedAt: "2026-05-12T12:00:00.000Z" });
|
||||
if (id === "FN-3909") {
|
||||
writeJson(path.join(taskDir, "task.json"), taskState);
|
||||
}
|
||||
return cloneValue(taskState);
|
||||
},
|
||||
async logEntry(id, action, outcome) {
|
||||
calls.logEntry += 1;
|
||||
const taskState = tasks.get(id);
|
||||
assert.ok(taskState, `missing task ${id}`);
|
||||
taskState.log.push({ timestamp: "2026-05-12T12:00:01.000Z", action, outcome });
|
||||
taskState.updatedAt = "2026-05-12T12:00:01.000Z";
|
||||
if (id === "FN-3909") {
|
||||
writeJson(path.join(taskDir, "task.json"), taskState);
|
||||
}
|
||||
return cloneValue(taskState);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
taskDir,
|
||||
taskPath: path.join(taskDir, "task.json"),
|
||||
promptPath: path.join(taskDir, "PROMPT.md"),
|
||||
store,
|
||||
calls,
|
||||
tasks,
|
||||
cleanup() {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function comparableTask(task) {
|
||||
const clone = cloneValue(task);
|
||||
delete clone.updatedAt;
|
||||
return clone;
|
||||
}
|
||||
|
||||
test("dry-run reports the diff without mutating the task", async () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const beforeTaskJson = readFileSync(fixture.taskPath, "utf8");
|
||||
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
|
||||
|
||||
const result = await main(["--project-root", fixture.root], { store: fixture.store });
|
||||
|
||||
assert.equal(result.status, "would-apply");
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.diff.before.title, STALE_TITLE);
|
||||
assert.match(result.diff.after.title, /Restore icons/);
|
||||
assert.equal(fixture.calls.updateTask, 0);
|
||||
assert.equal(fixture.calls.logEntry, 0);
|
||||
assert.equal(readFileSync(fixture.taskPath, "utf8"), beforeTaskJson);
|
||||
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("apply updates only title, description, and log on FN-3909", async () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const before = readJson(fixture.taskPath);
|
||||
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
|
||||
|
||||
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
|
||||
const after = readJson(fixture.taskPath);
|
||||
|
||||
assert.equal(result.status, "applied");
|
||||
assert.equal(fixture.calls.updateTask, 1);
|
||||
assert.equal(fixture.calls.logEntry, 1);
|
||||
assert.equal(after.title, "Restore icons + width-aware labels on agent card buttons");
|
||||
assert.match(after.description, /AgentsView card action buttons/);
|
||||
assert.equal(after.log.at(-1).action, RECONCILIATION_ACTION);
|
||||
assert.match(after.log.at(-1).outcome, /FN-4194/);
|
||||
|
||||
const beforeComparable = comparableTask(before);
|
||||
const afterComparable = comparableTask(after);
|
||||
beforeComparable.title = afterComparable.title;
|
||||
beforeComparable.description = afterComparable.description;
|
||||
beforeComparable.log = afterComparable.log;
|
||||
assert.deepEqual(afterComparable, beforeComparable);
|
||||
|
||||
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("second apply is a no-op once reconciliation marker exists", async () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
|
||||
const afterFirstApply = readFileSync(fixture.taskPath, "utf8");
|
||||
|
||||
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
|
||||
|
||||
assert.equal(result.status, "noop");
|
||||
assert.equal(fixture.calls.updateTask, 1);
|
||||
assert.equal(fixture.calls.logEntry, 1);
|
||||
assert.equal(readFileSync(fixture.taskPath, "utf8"), afterFirstApply);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses when the row already matches the prompt heading without reconciliation marker", async () => {
|
||||
const task = createTaskFixture();
|
||||
task.title = "Restore icons + width-aware labels on agent card buttons";
|
||||
task.description = "Already canonical but not reconciled.";
|
||||
const fixture = createFixture({ task });
|
||||
try {
|
||||
await assert.rejects(
|
||||
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
|
||||
/already matches PROMPT heading but no FN-4194 reconciliation marker is present/,
|
||||
);
|
||||
assert.equal(fixture.calls.updateTask, 0);
|
||||
assert.equal(fixture.calls.logEntry, 0);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses when PROMPT heading is missing or unexpected", async (t) => {
|
||||
await t.test("missing heading", async () => {
|
||||
const fixture = createFixture({ rawPrompt: "No markdown heading here.\n\nJust body text.\n" });
|
||||
try {
|
||||
await assert.rejects(
|
||||
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
|
||||
/PROMPT.md is missing a first heading/,
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test("unexpected heading", async () => {
|
||||
const fixture = createFixture({ promptHeading: "# Task: FN-3909 - Unexpected heading\n" });
|
||||
try {
|
||||
await assert.rejects(
|
||||
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
|
||||
/unexpected PROMPT heading/,
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
183
scripts/reconcile-fn-3909-identity.mjs
Normal file
183
scripts/reconcile-fn-3909-identity.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export const TASK_ID = "FN-3909";
|
||||
export const SOURCE_TASK_ID = "FN-4194";
|
||||
export const CANONICAL_TITLE = "Restore icons + width-aware labels on agent card buttons";
|
||||
export const CANONICAL_DESCRIPTION = "Fix the AgentsView card action buttons so the Details button restores its icon and split-sidebar action labels only hide when the available width is too narrow to fit them.";
|
||||
export const EXPECTED_STALE_TITLE = "Implement heartbeat scope discipline for default agent promp";
|
||||
export const EXPECTED_STALE_DESCRIPTION_FRAGMENT = "Pick up FN-3884 (Heartbeat scope discipline for default agent prompts)";
|
||||
export const EXPECTED_PROMPT_HEADING = `Task: ${TASK_ID} - ${CANONICAL_TITLE}`;
|
||||
export const RECONCILIATION_ACTION = `${SOURCE_TASK_ID} reconciliation`;
|
||||
export const RECONCILIATION_OUTCOME = `${SOURCE_TASK_ID}: reconciled ${TASK_ID} title/description to the canonical merged UI-fix identity documented in task postmortem and canonical-mapping notes.`;
|
||||
|
||||
function readFlagValue(argv, flag) {
|
||||
const index = argv.indexOf(flag);
|
||||
if (index < 0) return undefined;
|
||||
return argv[index + 1];
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function readPromptHeading(projectRoot) {
|
||||
const promptPath = path.join(projectRoot, ".fusion", "tasks", TASK_ID, "PROMPT.md");
|
||||
const prompt = readFileSync(promptPath, "utf8");
|
||||
const heading = firstHeading(prompt);
|
||||
if (!heading) {
|
||||
throw new Error(`Refusing to reconcile ${TASK_ID}: PROMPT.md is missing a first heading at ${promptPath}`);
|
||||
}
|
||||
if (normalizeTitle(heading) !== normalizeTitle(EXPECTED_PROMPT_HEADING)) {
|
||||
throw new Error(
|
||||
`Refusing to reconcile ${TASK_ID}: unexpected PROMPT heading "${heading}" (expected "${EXPECTED_PROMPT_HEADING}")`,
|
||||
);
|
||||
}
|
||||
return { promptPath, heading };
|
||||
}
|
||||
|
||||
function summarizeDiff(task, heading) {
|
||||
return {
|
||||
id: task.id,
|
||||
promptHeading: heading,
|
||||
before: {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
},
|
||||
after: {
|
||||
title: CANONICAL_TITLE,
|
||||
description: CANONICAL_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasReconciliationLog(task) {
|
||||
return Array.isArray(task?.log)
|
||||
&& task.log.some((entry) => entry?.action === RECONCILIATION_ACTION);
|
||||
}
|
||||
|
||||
export function assessTaskState(task, heading) {
|
||||
if (!task || task.id !== TASK_ID) {
|
||||
throw new Error(`Refusing to reconcile: expected task ${TASK_ID}`);
|
||||
}
|
||||
|
||||
const normalizedCurrentTitle = normalizeTitle(task.title);
|
||||
const normalizedCanonicalTitle = normalizeTitle(CANONICAL_TITLE);
|
||||
const alreadyCanonical = normalizedCurrentTitle === normalizedCanonicalTitle;
|
||||
const alreadyLogged = hasReconciliationLog(task);
|
||||
|
||||
if (alreadyCanonical && alreadyLogged) {
|
||||
return {
|
||||
status: "noop",
|
||||
reason: `${TASK_ID} already matches the canonical title and carries the ${SOURCE_TASK_ID} reconciliation log entry`,
|
||||
diff: summarizeDiff(task, heading),
|
||||
};
|
||||
}
|
||||
|
||||
if (alreadyCanonical) {
|
||||
throw new Error(
|
||||
`Refusing to reconcile ${TASK_ID}: row title already matches PROMPT heading but no ${SOURCE_TASK_ID} reconciliation marker is present`,
|
||||
);
|
||||
}
|
||||
|
||||
if (task.title !== EXPECTED_STALE_TITLE) {
|
||||
throw new Error(
|
||||
`Refusing to reconcile ${TASK_ID}: unexpected stale title "${task.title}" (expected "${EXPECTED_STALE_TITLE}")`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!String(task.description ?? "").includes(EXPECTED_STALE_DESCRIPTION_FRAGMENT)) {
|
||||
throw new Error(`Refusing to reconcile ${TASK_ID}: current description does not match the expected heartbeat-scope stale text`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: "needs-reconciliation",
|
||||
diff: summarizeDiff(task, heading),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runReconciliation({ store, projectRoot, dryRun = true } = {}) {
|
||||
if (!store) {
|
||||
throw new Error("runReconciliation requires a store instance");
|
||||
}
|
||||
const resolvedProjectRoot = path.resolve(projectRoot ?? process.cwd());
|
||||
const { heading, promptPath } = readPromptHeading(resolvedProjectRoot);
|
||||
const task = await store.getTask(TASK_ID);
|
||||
const assessment = assessTaskState(task, heading);
|
||||
|
||||
if (assessment.status === "noop") {
|
||||
return {
|
||||
dryRun,
|
||||
status: "noop",
|
||||
promptPath,
|
||||
...assessment,
|
||||
};
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
dryRun,
|
||||
status: "would-apply",
|
||||
promptPath,
|
||||
diff: assessment.diff,
|
||||
};
|
||||
}
|
||||
|
||||
await store.updateTask(TASK_ID, {
|
||||
title: CANONICAL_TITLE,
|
||||
description: CANONICAL_DESCRIPTION,
|
||||
});
|
||||
await store.logEntry(TASK_ID, RECONCILIATION_ACTION, RECONCILIATION_OUTCOME);
|
||||
const updated = await store.getTask(TASK_ID);
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
status: "applied",
|
||||
promptPath,
|
||||
diff: summarizeDiff(task, heading),
|
||||
updated: {
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
reconciliationLogPresent: hasReconciliationLog(updated),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), deps = {}) {
|
||||
const dryRun = !argv.includes("--apply");
|
||||
const projectRoot = path.resolve(readFlagValue(argv, "--project-root") ?? process.cwd());
|
||||
const store = deps.store ?? (await (async () => {
|
||||
const { TaskStore } = await import("../packages/core/dist/index.js");
|
||||
const taskStore = new TaskStore(projectRoot);
|
||||
await taskStore.init();
|
||||
return taskStore;
|
||||
})());
|
||||
|
||||
const result = await runReconciliation({ store, projectRoot, dryRun });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user