feat(FN-3784): document report archive feature in fusion-plugin-reports REA

Documents the report archive feature in the Fusion Plugin Reports README and adds a patch changeset for the release.

Fusion-Task-Id: FN-3784
This commit is contained in:
Fusion
2026-05-09 14:08:05 -07:00
committed by gsxdsm
parent 2864f70608
commit 235ba11712
8 changed files with 802 additions and 3 deletions

View File

@@ -0,0 +1,66 @@
import { Database } from "@fusion/core";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ensureReportSchema } from "../../report-schema.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "report-schema-test-"));
}
describe("ensureReportSchema", () => {
let tmp: string;
let db: Database;
beforeEach(() => {
tmp = makeTmpDir();
db = new Database(join(tmp, ".fusion"), { inMemory: true });
db.init();
});
afterEach(async () => {
db.close();
await rm(tmp, { recursive: true, force: true });
});
it("creates reports table and indexes idempotently", () => {
ensureReportSchema(db);
ensureReportSchema(db);
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='reports'").get() as { name: string } | undefined;
expect(table?.name).toBe("reports");
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reports' ORDER BY name").all() as Array<{ name: string }>;
expect(indexes.map((row) => row.name)).toEqual(expect.arrayContaining([
"idxReportsCadenceCreated",
"idxReportsStatusUpdated",
"idxReportsPeriod",
]));
});
it("enforces cadence and status CHECK constraints", () => {
ensureReportSchema(db);
const base = {
id: "rep_1",
cadence: "daily",
periodStart: "2026-05-08T00:00:00.000Z",
periodEnd: "2026-05-08T23:59:59.999Z",
title: "Daily Report",
status: "generating",
generationStartedAt: "2026-05-09T00:00:00.000Z",
createdAt: "2026-05-09T00:00:00.000Z",
updatedAt: "2026-05-09T00:00:00.000Z",
};
const stmt = db.prepare(`
INSERT INTO reports (id, cadence, periodStart, periodEnd, title, status, generationStartedAt, createdAt, updatedAt)
VALUES (@id, @cadence, @periodStart, @periodEnd, @title, @status, @generationStartedAt, @createdAt, @updatedAt)
`);
expect(() => stmt.run({ ...base, id: "rep_bad_cadence", cadence: "hourly" })).toThrow();
expect(() => stmt.run({ ...base, id: "rep_bad_status", status: "queued" })).toThrow();
});
});

View File

