feat(FN-671): preserve quick entry disclosure and normalize comment migration

- Keep QuickEntryBox disclosure state persisted and covered by updated UI tests
- Add normalized legacy comment migration logic to unify steering comments without duplication
- Update database migration tests to verify schema v5 comment handling behavior
- Carry merged dashboard, engine, and routing changes into the squash commit
This commit is contained in:
gsxdsm
2026-04-01 13:07:26 -07:00
parent 7f47013d13
commit d80a5765cf
14 changed files with 511 additions and 230 deletions

View File

@@ -466,6 +466,47 @@ describe("migrateFromLegacy", () => {
});
});
describe("comment migration", () => {
it("deduplicates overlapping steeringComments and comments during legacy import", async () => {
const tasksDir = join(kbDir, "tasks");
const taskDir = join(tasksDir, "FN-002");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-002",
description: "Comment overlap",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
steeringComments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
],
comments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await migrateFromLegacy(kbDir, db);
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-002'").get() as any;
expect(JSON.parse(row.steeringComments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
]);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
]);
});
});
describe("data integrity", () => {
it("preserves all task fields through migration", async () => {
const tasksDir = join(kbDir, "tasks");
@@ -541,9 +582,10 @@ describe("migrateFromLegacy", () => {
expect(JSON.parse(row.steps)).toHaveLength(2);
expect(JSON.parse(row.log)).toHaveLength(1);
expect(JSON.parse(row.attachments)).toHaveLength(1);
// steeringComments are merged into comments during migration
expect(JSON.parse(row.steeringComments)).toEqual([]); // Now empty - merged into comments
expect(JSON.parse(row.comments)).toHaveLength(1); // Migrated from steeringComments
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Fix this", createdAt: "2025-01-01", author: "user" },
]);
expect(JSON.parse(row.workflowStepResults)).toHaveLength(1);
expect(JSON.parse(row.prInfo).number).toBe(1);
expect(JSON.parse(row.issueInfo).number).toBe(10);

View File

@@ -12,7 +12,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { readFile, readdir, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import type { Database } from "./db.js";
import { toJson, toJsonNullable } from "./db.js";
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry } from "./types.js";
import type { ScheduledTask } from "./automation.js";
@@ -184,20 +184,10 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
const raw = await readFile(taskJsonPath, "utf-8");
const task: Task = JSON.parse(raw);
const steeringComments = Array.isArray((task as any).steeringComments)
? (task as any).steeringComments
: [];
const comments = Array.isArray(task.comments) ? task.comments : [];
const mergedComments = [
...steeringComments.map((comment: any) => ({
id: comment.id,
text: comment.text,
author: comment.author,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt ?? comment.createdAt,
})),
...comments,
];
const normalizedComments = normalizeTaskComments(
task.steeringComments,
task.comments,
);
insertStmt.run(
task.id,
@@ -229,8 +219,8 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
"[]",
toJson(mergedComments),
toJson(normalizedComments.steeringComments),
toJson(normalizedComments.comments),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "./db.js";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import { mkdtempSync, existsSync } from "node:fs";
import { join } from "node:path";
@@ -491,6 +491,26 @@ describe("Database", () => {
});
});
describe("comment normalization", () => {
it("merges overlapping legacy and unified comments exactly once", () => {
const normalized = normalizeTaskComments(
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }],
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" }],
);
expect(normalized.comments).toEqual([
{
id: "c1",
text: "Legacy note",
author: "user",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
]);
expect(normalized.steeringComments).toHaveLength(1);
});
});
describe("JSON helpers", () => {
describe("toJson", () => {
it("stringifies arrays", () => {
@@ -683,7 +703,7 @@ describe("schema migrations", () => {
// Now run init() which should trigger migration
db.init();
// Verify version bumped to 4 (includes v1→v2, v2→v3, and v3→v4 migrations)
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(5);
// Verify new columns exist and existing data is intact
@@ -807,7 +827,7 @@ describe("schema migrations", () => {
// Now run init() which should trigger migrations v2→v3→v4
db.init();
// Verify version bumped to 4
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(5);
// Verify new columns exist and existing data is intact
@@ -836,6 +856,159 @@ describe("schema migrations", () => {
db.close();
});
it("migrates pre-comments databases by copying steering comments into unified comments exactly once", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
steeringComments TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '1')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments) VALUES (?, ?, ?, ?, ?, ?)")
.run(
"FN-100",
"legacy comments",
"todo",
"2025-01-01T00:00:00.000Z",
"2025-01-01T00:00:00.000Z",
JSON.stringify([{ id: "legacy-1", text: "Use TypeScript", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
);
db.init();
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-100'").get() as any;
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
expect(JSON.parse(row.comments)).toEqual([
{
id: "legacy-1",
text: "Use TypeScript",
author: "user",
createdAt: "2025-01-01T00:00:00.000Z",
},
]);
db.close();
});
it("deduplicates overlapping steeringComments and comments during schema upgrade", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
mergeDetails TEXT
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '4')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments, comments) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(
"FN-101",
"mixed comments",
"todo",
"2025-01-01T00:00:00.000Z",
"2025-01-01T00:00:00.000Z",
JSON.stringify([{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
JSON.stringify([
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
]),
);
db.init();
const row = db.prepare("SELECT comments FROM tasks WHERE id = 'FN-101'").get() as any;
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
]);
db.close();
});
});
describe("createDatabase factory", () => {

View File

@@ -12,6 +12,7 @@ import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { TaskComment } from "./types.js";
// ── Types ────────────────────────────────────────────────────────────
@@ -60,6 +61,64 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
const SCHEMA_VERSION = 5;
function normalizeTaskComments(
steeringComments: TaskComment[] | undefined,
comments: TaskComment[] | undefined,
): { steeringComments: TaskComment[]; comments: TaskComment[] } {
const normalizedComments: TaskComment[] = [];
const seenKeys = new Set<string>();
const pushComment = (comment: TaskComment) => {
const key = comment.id || `${comment.text}\u0000${comment.author}\u0000${comment.createdAt}`;
const existingIndex = normalizedComments.findIndex((entry) => {
if (comment.id && entry.id) {
return entry.id === comment.id;
}
return (
entry.text === comment.text &&
entry.author === comment.author &&
entry.createdAt === comment.createdAt
);
});
if (existingIndex !== -1) {
const existing = normalizedComments[existingIndex];
normalizedComments[existingIndex] = {
...existing,
...comment,
updatedAt: comment.updatedAt ?? existing.updatedAt,
};
seenKeys.add(key);
return;
}
if (!seenKeys.has(key)) {
normalizedComments.push(comment);
seenKeys.add(key);
}
};
for (const comment of comments || []) {
if (!comment || !comment.id || !comment.createdAt) continue;
pushComment(comment);
}
for (const comment of steeringComments || []) {
if (!comment || !comment.id || !comment.createdAt) continue;
pushComment({
id: comment.id,
text: comment.text,
author: comment.author,
createdAt: comment.createdAt,
});
}
return {
steeringComments: steeringComments || [],
comments: normalizedComments,
};
}
const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data
CREATE TABLE IF NOT EXISTS tasks (
@@ -336,8 +395,7 @@ export class Database {
if (version < 5) {
this.applyMigration(5, () => {
// Migrate steeringComments to comments (unified comments field)
this.migrateSteeringCommentsToComments();
this.migrateLegacyCommentsToUnifiedComments();
});
}
@@ -377,55 +435,34 @@ export class Database {
}
/**
* Migrate steeringComments data to the unified comments field.
* This is a one-way migration from schema version 4 to 5.
* Normalize legacy steering comments into the unified comments field exactly once.
*
* This migration is idempotent: rows already normalized remain unchanged on rerun.
* The legacy steeringComments column is preserved for backward compatibility, but
* migrated comments are represented canonically in the comments column.
*/
private migrateSteeringCommentsToComments(): void {
// Only run if steeringComments column exists
if (!this.hasColumn("tasks", "steeringComments")) {
private migrateLegacyCommentsToUnifiedComments(): void {
if (!this.hasColumn("tasks", "comments") || !this.hasColumn("tasks", "steeringComments")) {
return;
}
// Get all tasks that have steering comments
const tasksWithSteering = this.db
.prepare("SELECT id, steeringComments, comments FROM tasks WHERE steeringComments != '[]'")
.all() as Array<{ id: string; steeringComments: string; comments: string }>;
const rows = this.db.prepare("SELECT id, steeringComments, comments FROM tasks").all() as Array<{
id: string;
steeringComments: string | null;
comments: string | null;
}>;
for (const task of tasksWithSteering) {
try {
const steeringComments = JSON.parse(task.steeringComments) as Array<{
id: string;
text: string;
createdAt: string;
author: "user" | "agent";
}>;
const existingComments = JSON.parse(task.comments || "[]") as Array<{
id: string;
text: string;
author: string;
createdAt: string;
updatedAt?: string;
}>;
const updateStmt = this.db.prepare(
"UPDATE tasks SET comments = ? WHERE id = ?",
);
// Convert steering comments to the unified format
const migratedComments = steeringComments.map((sc) => ({
id: sc.id,
text: sc.text,
author: sc.author,
createdAt: sc.createdAt,
updatedAt: sc.createdAt, // Steering comments didn't have updatedAt
}));
// Merge: existing comments first, then migrated steering comments
const mergedComments = [...existingComments, ...migratedComments];
// Update the task with merged comments
this.db
.prepare("UPDATE tasks SET comments = ? WHERE id = ?")
.run(JSON.stringify(mergedComments), task.id);
} catch {
// Skip tasks with invalid JSON in steeringComments
continue;
for (const row of rows) {
const steeringComments = fromJson<TaskComment[]>(row.steeringComments) || [];
const comments = fromJson<TaskComment[]>(row.comments) || [];
const normalized = normalizeTaskComments(steeringComments, comments);
const nextCommentsJson = toJson(normalized.comments);
if ((row.comments || "[]") !== nextCommentsJson) {
updateStmt.run(nextCommentsJson, row.id);
}
}
}
@@ -545,3 +582,5 @@ export class Database {
export function createDatabase(kbDir: string): Database {
return new Database(kbDir);
}
export { normalizeTaskComments };