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

@@ -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 };