@@ -0,0 +1,177 @@
import { Database } from "@fusion/core";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureReportSchema } from "../../report-schema.js";
import type { CombinedReview } from "../../review-types.js";
import { ReportStore, ReportStoreError } from "../report-store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "report-store-test-"));
}
function makeReview(): CombinedReview {
return {
overallVerdict: "revise",
consensusSummary: "Needs updates",
mergedHighlights: ["Good structure"],
mergedLowlights: ["Missing metrics"],
mergedSuggestions: ["Add numbers"],
individual: [],
failures: [],
};
}
describe("ReportStore", () => {
let tmp: string;
let db: Database;
let store: ReportStore;
beforeEach(() => {
tmp = makeTmpDir();
db = new Database(join(tmp, ".fusion"), { inMemory: true });
db.init();
ensureReportSchema(db);
store = new ReportStore(db);
});
afterEach(async () => {
db.close();
await rm(tmp, { recursive: true, force: true });
});
it("createReport persists generating report", () => {
const listener = vi.fn();
store.on("report:created", listener);
const report = store.createReport({
cadence: "daily",
periodStart: "2026-05-01T00:00:00.000Z",
periodEnd: "2026-05-01T23:59:59.999Z",
title: "Daily",
metadata: { sourceCount: 12 },
draftMarkdown: "# Draft",
});
expect(report.id).toMatch(/^rep_/);
expect(report.status).toBe("generating");
expect(report.generationStartedAt).toBeTruthy();
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getReport(report.id)?.metadata).toEqual({ sourceCount: 12 });
});
it("getReport hydrates metadata and combinedReview", () => {
const report = store.createReport({ cadence: "weekly", periodStart: "2026-05-01", periodEnd: "2026-05-07", title: "Weekly" });
store.setStatus(report.id, "review_pending");
store.setStatus(report.id, "review_in_progress");
store.attachReview(report.id, makeReview());
const hydrated = store.getReport(report.id);
expect(hydrated?.combinedReview?.overallVerdict).toBe("revise");
expect(hydrated?.metadata).toEqual({});
});
it("listReports filters and paginates", () => {
const a = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const b = store.createReport({ cadence: "weekly", periodStart: "2026-05-02", periodEnd: "2026-05-08", title: "B" });
const c = store.createReport({ cadence: "daily", periodStart: "2026-05-03", periodEnd: "2026-05-03", title: "C" });
store.setStatus(c.id, "failed", { failureReason: "x" });
expect(store.listReports({ cadence: "daily" }).length).toBe(2);
expect(store.listReports({ statusIn: ["failed"] }).map((r) => r.id)).toEqual([c.id]);
expect(store.listReports({ periodStartFrom: "2026-05-02", periodStartTo: "2026-05-03", orderBy: "periodStart", orderDir: "asc" }).map((r) => r.id)).toEqual([b.id, c.id]);
const paged = store.listReports({ orderBy: "periodStart", orderDir: "asc", limit: 1, offset: 1 });
expect(paged).toHaveLength(1);
});
it("setStatus enforces lifecycle and timestamps", () => {
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const pending = store.setStatus(report.id, "review_pending");
expect(pending.generationCompletedAt).toBeTruthy();
const inProgress = store.setStatus(report.id, "review_in_progress");
expect(inProgress.reviewStartedAt).toBeTruthy();
const complete = store.setStatus(report.id, "review_complete");
expect(complete.reviewCompletedAt).toBeTruthy();
const approved = store.setStatus(report.id, "approved", { approvedBy: "agent-1" });
expect(approved.approvedAt).toBeTruthy();
expect(approved.approvedBy).toBe("agent-1");
const published = store.setStatus(report.id, "published");
expect(published.publishedAt).toBeTruthy();
expect(() => store.setStatus(report.id, "generating")).toThrow(ReportStoreError);
});
it("setStatus failed works from non-terminal state and saves failureReason", () => {
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const failed = store.setStatus(report.id, "failed", { failureReason: "timeout" });
expect(failed.failureReason).toBe("timeout");
});
it("attachRenderedHtml and deleteReport persist and emit events", () => {
const report = store.createReport({ cadence: "manual", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const deleted = vi.fn();
store.on("report:deleted", deleted);
const updated = store.attachRenderedHtml(report.id, ".fusion/plugins/reports/report.html");
expect(updated.renderedHtmlPath).toContain("report.html");
store.deleteReport(report.id);
expect(store.getReport(report.id)).toBeNull();
expect(deleted).toHaveBeenCalledWith(report.id);
});
it("emits events once for update/status/review", () => {
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const updated = vi.fn();
const status = vi.fn();
const review = vi.fn();
store.on("report:updated", updated);
store.on("report:status-changed", status);
store.on("report:review-attached", review);
store.updateReport(report.id, { title: "B" });
store.setStatus(report.id, "review_pending");
store.setStatus(report.id, "review_in_progress");
store.attachReview(report.id, makeReview());
expect(updated).toHaveBeenCalledTimes(1);
expect(status).toHaveBeenCalledTimes(3);
expect(review).toHaveBeenCalledTimes(1);
});
it("rolls back failed transaction and commits successful transaction", () => {
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
const originalPrepare = db.prepare.bind(db);
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
const stmt = originalPrepare(sql);
if (sql.includes("UPDATE reports") && !sql.includes("WHERE id = @id")) {
return stmt;
}
if (sql.includes("UPDATE reports")) {
return {
...stmt,
run: (...args: unknown[]) => {
throw new Error("forced failure");
},
} as typeof stmt;
}
return stmt;
});
expect(() => store.updateReport(report.id, { title: "Broken" })).toThrow("forced failure");
expect(store.getReport(report.id)?.title).toBe("A");
spy.mockRestore();
const ok = store.updateReport(report.id, { title: "Good" });
expect(ok.title).toBe("Good");
});
});

