docs(FN-1732): remove changeset for docs-only task

This commit is contained in:
Fusion
2026-04-15 11:14:15 -07:00
committed by gsxdsm
parent ddf0fc7303
commit f62d97cff8
15 changed files with 2278 additions and 19 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
const index = db
.prepare(

View File

@@ -119,7 +119,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
});
it("seeds lastModified", () => {
@@ -142,7 +142,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
});
it("does not overwrite existing config on re-init", () => {
@@ -749,7 +749,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -774,11 +774,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
db.close();
});
@@ -794,7 +794,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -818,7 +818,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -922,7 +922,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1288,7 +1288,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 32;
const SCHEMA_VERSION = 33;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -1236,6 +1236,72 @@ export class Database {
`);
});
}
// Insight persistence tables (FN-1877)
// Normalized insight entities and insight-generation run records
if (version < 33) {
this.applyMigration(33, () => {
// project_insights: normalized insight entities
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_insights (
id TEXT PRIMARY KEY,
projectId TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT,
category TEXT NOT NULL,
status TEXT NOT NULL,
fingerprint TEXT NOT NULL,
provenance TEXT,
lastRunId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
// project_insight_runs: insight-generation run records
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_insight_runs (
id TEXT PRIMARY KEY,
projectId TEXT NOT NULL,
trigger TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
error TEXT,
insightsCreated INTEGER NOT NULL DEFAULT 0,
insightsUpdated INTEGER NOT NULL DEFAULT 0,
inputMetadata TEXT,
outputMetadata TEXT,
createdAt TEXT NOT NULL,
startedAt TEXT,
completedAt TEXT
)
`);
// Index for filtering insights by projectId
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxProjectInsightsProjectId
ON project_insights(projectId)
`);
// Index for fingerprint-based upsert dedupe
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxProjectInsightsFingerprint
ON project_insights(projectId, fingerprint)
`);
// Index for filtering insights by category
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
ON project_insights(category)
`);
// Index for filtering runs by projectId
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
ON project_insight_runs(projectId)
`);
});
}
}
/**

View File

@@ -0,0 +1,856 @@
/**
* InsightStore Tests
*
* Covers:
* - Insight create/get/list/update/delete/upsert lifecycle
* - Insight run create/list/update/upsert lifecycle
* - Fingerprint-based upsert dedupe (no duplicate rows)
* - Stable identity on upsert (id/createdAt preserved)
* - Deterministic ordering under timestamp ties
* - Migration: pre-33 DB upgrades to include insight tables
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Database, createDatabase, fromJson } from "./db.js";
import { InsightStore, computeInsightFingerprint } from "./insight-store.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type {
Insight,
InsightRun,
InsightCategory,
InsightStatus,
InsightProvenance,
InsightRunTrigger,
InsightRunStatus,
} from "./insight-types.js";
// ── Test Fixtures ────────────────────────────────────────────────────
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-insight-test-"));
}
let kbDir: string;
let db: Database;
let store: InsightStore;
function createProvenance(overrides: Partial<InsightProvenance> = {}): InsightProvenance {
return {
trigger: "manual",
description: "Test generation",
relatedEntityIds: [],
...overrides,
};
}
beforeEach(() => {
kbDir = makeTmpDir();
db = createDatabase(kbDir);
db.init();
store = new InsightStore(db);
});
// ── Insight CRUD ────────────────────────────────────────────────────
describe("InsightStore", () => {
describe("createInsight", () => {
it("creates an insight and returns it with assigned id and timestamps", () => {
const input = {
title: "Test Insight",
category: "quality" as InsightCategory,
provenance: createProvenance(),
};
const insight = store.createInsight("test-project", input);
expect(insight.id).toMatch(/^INS-[A-Z0-9]+-[A-Z0-9]+$/);
expect(insight.projectId).toBe("test-project");
expect(insight.title).toBe("Test Insight");
expect(insight.content).toBeNull();
expect(insight.category).toBe("quality");
expect(insight.status).toBe("generated");
expect(insight.fingerprint).toBeTruthy();
expect(insight.lastRunId).toBeNull();
expect(insight.createdAt).toBeTruthy();
expect(insight.updatedAt).toBeTruthy();
});
it("accepts optional content and custom status", () => {
const input = {
title: "Insight with content",
content: "Detailed description",
category: "performance" as InsightCategory,
status: "confirmed" as InsightStatus,
provenance: createProvenance(),
};
const insight = store.createInsight("proj", input);
expect(insight.content).toBe("Detailed description");
expect(insight.status).toBe("confirmed");
});
it("uses provided fingerprint when given", () => {
const input = {
title: "Custom fingerprint",
category: "security" as InsightCategory,
provenance: createProvenance(),
fingerprint: "my-custom-fingerprint",
};
const insight = store.createInsight("proj", input);
expect(insight.fingerprint).toBe("my-custom-fingerprint");
});
it("persists insight to the database", () => {
const insight = store.createInsight("proj", {
title: "Persisted",
category: "architecture",
provenance: createProvenance(),
});
const fromDb = store.getInsight(insight.id);
expect(fromDb).toEqual(insight);
});
it("emits insight:created event", () => {
const handler = vi.fn();
store.on("insight:created", handler);
const insight = store.createInsight("proj", {
title: "Event test",
category: "ux",
provenance: createProvenance(),
});
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(insight);
});
});
describe("getInsight", () => {
it("returns the insight when found", () => {
const created = store.createInsight("proj", {
title: "To get",
category: "testability",
provenance: createProvenance(),
});
const found = store.getInsight(created.id);
expect(found).toEqual(created);
});
it("returns undefined when not found", () => {
const found = store.getInsight("INS-NOTFOUND");
expect(found).toBeUndefined();
});
});
describe("listInsights", () => {
it("returns all insights for a project", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(2);
});
it("filters by category", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj", category: "quality" });
expect(list).toHaveLength(1);
expect(list[0].title).toBe("A");
});
it("filters by status", () => {
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj", status: "confirmed" });
expect(list).toHaveLength(1);
expect(list[0].title).toBe("A");
});
it("supports pagination with limit and offset", () => {
for (let i = 0; i < 10; i++) {
store.createInsight("proj", { title: `Insight ${i}`, category: "quality", provenance: createProvenance() });
}
const page1 = store.listInsights({ projectId: "proj", limit: 3, offset: 0 });
const page2 = store.listInsights({ projectId: "proj", limit: 3, offset: 3 });
expect(page1).toHaveLength(3);
expect(page2).toHaveLength(3);
expect(page1[0].id).not.toEqual(page2[0].id);
});
it("is ordered ascending by createdAt, then id (deterministic)", () => {
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
const now = new Date();
const insertedIds: string[] = [];
for (let i = 0; i < 5; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
const id = `INS-LIST-${i}`;
insertedIds.push(id);
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-list-${i}`,
null,
null,
ts,
ts,
);
}
const list = store.listInsights({ projectId: "proj" });
expect(list.map((i) => i.id)).toEqual(insertedIds);
// Verify ascending order by createdAt
for (let i = 1; i < list.length; i++) {
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
}
});
});
describe("updateInsight", () => {
it("updates mutable fields", () => {
const original = store.createInsight("proj", {
title: "Original",
category: "quality",
provenance: createProvenance(),
});
const updated = store.updateInsight(original.id, {
title: "Updated Title",
content: "Updated content",
status: "confirmed",
});
expect(updated!.title).toBe("Updated Title");
expect(updated!.content).toBe("Updated content");
expect(updated!.status).toBe("confirmed");
expect(updated!.id).toBe(original.id);
expect(updated!.createdAt).toBe(original.createdAt);
// updatedAt should be >= original.createdAt (updated after creation)
expect(updated!.updatedAt >= original.createdAt).toBe(true);
});
it("returns undefined for non-existent insight", () => {
const result = store.updateInsight("INS-NOTFOUND", { title: "X" });
expect(result).toBeUndefined();
});
it("emits insight:updated event", () => {
const handler = vi.fn();
store.on("insight:updated", handler);
const insight = store.createInsight("proj", {
title: "To update",
category: "reliability",
provenance: createProvenance(),
});
store.updateInsight(insight.id, { status: "stale" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].status).toBe("stale");
});
});
describe("deleteInsight", () => {
it("deletes an existing insight", () => {
const insight = store.createInsight("proj", {
title: "To delete",
category: "dependency",
provenance: createProvenance(),
});
const deleted = store.deleteInsight(insight.id);
expect(deleted).toBe(true);
expect(store.getInsight(insight.id)).toBeUndefined();
});
it("returns false for non-existent insight", () => {
const deleted = store.deleteInsight("INS-NOTFOUND");
expect(deleted).toBe(false);
});
it("emits insight:deleted event", () => {
const handler = vi.fn();
store.on("insight:deleted", handler);
const insight = store.createInsight("proj", {
title: "To delete",
category: "documentation",
provenance: createProvenance(),
});
store.deleteInsight(insight.id);
expect(handler).toHaveBeenCalledWith(insight.id);
});
});
describe("upsertInsight (dedupe)", () => {
it("creates a new insight when no fingerprint match exists", () => {
const result = store.upsertInsight("proj", {
title: "New insight",
category: "architecture",
provenance: createProvenance(),
fingerprint: "new-fp",
});
expect(result.id).toMatch(/^INS-/);
expect(result.fingerprint).toBe("new-fp");
expect(store.listInsights({ projectId: "proj" })).toHaveLength(1);
});
it("updates existing insight when fingerprint matches (no duplicate)", () => {
// First upsert — creates
const created = store.upsertInsight("proj", {
title: "Original title",
category: "quality",
provenance: createProvenance(),
fingerprint: "same-fp",
});
const countBefore = store.listInsights({ projectId: "proj" }).length;
expect(countBefore).toBe(1);
// Second upsert with same fingerprint — updates (no duplicate)
const updated = store.upsertInsight("proj", {
title: "Updated title",
content: "Added content",
category: "quality",
provenance: createProvenance(),
fingerprint: "same-fp",
});
expect(updated.id).toBe(created.id); // Same id
expect(updated.title).toBe("Updated title");
expect(updated.content).toBe("Added content");
expect(updated.createdAt).toBe(created.createdAt); // Original createdAt preserved
const countAfter = store.listInsights({ projectId: "proj" }).length;
expect(countAfter).toBe(1); // No duplicate created
});
it("preserves stable identity on upsert (id and createdAt unchanged)", () => {
const first = store.upsertInsight("proj", {
title: "Stable identity test",
category: "workflow",
provenance: createProvenance(),
fingerprint: "stable-fp",
});
const second = store.upsertInsight("proj", {
title: "Updated title",
category: "workflow",
provenance: createProvenance({ trigger: "schedule" }),
fingerprint: "stable-fp",
});
expect(second.id).toBe(first.id);
expect(second.createdAt).toBe(first.createdAt);
// updatedAt should be >= first.createdAt (updated after first creation)
expect(second.updatedAt >= first.createdAt).toBe(true);
});
it("upserting different fingerprints creates separate insights", () => {
store.upsertInsight("proj", {
title: "Insight A",
category: "quality",
provenance: createProvenance(),
fingerprint: "fp-a",
});
store.upsertInsight("proj", {
title: "Insight B",
category: "quality",
provenance: createProvenance(),
fingerprint: "fp-b",
});
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(2);
expect(list.map((i) => i.fingerprint)).toContain("fp-a");
expect(list.map((i) => i.fingerprint)).toContain("fp-b");
});
it("upserting same fingerprint in different projects creates separate insights", () => {
store.upsertInsight("proj-a", {
title: "Shared title",
category: "performance",
provenance: createProvenance(),
fingerprint: "cross-project-fp",
});
store.upsertInsight("proj-b", {
title: "Shared title",
category: "performance",
provenance: createProvenance(),
fingerprint: "cross-project-fp",
});
const listA = store.listInsights({ projectId: "proj-a" });
const listB = store.listInsights({ projectId: "proj-b" });
expect(listA).toHaveLength(1);
expect(listB).toHaveLength(1);
expect(listA[0].id).not.toEqual(listB[0].id);
});
});
describe("countInsights", () => {
it("counts all insights for a project", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
expect(store.countInsights({ projectId: "proj" })).toBe(2);
});
it("counts with filters", () => {
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
expect(store.countInsights({ projectId: "proj", category: "quality" })).toBe(2);
expect(store.countInsights({ projectId: "proj", status: "confirmed" })).toBe(1);
});
});
describe("deterministic ordering", () => {
it("ordering is stable across repeated reads", () => {
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
const now = new Date();
for (let i = 0; i < 10; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INS-STABLE-${i}`,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-stable-${i}`,
null,
null,
ts,
ts,
);
}
// Ordering is stable: same reads across multiple calls
const read1 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
const read2 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
const read3 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
expect(read1).toEqual(read2);
expect(read2).toEqual(read3);
// Verify the expected IDs are present
expect(read1).toEqual([
"INS-STABLE-0", "INS-STABLE-1", "INS-STABLE-2", "INS-STABLE-3", "INS-STABLE-4",
"INS-STABLE-5", "INS-STABLE-6", "INS-STABLE-7", "INS-STABLE-8", "INS-STABLE-9",
]);
});
it("results are ascending (oldest first) by createdAt, then id", () => {
// Create insights with explicit timestamps using SQL to avoid millisecond collisions
const now = new Date();
for (let i = 0; i < 5; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INS-ORDER-${i}`,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-order-${i}`,
null,
null,
ts,
ts,
);
}
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(5);
// Verify IDs match what we inserted (auto-incremented order 0..4)
expect(list.map((i) => i.id)).toEqual([
"INS-ORDER-0",
"INS-ORDER-1",
"INS-ORDER-2",
"INS-ORDER-3",
"INS-ORDER-4",
]);
// Verify ascending order by createdAt
for (let i = 1; i < list.length; i++) {
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
}
});
});
describe("computeInsightFingerprint", () => {
it("produces consistent fingerprints for same input", () => {
const fp1 = computeInsightFingerprint("Test Insight", "quality");
const fp2 = computeInsightFingerprint("Test Insight", "quality");
expect(fp1).toBe(fp2);
});
it("produces consistent fingerprints regardless of case", () => {
const fp1 = computeInsightFingerprint("Test Insight", "quality");
const fp2 = computeInsightFingerprint("test insight", "quality");
expect(fp1).toBe(fp2);
});
it("different titles produce different fingerprints", () => {
const fp1 = computeInsightFingerprint("Title A", "quality");
const fp2 = computeInsightFingerprint("Title B", "quality");
expect(fp1).not.toBe(fp2);
});
it("different categories produce different fingerprints", () => {
const fp1 = computeInsightFingerprint("Same Title", "quality");
const fp2 = computeInsightFingerprint("Same Title", "performance");
expect(fp1).not.toBe(fp2);
});
it("trims whitespace before hashing", () => {
const fp1 = computeInsightFingerprint(" Test ", "quality");
const fp2 = computeInsightFingerprint("Test", "quality");
expect(fp1).toBe(fp2);
});
});
});
// ── Insight Run CRUD ────────────────────────────────────────────────
describe("InsightStore Run CRUD", () => {
describe("createRun", () => {
it("creates a run with pending status", () => {
const run = store.createRun("proj", { trigger: "manual" });
expect(run.id).toMatch(/^INSR-/);
expect(run.projectId).toBe("proj");
expect(run.trigger).toBe("manual");
expect(run.status).toBe("pending");
expect(run.insightsCreated).toBe(0);
expect(run.insightsUpdated).toBe(0);
expect(run.createdAt).toBeTruthy();
expect(run.startedAt).toBeNull();
expect(run.completedAt).toBeNull();
});
it("persists run to the database", () => {
const created = store.createRun("proj", { trigger: "schedule" });
const fromDb = store.getRun(created.id);
expect(fromDb).toEqual(created);
});
it("emits run:created event", () => {
const handler = vi.fn();
store.on("run:created", handler);
const run = store.createRun("proj", { trigger: "api" });
expect(handler).toHaveBeenCalledWith(run);
});
});
describe("getRun", () => {
it("returns run when found", () => {
const created = store.createRun("proj", { trigger: "manual" });
expect(store.getRun(created.id)).toEqual(created);
});
it("returns undefined when not found", () => {
expect(store.getRun("INSR-NOTFOUND")).toBeUndefined();
});
});
describe("listRuns", () => {
it("returns runs for a project", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
store.createRun("other", { trigger: "manual" });
const list = store.listRuns({ projectId: "proj" });
expect(list).toHaveLength(2);
});
it("filters by status", () => {
store.createRun("proj", { trigger: "manual" }); // pending
const running = store.createRun("proj", { trigger: "schedule" });
store.updateRun(running.id, { status: "running" });
const pending = store.listRuns({ projectId: "proj", status: "pending" });
expect(pending).toHaveLength(1);
expect(pending[0].status).toBe("pending");
});
it("filters by trigger", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
const manual = store.listRuns({ projectId: "proj", trigger: "manual" });
expect(manual).toHaveLength(1);
});
it("supports pagination", () => {
for (let i = 0; i < 10; i++) {
store.createRun("proj", { trigger: "manual" });
}
const page1 = store.listRuns({ projectId: "proj", limit: 3, offset: 0 });
expect(page1).toHaveLength(3);
});
it("is ordered descending by createdAt (newest first)", () => {
// Create runs with explicit descending timestamps to ensure deterministic ordering
const now = new Date();
for (let i = 4; i >= 0; i--) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insight_runs (id, projectId, trigger, status, summary, error, insightsCreated, insightsUpdated, inputMetadata, outputMetadata, createdAt, startedAt, completedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INSR-ORDER-${i}`,
"proj",
"manual",
"pending",
null,
null,
0,
0,
null,
null,
ts,
null,
null,
);
}
const list = store.listRuns({ projectId: "proj" });
expect(list).toHaveLength(5);
// Descending by createdAt: newest first (ts=4, ts=3, ts=2, ts=1, ts=0)
for (let i = 1; i < list.length; i++) {
const prev = list[i - 1];
const curr = list[i];
expect(prev.createdAt > curr.createdAt).toBe(true);
}
});
});
describe("updateRun", () => {
it("updates mutable fields", () => {
const run = store.createRun("proj", { trigger: "manual" });
const updated = store.updateRun(run.id, {
status: "running",
startedAt: "2025-01-01T00:00:00.000Z",
});
expect(updated!.status).toBe("running");
expect(updated!.startedAt).toBe("2025-01-01T00:00:00.000Z");
expect(updated!.id).toBe(run.id);
});
it("auto-sets completedAt when transitioning to terminal state", () => {
const run = store.createRun("proj", { trigger: "schedule" });
const updated = store.updateRun(run.id, {
status: "completed",
summary: "Done",
insightsCreated: 5,
insightsUpdated: 2,
});
expect(updated!.status).toBe("completed");
expect(updated!.completedAt).toBeTruthy();
});
it("does not override completedAt if already provided", () => {
const run = store.createRun("proj", { trigger: "manual" });
const fixed = "2025-06-01T12:00:00.000Z";
const updated = store.updateRun(run.id, {
status: "failed",
completedAt: fixed,
error: "boom",
});
expect(updated!.completedAt).toBe(fixed);
});
it("returns undefined for non-existent run", () => {
const result = store.updateRun("INSR-NOTFOUND", { status: "running" });
expect(result).toBeUndefined();
});
it("emits run:updated event on status change", () => {
const handler = vi.fn();
store.on("run:updated", handler);
const run = store.createRun("proj", { trigger: "manual" });
store.updateRun(run.id, { status: "running" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].status).toBe("running");
});
it("emits run:completed event when reaching terminal state", () => {
const handler = vi.fn();
store.on("run:completed", handler);
const run = store.createRun("proj", { trigger: "schedule" });
store.updateRun(run.id, { status: "completed" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].id).toBe(run.id);
expect(handler.mock.calls[0][0].status).toBe("completed");
});
});
describe("upsertRun", () => {
it("creates new run when no pending/running run exists", () => {
const run = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(run.id).toMatch(/^INSR-/);
expect(run.status).toBe("pending");
});
it("returns existing pending/running run instead of creating duplicate", () => {
const first = store.createRun("proj", { trigger: "schedule" });
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(second.id).toBe(first.id);
expect(store.listRuns({ projectId: "proj", trigger: "schedule" })).toHaveLength(1);
});
it("creates new run when existing run is terminal", () => {
const first = store.createRun("proj", { trigger: "schedule" });
store.updateRun(first.id, { status: "completed" });
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(second.id).not.toBe(first.id);
expect(store.listRuns({ projectId: "proj" })).toHaveLength(2);
});
});
describe("countRuns", () => {
it("counts runs with optional filters", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
store.createRun("other", { trigger: "manual" });
expect(store.countRuns({ projectId: "proj" })).toBe(2);
expect(store.countRuns({ projectId: "proj", trigger: "manual" })).toBe(1);
});
});
});
// ── Migration Test ───────────────────────────────────────────────────
describe("Migration: pre-33 DB upgrade", () => {
it("creates insight tables when upgrading from schema version 32", () => {
const legacyDir = mkdtempSync(join(tmpdir(), "fn-mig-test-"));
try {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(33);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
// to simulate a pre-33 database
const db2 = createDatabase(legacyDir);
db2.init();
db2.prepare("UPDATE __meta SET value = '32' WHERE key = 'schemaVersion'").run();
// Drop insight tables/indexes to fully simulate pre-33 state
db2.prepare("DROP TABLE IF EXISTS project_insight_runs").run();
db2.prepare("DROP TABLE IF EXISTS project_insights").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsProjectId").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsFingerprint").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsCategory").run();
db2.prepare("DROP INDEX IF EXISTS idxInsightRunsProjectId").run();
db2.close();
// Step 3: Verify pre-33 state (after downgrade, before re-init)
// Note: we check the version BEFORE calling init() on db3
// because init() would immediately run migration 33.
// We verify pre-33 state by re-opening without calling init() on the new instance,
// then calling init() and verifying it upgrades.
const db3 = createDatabase(legacyDir);
// Read version without running migrations
const versionBefore = db3.getSchemaVersion();
expect(versionBefore).toBe(32);
// Verify insight tables are absent in the pre-33 state
const tablesBefore = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
).all() as { name: string }[];
const tableNamesBefore = tablesBefore.map((t) => t.name);
expect(tableNamesBefore).not.toContain("project_insights");
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(33);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
).all() as { name: string }[];
const tableNamesAfter = tablesAfter.map((t) => t.name);
expect(tableNamesAfter).toContain("project_insights");
expect(tableNamesAfter).toContain("project_insight_runs");
// Verify indexes exist
const indexes = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
).all() as { name: string }[];
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxProjectInsightsProjectId");
expect(indexNames).toContain("idxProjectInsightsFingerprint");
expect(indexNames).toContain("idxInsightRunsProjectId");
db3.close();
} finally {
rmSync(legacyDir, { recursive: true, force: true });
}
});
it("migration is idempotent — running twice does not fail", () => {
const testDir = mkdtempSync(join(tmpdir(), "fn-idempotent-test-"));
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(33);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(33);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,666 @@
/**
* InsightStore — Project-level insight persistence and run tracking.
*
* Manages normalized insight entities and insight-generation run records
* in SQLite, with deterministic ordering and fingerprint-based upsert dedupe.
*
* ## Ordering Contract
*
* All list operations return results in **ascending** order by default.
* When multiple rows share the same primary sort key (e.g., `createdAt`),
* ties are broken deterministically by `id` ascending (lexicographic).
*
* This is enforced both in SQL ORDER BY clauses and in-memory sorts
* to guarantee stable iteration across repeated reads.
*
* ## Deduplication Contract
*
* `upsertInsight()` deduplicates by (projectId, fingerprint).
* When a fingerprint match is found, the existing row's mutable fields are
* updated and its original `id` / `createdAt` are preserved — no new row
* is created. Use `createInsight()` to force creation regardless of fingerprint.
*
* ## Naming Convention
*
* Table names use `project_insights` / `project_insight_runs` (snake_case)
* to match the established SQLite convention in this codebase.
*/
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { toJson, toJsonNullable, fromJson } from "./db.js";
import { randomUUID } from "node:crypto";
import type {
Insight,
InsightCreateInput,
InsightUpdateInput,
InsightUpsertInput,
InsightListOptions,
InsightCategory,
InsightStatus,
InsightProvenance,
InsightRun,
InsightRunCreateInput,
InsightRunUpdateInput,
InsightRunListOptions,
InsightRunStatus,
InsightRunTrigger,
InsightRunInputMetadata,
InsightRunOutputMetadata,
} from "./insight-types.js";
import type { InsightStoreEvents } from "./insight-types.js";
// ── ID Generators ────────────────────────────────────────────────────
function generateInsightId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `INS-${timestamp}-${random}`;
}
function generateRunId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `INSR-${timestamp}-${random}`;
}
// ── Fingerprint Helper ────────────────────────────────────────────────
/**
* Compute a canonical fingerprint for an insight.
*
* The fingerprint is derived from normalized (lowercased, trimmed) title
* and category to produce a consistent dedupe key regardless of
* minor wording variations.
*
* @param title - The insight title
* @param category - The insight category
* @returns A deterministic fingerprint string
*/
export function computeInsightFingerprint(title: string, category: InsightCategory): string {
// Normalize: lowercase, trim, collapse internal whitespace
const normalizedTitle = title.toLowerCase().trim().replace(/\s+/g, " ");
const normalizedCategory = category.toLowerCase().trim();
const raw = `${normalizedCategory}:${normalizedTitle}`;
// Use a simple hash for the fingerprint — deterministic and short
let hash = 0;
for (let i = 0; i < raw.length; i++) {
const char = raw.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Return as unsigned hex string
return Math.abs(hash).toString(16).padStart(8, "0");
}
// ── InsightStore Class ───────────────────────────────────────────────
export class InsightStore extends EventEmitter<InsightStoreEvents> {
constructor(private db: Database) {
super();
this.setMaxListeners(50);
}
/** Expose the database for testing purposes. */
getDatabase(): Database {
return this.db;
}
// ── Insight CRUD ────────────────────────────────────────────────────
/**
* Create a new insight.
*
* Does NOT check for fingerprint duplicates — use `upsertInsight()`
* when dedupe-by-fingerprint is desired.
*
* @param projectId - Project this insight belongs to
* @param input - Insight creation input
* @returns The newly created insight
*/
createInsight(projectId: string, input: InsightCreateInput): Insight {
const now = new Date().toISOString();
const id = generateInsightId();
const fingerprint = input.fingerprint ?? computeInsightFingerprint(input.title, input.category);
const status = input.status ?? "generated";
this.db.prepare(`
INSERT INTO project_insights (
id, projectId, title, content, category, status,
fingerprint, provenance, lastRunId, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
projectId,
input.title,
input.content ?? null,
input.category,
status,
fingerprint,
toJsonNullable(input.provenance) ?? null,
null,
now,
now,
);
this.db.bumpLastModified();
const insight: Insight = {
id,
projectId,
title: input.title,
content: input.content ?? null,
category: input.category,
status,
fingerprint,
provenance: input.provenance,
lastRunId: null,
createdAt: now,
updatedAt: now,
};
this.emit("insight:created", insight);
return insight;
}
/**
* Get a single insight by ID.
*
* @param id - The insight ID
* @returns The insight, or undefined if not found
*/
getInsight(id: string): Insight | undefined {
const row = this.db.prepare("SELECT * FROM project_insights WHERE id = ?").get(id) as
| Record<string, unknown>
| undefined;
return row ? this.rowToInsight(row) : undefined;
}
/**
* List insights with optional filtering and pagination.
*
* Results are ordered ascending by (createdAt, id) for deterministic iteration.
*
* @param options - Filter and pagination options
* @returns Matching insights, ordered ascending by createdAt then id
*/
listInsights(options: InsightListOptions = {}): Insight[] {
const conditions: string[] = [];
const params: (string | number)[] = [];
if (options.projectId !== undefined) {
conditions.push("projectId = ?");
params.push(options.projectId);
}
if (options.category !== undefined) {
conditions.push("category = ?");
params.push(options.category);
}
if (options.status !== undefined) {
conditions.push("status = ?");
params.push(options.status);
}
if (options.runId !== undefined) {
conditions.push("lastRunId = ?");
params.push(options.runId);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitClause = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
const offsetClause = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
// Deterministic ordering: createdAt ASC, id ASC (lexicographic tie-breaker)
const rows = this.db.prepare(`
SELECT * FROM project_insights
${whereClause}
ORDER BY createdAt ASC, id ASC
${limitClause}
${offsetClause}
`).all(...params) as Record<string, unknown>[];
return rows.map((row) => this.rowToInsight(row));
}
/**
* Update an existing insight.
*
* @param id - The insight ID to update
* @param input - Fields to update
* @returns The updated insight, or undefined if not found
*/
updateInsight(id: string, input: InsightUpdateInput): Insight | undefined {
const existing = this.getInsight(id);
if (!existing) return undefined;
const now = new Date().toISOString();
const sets: string[] = ["updatedAt = ?"];
const params: (string | null)[] = [now];
if (input.title !== undefined) {
sets.push("title = ?");
params.push(input.title);
}
if (input.content !== undefined) {
sets.push("content = ?");
params.push(input.content);
}
if (input.category !== undefined) {
sets.push("category = ?");
params.push(input.category);
}
if (input.status !== undefined) {
sets.push("status = ?");
params.push(input.status);
}
if (input.provenance !== undefined) {
sets.push("provenance = ?");
params.push(toJsonNullable(input.provenance));
}
params.push(id);
this.db.prepare(`UPDATE project_insights SET ${sets.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified();
// Re-read to get the full updated record
const updated = this.getInsight(id)!;
this.emit("insight:updated", updated);
return updated;
}
/**
* Delete an insight by ID.
*
* @param id - The insight ID to delete
* @returns true if deleted, false if not found
*/
deleteInsight(id: string): boolean {
const existing = this.getInsight(id);
if (!existing) return false;
this.db.prepare("DELETE FROM project_insights WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("insight:deleted", id);
return true;
}
/**
* Upsert an insight by (projectId, fingerprint).
*
* - If an insight with the same projectId + fingerprint exists, update its
* mutable fields (title, content, provenance, lastRunId, updatedAt) and
* preserve the original `id` and `createdAt`.
* - If no match exists, create a new insight.
*
* This enables idempotent insight generation where re-running the same
* analysis updates the existing insight rather than creating duplicates.
*
* @param projectId - Project scope
* @param input - Upsert input (fingerprint required for dedupe)
* @returns The created or updated insight
*/
upsertInsight(projectId: string, input: InsightUpsertInput): Insight {
const now = new Date().toISOString();
const fingerprint = input.fingerprint;
// Check for existing insight with same projectId + fingerprint
const existingRow = this.db.prepare(`
SELECT * FROM project_insights WHERE projectId = ? AND fingerprint = ?
`).get(projectId, fingerprint) as Record<string, unknown> | undefined;
if (existingRow) {
// Update existing row in place — preserve id and createdAt
const sets: string[] = [
"title = ?",
"content = ?",
"category = ?",
"status = ?",
"provenance = ?",
"lastRunId = ?",
"updatedAt = ?",
];
const params: (string | null)[] = [
input.title,
input.content ?? null,
input.category,
input.status ?? "confirmed",
toJsonNullable(input.provenance),
input.provenance.metadata?.runId as string | null ?? null,
now,
];
const id = existingRow.id as string;
this.db.prepare(`UPDATE project_insights SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
this.db.bumpLastModified();
const updated = this.getInsight(id)!;
this.emit("insight:updated", updated);
return updated;
} else {
// Create new insight
return this.createInsight(projectId, {
...input,
status: input.status ?? "confirmed",
});
}
}
/**
* Get the count of insights matching the given filter.
*/
countInsights(options: Omit<InsightListOptions, "limit" | "offset"> = {}): number {
const conditions: string[] = [];
const params: string[] = [];
if (options.projectId !== undefined) {
conditions.push("projectId = ?");
params.push(options.projectId);
}
if (options.category !== undefined) {
conditions.push("category = ?");
params.push(options.category);
}
if (options.status !== undefined) {
conditions.push("status = ?");
params.push(options.status);
}
if (options.runId !== undefined) {
conditions.push("lastRunId = ?");
params.push(options.runId);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const row = this.db.prepare(`
SELECT COUNT(*) as count FROM project_insights ${whereClause}
`).get(...params) as { count: number } | undefined;
return row?.count ?? 0;
}
// ── Insight Run CRUD ────────────────────────────────────────────────
/**
* Create a new insight generation run.
*
* @param projectId - Project this run belongs to
* @param input - Run creation input
* @returns The newly created run
*/
createRun(projectId: string, input: InsightRunCreateInput): InsightRun {
const now = new Date().toISOString();
const id = generateRunId();
const inputMetadata = input.inputMetadata ?? {};
this.db.prepare(`
INSERT INTO project_insight_runs (
id, projectId, trigger, status, summary, error,
insightsCreated, insightsUpdated,
inputMetadata, outputMetadata,
createdAt, startedAt, completedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
projectId,
input.trigger,
"pending",
null,
null,
0,
0,
toJsonNullable(inputMetadata) ?? null,
null,
now,
null,
null,
);
this.db.bumpLastModified();
const run: InsightRun = {
id,
projectId,
trigger: input.trigger,
status: "pending",
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata,
outputMetadata: {},
createdAt: now,
startedAt: null,
completedAt: null,
};
this.emit("run:created", run);
return run;
}
/**
* Get a single run by ID.
*/
getRun(id: string): InsightRun | undefined {
const row = this.db.prepare("SELECT * FROM project_insight_runs WHERE id = ?").get(id) as
| Record<string, unknown>
| undefined;
return row ? this.rowToRun(row) : undefined;
}
/**
* List runs with optional filtering and pagination.
*
* Results are ordered ascending by (createdAt DESC, id DESC) for newest-first
* default ordering. Use options with explicit ordering to override.
*
* @param options - Filter and pagination options
* @returns Matching runs
*/
listRuns(options: InsightRunListOptions = {}): InsightRun[] {
const conditions: string[] = [];
const params: (string | number)[] = [];
if (options.projectId !== undefined) {
conditions.push("projectId = ?");
params.push(options.projectId);
}
if (options.status !== undefined) {
conditions.push("status = ?");
params.push(options.status);
}
if (options.trigger !== undefined) {
conditions.push("trigger = ?");
params.push(options.trigger);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitClause = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
const offsetClause = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
// Deterministic ordering: newest first by default (createdAt DESC, id DESC)
const rows = this.db.prepare(`
SELECT * FROM project_insight_runs
${whereClause}
ORDER BY createdAt DESC, id DESC
${limitClause}
${offsetClause}
`).all(...params) as Record<string, unknown>[];
return rows.map((row) => this.rowToRun(row));
}
/**
* Update an existing run.
*
* When `status` transitions to a terminal state (`completed`, `failed`,
* `cancelled`), `completedAt` is set automatically if not already provided.
*
* @param id - The run ID to update
* @param input - Fields to update
* @returns The updated run, or undefined if not found
*/
updateRun(id: string, input: InsightRunUpdateInput): InsightRun | undefined {
const existing = this.getRun(id);
if (!existing) return undefined;
const now = new Date().toISOString();
const isTerminal = input.status !== undefined && ["completed", "failed", "cancelled"].includes(input.status);
const autoComplete = isTerminal && input.completedAt === undefined && existing.completedAt === null;
const sets: string[] = [];
const params: (string | number | null)[] = [];
if (input.status !== undefined) {
sets.push("status = ?");
params.push(input.status);
}
if (input.summary !== undefined) {
sets.push("summary = ?");
params.push(input.summary);
}
if (input.error !== undefined) {
sets.push("error = ?");
params.push(input.error);
}
if (input.insightsCreated !== undefined) {
sets.push("insightsCreated = ?");
params.push(input.insightsCreated);
}
if (input.insightsUpdated !== undefined) {
sets.push("insightsUpdated = ?");
params.push(input.insightsUpdated);
}
if (input.outputMetadata !== undefined) {
sets.push("outputMetadata = ?");
params.push(toJsonNullable(input.outputMetadata));
}
if (input.startedAt !== undefined) {
sets.push("startedAt = ?");
params.push(input.startedAt);
}
if (input.completedAt !== undefined) {
sets.push("completedAt = ?");
params.push(input.completedAt);
}
if (sets.length === 0) return existing;
// Auto-set completedAt for terminal transitions
if (autoComplete) {
sets.push("completedAt = ?");
params.push(now);
}
params.push(id);
this.db.prepare(`UPDATE project_insight_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified();
const updated = this.getRun(id)!;
if (isTerminal) {
this.emit("run:completed", updated);
}
this.emit("run:updated", updated);
return updated;
}
/**
* Upsert a run by (projectId, trigger, createdAt) — used when a pipeline
* needs to resume or update a specific run by fingerprint-like key.
*
* For most cases, `createRun()` + `updateRun()` is sufficient.
* This method exists for pipelines that need idempotent run creation.
*
* @param projectId - Project scope
* @param trigger - Trigger type to match
* @param input - Run data
* @returns The created or existing run
*/
upsertRun(projectId: string, trigger: InsightRunTrigger, input: InsightRunCreateInput): InsightRun {
// Find most recent pending/running run for this project + trigger
const existingRow = this.db.prepare(`
SELECT * FROM project_insight_runs
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running')
ORDER BY createdAt DESC, id DESC
LIMIT 1
`).get(projectId, trigger) as Record<string, unknown> | undefined;
if (existingRow) {
return this.getRun(existingRow.id as string)!;
}
return this.createRun(projectId, input);
}
/**
* Get the count of runs matching the given filter.
*/
countRuns(options: Omit<InsightRunListOptions, "limit" | "offset"> = {}): number {
const conditions: string[] = [];
const params: string[] = [];
if (options.projectId !== undefined) {
conditions.push("projectId = ?");
params.push(options.projectId);
}
if (options.status !== undefined) {
conditions.push("status = ?");
params.push(options.status);
}
if (options.trigger !== undefined) {
conditions.push("trigger = ?");
params.push(options.trigger);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const row = this.db.prepare(`
SELECT COUNT(*) as count FROM project_insight_runs ${whereClause}
`).get(...params) as { count: number } | undefined;
return row?.count ?? 0;
}
// ── Row → Entity Converters ─────────────────────────────────────────
private rowToInsight(row: Record<string, unknown>): Insight {
return {
id: row.id as string,
projectId: row.projectId as string,
title: row.title as string,
content: row.content as string | null,
category: row.category as InsightCategory,
status: row.status as InsightStatus,
fingerprint: row.fingerprint as string,
provenance: (() => {
const p = fromJson<InsightProvenance>(row.provenance as string | null);
return p ?? { trigger: "unknown" };
})(),
lastRunId: row.lastRunId as string | null,
createdAt: row.createdAt as string,
updatedAt: row.updatedAt as string,
};
}
private rowToRun(row: Record<string, unknown>): InsightRun {
return {
id: row.id as string,
projectId: row.projectId as string,
trigger: row.trigger as InsightRunTrigger,
status: row.status as InsightRunStatus,
summary: row.summary as string | null,
error: row.error as string | null,
insightsCreated: row.insightsCreated as number,
insightsUpdated: row.insightsUpdated as number,
inputMetadata: (() => {
const m = fromJson<InsightRunInputMetadata>(row.inputMetadata as string | null);
return m ?? {};
})(),
outputMetadata: (() => {
const m = fromJson<InsightRunOutputMetadata>(row.outputMetadata as string | null);
return m ?? {};
})(),
createdAt: row.createdAt as string,
startedAt: row.startedAt as string | null,
completedAt: row.completedAt as string | null,
};
}
}

View File

@@ -0,0 +1,477 @@
/**
* Insight Domain Types
*
* Normalized domain contracts for project-level insight persistence and
* insight-generation run tracking.
*
* Design principles:
* - Insights are **project-scoped** — each insight belongs to exactly one project
* - **Deduplication** via canonical fingerprint: same fingerprint = same logical insight
* - **Stable identity**: upserts preserve the original `id` and `createdAt`
* - **Deterministic ordering**: list operations use multi-column tie-breakers
* (see ordering contract below)
*
* ## Ordering Contract
*
* All list operations return results in **ascending** order by default.
* When multiple rows share the same primary sort key (e.g., `createdAt`),
* the tie is broken deterministically by:
*
* 1. `id` ascending (lexicographic, case-sensitive)
*
* This two-level ordering is enforced in both SQL ORDER BY clauses and
* in-memory sorts to guarantee stable iteration across repeated reads.
* Timestamp-only ordering is intentionally avoided because SQLite timestamps
* have millisecond resolution, making ties possible in batch operations.
*
* ## Naming Convention
*
* Table names use `project_insights` / `project_insight_runs` (snake_case)
* to match the established SQLite convention in this codebase.
* Type names use PascalCase (e.g., `Insight`, `InsightRun`).
*/
// ── Insight Category ─────────────────────────────────────────────────
/** High-level category for classifying an insight. */
export type InsightCategory =
| "quality" // Code quality, patterns, debt
| "performance" // Performance, efficiency, bottlenecks
| "architecture" // Structural decisions, coupling, design
| "security" // Security vulnerabilities, risks
| "reliability" // Error handling, stability, resilience
| "ux" // User experience, accessibility, ergonomics
| "testability" // Testing gaps, coverage, test design
| "documentation" // Docs, comments, clarity
| "dependency" // Third-party deps, versions, updates
| "workflow" // Process, tooling, developer experience
| "other"; // Uncategorized / general
// ── Insight Lifecycle ─────────────────────────────────────────────────
/**
* Lifecycle state of an insight.
*
* State transitions:
* generated → confirmed → stale
* ↓
* dismissed
*
* A "stale" insight has been superseded or is no longer relevant.
* A "dismissed" insight was manually rejected by a reviewer.
*/
export type InsightStatus = "generated" | "confirmed" | "stale" | "dismissed";
// ── Provenance Metadata ───────────────────────────────────────────────
/**
* Provenance metadata capturing where/how an insight was derived.
* Stored as a JSON column in the database.
*/
export interface InsightProvenance {
/**
* What triggered this insight to be generated.
* Examples: "schedule", "manual", "task_completion", "merge_event"
*/
trigger: string;
/** Human-readable description of the generation context. */
description?: string;
/**
* Identifiers of related entities that were in scope when the insight
* was generated. For example, task IDs that were analyzed.
*/
relatedEntityIds?: string[];
/**
* Token usage estimate for the generation call.
* Stored as an approximate snapshot, not for billing purposes.
*/
tokenEstimate?: {
input?: number;
output?: number;
};
/**
* Arbitrary metadata specific to the generation method.
* Examples: model ID, temperature, processing duration.
*/
metadata?: Record<string, unknown>;
}
// ── Core Insight Entity ───────────────────────────────────────────────
/**
* A persisted project insight.
*
* An insight represents a derived observation or recommendation about
* the project, generated by an AI analysis pipeline and stored for
* later review, action, or archival.
*
* Identity is determined by `id`; dedup logic uses `fingerprint`.
*/
export interface Insight {
/**
* Unique identifier (e.g., "INS-xxx").
* Assigned by the store on creation; stable across upserts.
*/
id: string;
/**
* Project this insight belongs to.
* Corresponds to the project directory / .fusion/ path.
*/
projectId: string;
/** Short title summarizing the insight (1120 chars recommended). */
title: string;
/**
* Full body of the insight. May contain markdown formatting.
* Nullable to support partial insights (title-only during generation).
*/
content: string | null;
/**
* Classification category.
*/
category: InsightCategory;
/**
* Current lifecycle state.
*/
status: InsightStatus;
/**
* Canonical fingerprint for deduplication.
*
* Two insights with the same fingerprint and projectId are considered
* the same logical insight. When an upsert finds a matching fingerprint,
* the existing row is updated and its `id` / `createdAt` are preserved.
*
* Construction: a deterministic hash of (normalized title + category).
* Consumers should use `computeInsightFingerprint()` from insight-store.ts.
*/
fingerprint: string;
/**
* Provenance metadata capturing the generation context.
*/
provenance: InsightProvenance;
/**
* ID of the most recent generation run that produced or updated this insight.
* References `project_insight_runs.id`.
*/
lastRunId: string | null;
/**
* When the insight was first generated.
* Set on creation and preserved on upsert.
*/
createdAt: string;
/**
* When the insight was last modified (any field change).
*/
updatedAt: string;
}
// ── Insight Creation Input ───────────────────────────────────────────
/**
* Input for creating a new insight.
* `fingerprint` is required for upsertable insights.
*/
export interface InsightCreateInput {
title: string;
content?: string | null;
category: InsightCategory;
provenance: InsightProvenance;
fingerprint?: string;
status?: InsightStatus;
}
// ── Insight Update Input ─────────────────────────────────────────────
/**
* Input for updating an existing insight.
* All fields are optional — only provided fields are updated.
*/
export interface InsightUpdateInput {
title?: string;
content?: string | null;
category?: InsightCategory;
status?: InsightStatus;
provenance?: InsightProvenance;
}
// ── Insight Upsert Input ─────────────────────────────────────────────
/**
* Input for upserting an insight by fingerprint.
*
* If an existing insight with the same (projectId, fingerprint) exists,
* the mutable fields are updated and the original `id` / `createdAt` are
* preserved (no duplicate row is created).
*
* If no match exists, a new insight is created with a fresh `id`.
*/
export interface InsightUpsertInput extends InsightCreateInput {
/**
* Required for upsert — must be provided to enable dedupe matching.
*/
fingerprint: string;
}
// ── Insight List Options ─────────────────────────────────────────────
/**
* Filtering and pagination options for insight listing.
*/
export interface InsightListOptions {
/** Restrict to a specific project. */
projectId?: string;
/** Restrict to a specific category. */
category?: InsightCategory;
/** Restrict to a specific status. */
status?: InsightStatus;
/** Restrict to insights linked to a specific run. */
runId?: string;
/** Maximum number of rows to return. Default: no limit. */
limit?: number;
/** Number of rows to skip. Default: 0. */
offset?: number;
}
// ── Insight Generation Run Entity ────────────────────────────────────
/**
* Status of a single insight-generation run.
*
* Runs are **immutable** once terminal (completed, failed, cancelled).
* Only `pending` and `running` runs can transition state.
*/
export type InsightRunStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
/**
* What triggered a generation run.
*/
export type InsightRunTrigger = "schedule" | "manual" | "task_completion" | "merge_event" | "api";
/**
* A single execution of the insight-generation pipeline.
*
* Runs track the full lifecycle of an analysis pass — from scheduling
* through input processing to output persistence.
*/
export interface InsightRun {
/**
* Unique identifier for this run (e.g., "INSR-xxx").
* Assigned by the store on creation.
*/
id: string;
/**
* Project this run belongs to.
*/
projectId: string;
/**
* What initiated this run.
*/
trigger: InsightRunTrigger;
/**
* Current execution status.
*/
status: InsightRunStatus;
/**
* Human-readable summary of the run outcome.
* Set by the pipeline on completion or failure.
*/
summary: string | null;
/**
* Error message if the run failed.
*/
error: string | null;
/**
* Number of insights that were created by this run.
*/
insightsCreated: number;
/**
* Number of existing insights that were updated (fingerprint-matched) by this run.
*/
insightsUpdated: number;
/**
* Input metadata for the run — what was analyzed.
* Stored as a JSON snapshot for audit/replay.
*/
inputMetadata: InsightRunInputMetadata;
/**
* Output metadata for the run — what was produced.
* Stored as a JSON snapshot.
*/
outputMetadata: InsightRunOutputMetadata;
/**
* When the run was queued / created.
*/
createdAt: string;
/**
* When the run started processing.
*/
startedAt: string | null;
/**
* When the run reached a terminal state.
*/
completedAt: string | null;
}
// ── Run Input / Output Metadata ──────────────────────────────────────
/**
* Input metadata snapshot for a run.
*/
export interface InsightRunInputMetadata {
/**
* IDs of tasks that were in scope for this analysis.
* Empty array if no task-specific scope.
*/
taskIds?: string[];
/**
* Branch or ref that was analyzed (if applicable).
*/
branch?: string;
/**
* Commit SHA at which analysis was performed.
*/
commitSha?: string;
/**
* Any additional context passed to the generation prompt.
*/
context?: string;
/**
* Arbitrary input metadata (model used, prompt tokens, etc.).
*/
metadata?: Record<string, unknown>;
}
/**
* Output metadata snapshot for a run.
*/
export interface InsightRunOutputMetadata {
/**
* IDs of insights created by this run.
*/
insightIds?: string[];
/**
* IDs of existing insights updated (fingerprint-matched) by this run.
*/
updatedInsightIds?: string[];
/**
* IDs of insights that were marked stale or dismissed by this run.
*/
supersededInsightIds?: string[];
/**
* Number of generation prompt tokens used.
*/
promptTokens?: number;
/**
* Number of completion tokens generated.
*/
completionTokens?: number;
/**
* Arbitrary output metadata.
*/
metadata?: Record<string, unknown>;
}
// ── Run Creation Input ───────────────────────────────────────────────
/**
* Input for creating a new insight run.
*/
export interface InsightRunCreateInput {
trigger: InsightRunTrigger;
inputMetadata?: InsightRunInputMetadata;
}
// ── Run Update Input ─────────────────────────────────────────────────
/**
* Input for updating a run record.
* Used by the pipeline to report progress and final outcomes.
*/
export interface InsightRunUpdateInput {
status?: InsightRunStatus;
summary?: string | null;
error?: string | null;
insightsCreated?: number;
insightsUpdated?: number;
outputMetadata?: InsightRunOutputMetadata;
startedAt?: string | null;
completedAt?: string | null;
}
// ── Run List Options ─────────────────────────────────────────────────
/**
* Filtering and pagination options for run listing.
*/
export interface InsightRunListOptions {
/** Restrict to a specific project. */
projectId?: string;
/** Restrict to runs with a specific status. */
status?: InsightRunStatus;
/** Restrict to runs triggered by a specific trigger type. */
trigger?: InsightRunTrigger;
/** Maximum number of rows to return. Default: no limit. */
limit?: number;
/** Number of rows to skip. Default: 0. */
offset?: number;
}
// ── Store Event Types ────────────────────────────────────────────────
export interface InsightStoreEvents {
/** Emitted when an insight is created */
"insight:created": [Insight];
/** Emitted when an insight is updated */
"insight:updated": [Insight];
/** Emitted when an insight is deleted */
"insight:deleted": [string];
/** Emitted when a run is created */
"run:created": [InsightRun];
/** Emitted when a run is updated */
"run:updated": [InsightRun];
/** Emitted when a run reaches a terminal state */
"run:completed": [InsightRun];
}

View File

@@ -2544,7 +2544,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 32 after migration", () => {
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
});
it("mission_features table has loop state columns", () => {

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 32 after init", () => {
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
});
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 28", () => {
expect(db.getSchemaVersion()).toBe(32);
expect(db.getSchemaVersion()).toBe(33);
});
});
});