View File

@@ -0,0 +1,341 @@
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { Database } from "@fusion/core";
import type { CombinedReview } from "../review-types.js";
import {
type Report,
type ReportCreateInput,
type ReportListFilter,
type ReportStatus,
type ReportUpdateInput,
isValidReportStatusTransition,
} from "./report-types.js";
interface ReportRow {
id: string;
cadence: Report["cadence"];
periodStart: string;
periodEnd: string;
title: string;
status: ReportStatus;
generationStartedAt: string;
generationCompletedAt: string | null;
reviewStartedAt: string | null;
reviewCompletedAt: string | null;
approvedAt: string | null;
approvedBy: string | null;
publishedAt: string | null;
archivedAt: string | null;
failureReason: string | null;
draftMarkdown: string | null;
renderedHtmlPath: string | null;
metadataJson: string;
combinedReviewJson: string | null;
createdAt: string;
updatedAt: string;
}
export interface ReportStoreEvents {
"report:created": [Report];
"report:updated": [Report];
"report:status-changed": [Report];
"report:review-attached": [Report];
"report:deleted": [string];
}
export class ReportStoreError extends Error {
constructor(message: string) {
super(message);
this.name = "ReportStoreError";
}
}
export class ReportStore extends EventEmitter<ReportStoreEvents> {
constructor(private readonly db: Database) {
super();
this.setMaxListeners(50);
}
createReport(input: ReportCreateInput): Report {
const now = new Date().toISOString();
const report: Report = {
id: `rep_${randomUUID().replaceAll("-", "")}`,
cadence: input.cadence,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
title: input.title,
status: "generating",
generationStartedAt: now,
generationCompletedAt: null,
reviewStartedAt: null,
reviewCompletedAt: null,
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
draftMarkdown: input.draftMarkdown ?? null,
renderedHtmlPath: null,
metadata: input.metadata ?? {},
combinedReview: null,
createdAt: now,
updatedAt: now,
};
this.db.transaction(() => {
this.db.prepare(`
INSERT INTO reports (
id, cadence, periodStart, periodEnd, title, status,
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
draftMarkdown, renderedHtmlPath, metadataJson, combinedReviewJson, createdAt, updatedAt
) VALUES (
@id, @cadence, @periodStart, @periodEnd, @title, @status,
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
@draftMarkdown, @renderedHtmlPath, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
)
`).run(this.toDbParams(report, true));
});
this.db.bumpLastModified();
this.emit("report:created", report);
return report;
}
getReport(id: string): Report | null {
const row = this.db.prepare("SELECT * FROM reports WHERE id = ?").get(id) as ReportRow | undefined;
return row ? this.rowToReport(row) : null;
}
listReports(filter: ReportListFilter = {}): Report[] {
const params: unknown[] = [];
const where: string[] = [];
if (filter.cadence) {
where.push("cadence = ?");
params.push(filter.cadence);
}
if (filter.statusIn && filter.statusIn.length > 0) {
where.push(`status IN (${filter.statusIn.map(() => "?").join(",")})`);
params.push(...filter.statusIn);
} else if (filter.status) {
where.push("status = ?");
params.push(filter.status);
}
if (filter.periodStartFrom) {
where.push("periodStart >= ?");
params.push(filter.periodStartFrom);
}
if (filter.periodStartTo) {
where.push("periodStart <= ?");
params.push(filter.periodStartTo);
}
const orderBy = filter.orderBy === "periodStart" ? "periodStart" : "createdAt";
const orderDir = filter.orderDir === "asc" ? "ASC" : "DESC";
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
const offset = Math.max(filter.offset ?? 0, 0);
const sql = `
SELECT * FROM reports
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY ${orderBy} ${orderDir}, id ${orderDir}
LIMIT ? OFFSET ?
`;
params.push(limit, offset);
const rows = this.db.prepare(sql).all(...params) as ReportRow[];
return rows.map((row) => this.rowToReport(row));
}
updateReport(id: string, patch: ReportUpdateInput): Report {
const current = this.requireReport(id);
const next: Report = {
...current,
title: patch.title ?? current.title,
draftMarkdown: patch.draftMarkdown ?? current.draftMarkdown,
renderedHtmlPath: patch.renderedHtmlPath ?? current.renderedHtmlPath,
metadata: patch.metadata ?? current.metadata,
failureReason: patch.failureReason ?? current.failureReason,
updatedAt: new Date().toISOString(),
};
this.db.transaction(() => this.persistExisting(next));
this.db.bumpLastModified();
this.emit("report:updated", next);
return next;
}
setStatus(id: string, next: ReportStatus, opts: { failureReason?: string; approvedBy?: string } = {}): Report {
const current = this.requireReport(id);
if (current.status === next) return current;
if (!isValidReportStatusTransition(current.status, next)) {
throw new ReportStoreError(`Invalid status transition: ${current.status} -> ${next}`);
}
const now = new Date().toISOString();
const updated: Report = {
...current,
status: next,
updatedAt: now,
failureReason: next === "failed" ? (opts.failureReason ?? current.failureReason) : current.failureReason,
};
if (next === "review_pending") updated.generationCompletedAt = now;
if (next === "review_in_progress") updated.reviewStartedAt = now;
if (next === "review_complete") updated.reviewCompletedAt = now;
if (next === "approved") {
updated.approvedAt = now;
updated.approvedBy = opts.approvedBy ?? current.approvedBy;
}
if (next === "published") updated.publishedAt = now;
if (next === "archived") updated.archivedAt = now;
this.db.transaction(() => this.persistExisting(updated));
this.db.bumpLastModified();
this.emit("report:status-changed", updated);
return updated;
}
attachReview(id: string, combined: CombinedReview): Report {
const current = this.requireReport(id);
if (current.status !== "review_in_progress") {
throw new ReportStoreError(`attachReview requires review_in_progress status; got ${current.status}`);
}
const now = new Date().toISOString();
const updated: Report = {
...current,
combinedReview: combined,
status: "review_complete",
reviewCompletedAt: now,
updatedAt: now,
};
this.db.transaction(() => this.persistExisting(updated));
this.db.bumpLastModified();
this.emit("report:review-attached", updated);
this.emit("report:status-changed", updated);
return updated;
}
attachRenderedHtml(id: string, htmlPath: string): Report {
return this.updateReport(id, { renderedHtmlPath: htmlPath });
}
deleteReport(id: string): void {
this.requireReport(id);
this.db.transaction(() => {
this.db.prepare("DELETE FROM reports WHERE id = ?").run(id);
});
this.db.bumpLastModified();
this.emit("report:deleted", id);
}
private requireReport(id: string): Report {
const report = this.getReport(id);
if (!report) throw new ReportStoreError(`Report ${id} not found`);
return report;
}
private rowToReport(row: ReportRow): Report {
return {
id: row.id,
cadence: row.cadence,
periodStart: row.periodStart,
periodEnd: row.periodEnd,
title: row.title,
status: row.status,
generationStartedAt: row.generationStartedAt,
generationCompletedAt: row.generationCompletedAt,
reviewStartedAt: row.reviewStartedAt,
reviewCompletedAt: row.reviewCompletedAt,
approvedAt: row.approvedAt,
approvedBy: row.approvedBy,
publishedAt: row.publishedAt,
archivedAt: row.archivedAt,
failureReason: row.failureReason,
draftMarkdown: row.draftMarkdown,
renderedHtmlPath: row.renderedHtmlPath,
metadata: this.parseMetadata(row.metadataJson),
combinedReview: this.parseCombinedReview(row.combinedReviewJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private persistExisting(report: Report): void {
const result = this.db.prepare(`
UPDATE reports
SET cadence = @cadence,
periodStart = @periodStart,
periodEnd = @periodEnd,
title = @title,
status = @status,
generationStartedAt = @generationStartedAt,
generationCompletedAt = @generationCompletedAt,
reviewStartedAt = @reviewStartedAt,
reviewCompletedAt = @reviewCompletedAt,
approvedAt = @approvedAt,
approvedBy = @approvedBy,
publishedAt = @publishedAt,
archivedAt = @archivedAt,
failureReason = @failureReason,
draftMarkdown = @draftMarkdown,
renderedHtmlPath = @renderedHtmlPath,
metadataJson = @metadataJson,
combinedReviewJson = @combinedReviewJson,
updatedAt = @updatedAt
WHERE id = @id
`).run(this.toDbParams(report, false));
if (result.changes === 0) {
throw new ReportStoreError(`Report ${report.id} not found`);
}
}
private toDbParams(report: Report, includeCreatedAt: boolean): Record<string, unknown> {
return {
id: report.id,
cadence: report.cadence,
periodStart: report.periodStart,
periodEnd: report.periodEnd,
title: report.title,
status: report.status,
generationStartedAt: report.generationStartedAt,
generationCompletedAt: report.generationCompletedAt,
reviewStartedAt: report.reviewStartedAt,
reviewCompletedAt: report.reviewCompletedAt,
approvedAt: report.approvedAt,
approvedBy: report.approvedBy,
publishedAt: report.publishedAt,
archivedAt: report.archivedAt,
failureReason: report.failureReason,
draftMarkdown: report.draftMarkdown,
renderedHtmlPath: report.renderedHtmlPath,
metadataJson: JSON.stringify(report.metadata ?? {}),
combinedReviewJson: report.combinedReview ? JSON.stringify(report.combinedReview) : null,
...(includeCreatedAt ? { createdAt: report.createdAt } : {}),
updatedAt: report.updatedAt,
};
}
private parseMetadata(json: string): Record<string, unknown> {
try {
const parsed = JSON.parse(json);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
private parseCombinedReview(json: string | null): CombinedReview | null {
if (!json) return null;
try {
return JSON.parse(json) as CombinedReview;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,77 @@
import type { CombinedReview } from "../review-types.js";
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
export type ReportStatus =
| "generating"
| "review_pending"
| "review_in_progress"
| "review_complete"
| "approved"
| "published"
| "archived"
| "failed";
export interface Report {
id: string;
cadence: ReportCadence;
periodStart: string;
periodEnd: string;
title: string;
status: ReportStatus;
generationStartedAt: string;
generationCompletedAt: string | null;
reviewStartedAt: string | null;
reviewCompletedAt: string | null;
approvedAt: string | null;
approvedBy: string | null;
publishedAt: string | null;
archivedAt: string | null;
failureReason: string | null;
draftMarkdown: string | null;
renderedHtmlPath: string | null;
metadata: Record<string, unknown>;
combinedReview: CombinedReview | null;
createdAt: string;
updatedAt: string;
}
export interface ReportCreateInput {
cadence: ReportCadence;
periodStart: string;
periodEnd: string;
title: string;
metadata?: Record<string, unknown>;
draftMarkdown?: string;
}
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "metadata" | "failureReason">>;
export interface ReportListFilter {
cadence?: ReportCadence;
status?: ReportStatus;
statusIn?: ReportStatus[];
periodStartFrom?: string;
periodStartTo?: string;
limit?: number;
offset?: number;
orderBy?: "createdAt" | "periodStart";
orderDir?: "asc" | "desc";
}
const TERMINAL_STATUSES = new Set<ReportStatus>(["published", "archived", "failed"]);
const LINEAR_TRANSITIONS: Record<Exclude<ReportStatus, "published" | "archived" | "failed">, ReportStatus> = {
generating: "review_pending",
review_pending: "review_in_progress",
review_in_progress: "review_complete",
review_complete: "approved",
approved: "published",
};
export function isValidReportStatusTransition(from: ReportStatus, to: ReportStatus): boolean {
if (from === to) return true;
if (TERMINAL_STATUSES.has(from)) return false;
if (to === "failed" || to === "archived") return true;
if (!(from in LINEAR_TRANSITIONS)) return false;
return LINEAR_TRANSITIONS[from as keyof typeof LINEAR_TRANSITIONS] === to;
}