@runfusion/fusion 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +2254 -1349
- package/dist/client/assets/AgentDetailView-ZbHEbYRT.js +18 -0
- package/dist/client/assets/{AgentsView-BkB9FiMT.js → AgentsView-B3jYk8Kt.js} +3 -3
- package/dist/client/assets/ChatView-DhPkiEGs.js +1 -0
- package/dist/client/assets/{DevServerView-BkvtjZBa.js → DevServerView-DyGDEiBP.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-BK-KbnhP.js → DirectoryPicker-D5UIeIl6.js} +1 -1
- package/dist/client/assets/{DocumentsView-BEg1CQAk.js → DocumentsView-DNHu1T8K.js} +1 -1
- package/dist/client/assets/{EvalsView-Berf9bQm.js → EvalsView-CpRobtDi.js} +1 -1
- package/dist/client/assets/{ExperimentalAgentOnboardingModal-jcInE50G.js → ExperimentalAgentOnboardingModal-DOY_oZi7.js} +1 -1
- package/dist/client/assets/{InsightsView-BX5bSF1J.js → InsightsView-vp0RE8Mg.js} +1 -1
- package/dist/client/assets/MemoryView-PSc5lGJt.js +2 -0
- package/dist/client/assets/MemoryView-zaXewZzi.css +1 -0
- package/dist/client/assets/{NodesView-DLUOBLf6.js → NodesView-DMj6HGeC.js} +1 -1
- package/dist/client/assets/{PiExtensionsManager-COlJf0Kx.js → PiExtensionsManager-DL_QcN56.js} +2 -2
- package/dist/client/assets/PluginManager-BtYKm8IT.js +1 -0
- package/dist/client/assets/{ResearchView-B256Lr8I.js → ResearchView-BhWqfdV0.js} +1 -1
- package/dist/client/assets/{SettingsModal-BeA_nQtW.js → SettingsModal-BAgB4_AR.js} +4 -4
- package/dist/client/assets/{SettingsModal-yRqM4DV8.js → SettingsModal-CUCyaAyE.js} +1 -1
- package/dist/client/assets/{SetupWizardModal-uUZk3TKT.js → SetupWizardModal-BKscasuh.js} +1 -1
- package/dist/client/assets/{SkillsView-CP8JX0P_.js → SkillsView-BdELqTy7.js} +1 -1
- package/dist/client/assets/{TodoView-DCRIkDZ-.js → TodoView-DFNGBDNV.js} +1 -1
- package/dist/client/assets/{folder-open-DHjELt8-.js → folder-open-k1xmUMyr.js} +1 -1
- package/dist/client/assets/index-Qq2JOOWx.css +1 -0
- package/dist/client/assets/{index-CQyVRLOb.js → index-TFYXEVpn.js} +160 -160
- package/dist/client/assets/{star-DYesq1AV.js → star-ne32r3Y4.js} +1 -1
- package/dist/client/assets/{upload-DTWF3Db5.js → upload-MS-2Gx53.js} +1 -1
- package/dist/client/assets/{users--syrel4l.js → users-C519GSjH.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/package.json +1 -1
- package/dist/extension.js +1370 -629
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +9 -11
- package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/bundled.js +30 -0
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +3 -28
- package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +899 -895
- package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +68 -71
- package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +47 -50
- package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-paperclip-runtime/bundled.js +155 -109
- package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/src/index.ts +49 -3
- package/dist/plugins/fusion-plugin-reports/src/report-schema.ts +38 -0
- package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-schema.test.ts +66 -0
- package/dist/plugins/fusion-plugin-reports/src/store/__tests__/report-store.test.ts +177 -0
- package/dist/plugins/fusion-plugin-reports/src/store/report-store.ts +341 -0
- package/dist/plugins/fusion-plugin-reports/src/store/report-types.ts +77 -0
- package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
- package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
- package/package.json +1 -1
- package/dist/client/assets/AgentDetailView-gy_5SUj2.js +0 -18
- package/dist/client/assets/ChatView-B_-B8fqu.js +0 -1
- package/dist/client/assets/MemoryView-CKElJY_3.js +0 -2
- package/dist/client/assets/MemoryView-DiajLXby.css +0 -1
- package/dist/client/assets/PluginManager-CfW55BF4.js +0 -1
- package/dist/client/assets/createLucideIcon-BazL2hk5.js +0 -21
- package/dist/client/assets/dashboard-view-BkTMSZYn.css +0 -1
- package/dist/client/assets/dashboard-view-CyWN-d02.js +0 -63
- package/dist/client/assets/dashboard-view-DdGlfuu-.css +0 -1
- package/dist/client/assets/dashboard-view-lR7YYmSC.js +0 -21
- package/dist/client/assets/index-CxA2Nn0_.css +0 -1
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +0 -58
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +0 -301
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +0 -27
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +0 -157
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +0 -126
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +0 -35
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +0 -36
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +0 -112
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +0 -115
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +0 -128
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +0 -82
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +0 -307
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +0 -60
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +0 -75
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +0 -62
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +0 -78
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +0 -95
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +0 -74
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +0 -58
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +0 -121
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +0 -70
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +0 -89
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +0 -86
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +0 -167
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +0 -66
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +0 -81
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +0 -35
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +0 -19
- package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +0 -70
- package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +0 -8
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +0 -53
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +0 -60
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +0 -45
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +0 -114
- package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +0 -24
- package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +0 -91
- package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +0 -15
- package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +0 -21
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +0 -17
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +0 -292
- package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +0 -65
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Database } from "@fusion/core";
|
|
2
|
+
|
|
3
|
+
export function ensureReportSchema(db: Database): void {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
cadence TEXT NOT NULL CHECK (cadence IN ('daily','weekly','monthly','quarterly','manual')),
|
|
8
|
+
periodStart TEXT NOT NULL,
|
|
9
|
+
periodEnd TEXT NOT NULL,
|
|
10
|
+
title TEXT NOT NULL,
|
|
11
|
+
status TEXT NOT NULL CHECK (status IN ('generating','review_pending','review_in_progress','review_complete','approved','published','archived','failed')),
|
|
12
|
+
generationStartedAt TEXT NOT NULL,
|
|
13
|
+
generationCompletedAt TEXT,
|
|
14
|
+
reviewStartedAt TEXT,
|
|
15
|
+
reviewCompletedAt TEXT,
|
|
16
|
+
approvedAt TEXT,
|
|
17
|
+
approvedBy TEXT,
|
|
18
|
+
publishedAt TEXT,
|
|
19
|
+
archivedAt TEXT,
|
|
20
|
+
failureReason TEXT,
|
|
21
|
+
draftMarkdown TEXT,
|
|
22
|
+
renderedHtmlPath TEXT,
|
|
23
|
+
metadataJson TEXT NOT NULL DEFAULT '{}',
|
|
24
|
+
combinedReviewJson TEXT,
|
|
25
|
+
createdAt TEXT NOT NULL,
|
|
26
|
+
updatedAt TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idxReportsCadenceCreated
|
|
30
|
+
ON reports(cadence, createdAt DESC, id);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idxReportsStatusUpdated
|
|
33
|
+
ON reports(status, updatedAt DESC, id);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idxReportsPeriod
|
|
36
|
+
ON reports(periodStart, periodEnd, id);
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Database } from "@fusion/core";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { ensureReportSchema } from "../../report-schema.js";
|
|
8
|
+
|
|
9
|
+
function makeTmpDir(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), "report-schema-test-"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("ensureReportSchema", () => {
|
|
14
|
+
let tmp: string;
|
|
15
|
+
let db: Database;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = makeTmpDir();
|
|
19
|
+
db = new Database(join(tmp, ".fusion"), { inMemory: true });
|
|
20
|
+
db.init();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
db.close();
|
|
25
|
+
await rm(tmp, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("creates reports table and indexes idempotently", () => {
|
|
29
|
+
ensureReportSchema(db);
|
|
30
|
+
ensureReportSchema(db);
|
|
31
|
+
|
|
32
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='reports'").get() as { name: string } | undefined;
|
|
33
|
+
expect(table?.name).toBe("reports");
|
|
34
|
+
|
|
35
|
+
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reports' ORDER BY name").all() as Array<{ name: string }>;
|
|
36
|
+
expect(indexes.map((row) => row.name)).toEqual(expect.arrayContaining([
|
|
37
|
+
"idxReportsCadenceCreated",
|
|
38
|
+
"idxReportsStatusUpdated",
|
|
39
|
+
"idxReportsPeriod",
|
|
40
|
+
]));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("enforces cadence and status CHECK constraints", () => {
|
|
44
|
+
ensureReportSchema(db);
|
|
45
|
+
|
|
46
|
+
const base = {
|
|
47
|
+
id: "rep_1",
|
|
48
|
+
cadence: "daily",
|
|
49
|
+
periodStart: "2026-05-08T00:00:00.000Z",
|
|
50
|
+
periodEnd: "2026-05-08T23:59:59.999Z",
|
|
51
|
+
title: "Daily Report",
|
|
52
|
+
status: "generating",
|
|
53
|
+
generationStartedAt: "2026-05-09T00:00:00.000Z",
|
|
54
|
+
createdAt: "2026-05-09T00:00:00.000Z",
|
|
55
|
+
updatedAt: "2026-05-09T00:00:00.000Z",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const stmt = db.prepare(`
|
|
59
|
+
INSERT INTO reports (id, cadence, periodStart, periodEnd, title, status, generationStartedAt, createdAt, updatedAt)
|
|
60
|
+
VALUES (@id, @cadence, @periodStart, @periodEnd, @title, @status, @generationStartedAt, @createdAt, @updatedAt)
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
expect(() => stmt.run({ ...base, id: "rep_bad_cadence", cadence: "hourly" })).toThrow();
|
|
64
|
+
expect(() => stmt.run({ ...base, id: "rep_bad_status", status: "queued" })).toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Database } from "@fusion/core";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { ensureReportSchema } from "../../report-schema.js";
|
|
8
|
+
import type { CombinedReview } from "../../review-types.js";
|
|
9
|
+
import { ReportStore, ReportStoreError } from "../report-store.js";
|
|
10
|
+
|
|
11
|
+
function makeTmpDir(): string {
|
|
12
|
+
return mkdtempSync(join(tmpdir(), "report-store-test-"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeReview(): CombinedReview {
|
|
16
|
+
return {
|
|
17
|
+
overallVerdict: "revise",
|
|
18
|
+
consensusSummary: "Needs updates",
|
|
19
|
+
mergedHighlights: ["Good structure"],
|
|
20
|
+
mergedLowlights: ["Missing metrics"],
|
|
21
|
+
mergedSuggestions: ["Add numbers"],
|
|
22
|
+
individual: [],
|
|
23
|
+
failures: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("ReportStore", () => {
|
|
28
|
+
let tmp: string;
|
|
29
|
+
let db: Database;
|
|
30
|
+
let store: ReportStore;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
tmp = makeTmpDir();
|
|
34
|
+
db = new Database(join(tmp, ".fusion"), { inMemory: true });
|
|
35
|
+
db.init();
|
|
36
|
+
ensureReportSchema(db);
|
|
37
|
+
store = new ReportStore(db);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
db.close();
|
|
42
|
+
await rm(tmp, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("createReport persists generating report", () => {
|
|
46
|
+
const listener = vi.fn();
|
|
47
|
+
store.on("report:created", listener);
|
|
48
|
+
|
|
49
|
+
const report = store.createReport({
|
|
50
|
+
cadence: "daily",
|
|
51
|
+
periodStart: "2026-05-01T00:00:00.000Z",
|
|
52
|
+
periodEnd: "2026-05-01T23:59:59.999Z",
|
|
53
|
+
title: "Daily",
|
|
54
|
+
metadata: { sourceCount: 12 },
|
|
55
|
+
draftMarkdown: "# Draft",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(report.id).toMatch(/^rep_/);
|
|
59
|
+
expect(report.status).toBe("generating");
|
|
60
|
+
expect(report.generationStartedAt).toBeTruthy();
|
|
61
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(store.getReport(report.id)?.metadata).toEqual({ sourceCount: 12 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("getReport hydrates metadata and combinedReview", () => {
|
|
66
|
+
const report = store.createReport({ cadence: "weekly", periodStart: "2026-05-01", periodEnd: "2026-05-07", title: "Weekly" });
|
|
67
|
+
store.setStatus(report.id, "review_pending");
|
|
68
|
+
store.setStatus(report.id, "review_in_progress");
|
|
69
|
+
store.attachReview(report.id, makeReview());
|
|
70
|
+
|
|
71
|
+
const hydrated = store.getReport(report.id);
|
|
72
|
+
expect(hydrated?.combinedReview?.overallVerdict).toBe("revise");
|
|
73
|
+
expect(hydrated?.metadata).toEqual({});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("listReports filters and paginates", () => {
|
|
77
|
+
const a = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
78
|
+
const b = store.createReport({ cadence: "weekly", periodStart: "2026-05-02", periodEnd: "2026-05-08", title: "B" });
|
|
79
|
+
const c = store.createReport({ cadence: "daily", periodStart: "2026-05-03", periodEnd: "2026-05-03", title: "C" });
|
|
80
|
+
store.setStatus(c.id, "failed", { failureReason: "x" });
|
|
81
|
+
|
|
82
|
+
expect(store.listReports({ cadence: "daily" }).length).toBe(2);
|
|
83
|
+
expect(store.listReports({ statusIn: ["failed"] }).map((r) => r.id)).toEqual([c.id]);
|
|
84
|
+
expect(store.listReports({ periodStartFrom: "2026-05-02", periodStartTo: "2026-05-03", orderBy: "periodStart", orderDir: "asc" }).map((r) => r.id)).toEqual([b.id, c.id]);
|
|
85
|
+
|
|
86
|
+
const paged = store.listReports({ orderBy: "periodStart", orderDir: "asc", limit: 1, offset: 1 });
|
|
87
|
+
expect(paged).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("setStatus enforces lifecycle and timestamps", () => {
|
|
91
|
+
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
92
|
+
|
|
93
|
+
const pending = store.setStatus(report.id, "review_pending");
|
|
94
|
+
expect(pending.generationCompletedAt).toBeTruthy();
|
|
95
|
+
|
|
96
|
+
const inProgress = store.setStatus(report.id, "review_in_progress");
|
|
97
|
+
expect(inProgress.reviewStartedAt).toBeTruthy();
|
|
98
|
+
|
|
99
|
+
const complete = store.setStatus(report.id, "review_complete");
|
|
100
|
+
expect(complete.reviewCompletedAt).toBeTruthy();
|
|
101
|
+
|
|
102
|
+
const approved = store.setStatus(report.id, "approved", { approvedBy: "agent-1" });
|
|
103
|
+
expect(approved.approvedAt).toBeTruthy();
|
|
104
|
+
expect(approved.approvedBy).toBe("agent-1");
|
|
105
|
+
|
|
106
|
+
const published = store.setStatus(report.id, "published");
|
|
107
|
+
expect(published.publishedAt).toBeTruthy();
|
|
108
|
+
|
|
109
|
+
expect(() => store.setStatus(report.id, "generating")).toThrow(ReportStoreError);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("setStatus failed works from non-terminal state and saves failureReason", () => {
|
|
113
|
+
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
114
|
+
const failed = store.setStatus(report.id, "failed", { failureReason: "timeout" });
|
|
115
|
+
expect(failed.failureReason).toBe("timeout");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("attachRenderedHtml and deleteReport persist and emit events", () => {
|
|
119
|
+
const report = store.createReport({ cadence: "manual", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
120
|
+
const deleted = vi.fn();
|
|
121
|
+
store.on("report:deleted", deleted);
|
|
122
|
+
|
|
123
|
+
const updated = store.attachRenderedHtml(report.id, ".fusion/plugins/reports/report.html");
|
|
124
|
+
expect(updated.renderedHtmlPath).toContain("report.html");
|
|
125
|
+
|
|
126
|
+
store.deleteReport(report.id);
|
|
127
|
+
expect(store.getReport(report.id)).toBeNull();
|
|
128
|
+
expect(deleted).toHaveBeenCalledWith(report.id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("emits events once for update/status/review", () => {
|
|
132
|
+
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
133
|
+
const updated = vi.fn();
|
|
134
|
+
const status = vi.fn();
|
|
135
|
+
const review = vi.fn();
|
|
136
|
+
store.on("report:updated", updated);
|
|
137
|
+
store.on("report:status-changed", status);
|
|
138
|
+
store.on("report:review-attached", review);
|
|
139
|
+
|
|
140
|
+
store.updateReport(report.id, { title: "B" });
|
|
141
|
+
store.setStatus(report.id, "review_pending");
|
|
142
|
+
store.setStatus(report.id, "review_in_progress");
|
|
143
|
+
store.attachReview(report.id, makeReview());
|
|
144
|
+
|
|
145
|
+
expect(updated).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(status).toHaveBeenCalledTimes(3);
|
|
147
|
+
expect(review).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("rolls back failed transaction and commits successful transaction", () => {
|
|
151
|
+
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
|
152
|
+
|
|
153
|
+
const originalPrepare = db.prepare.bind(db);
|
|
154
|
+
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
|
155
|
+
const stmt = originalPrepare(sql);
|
|
156
|
+
if (sql.includes("UPDATE reports") && !sql.includes("WHERE id = @id")) {
|
|
157
|
+
return stmt;
|
|
158
|
+
}
|
|
159
|
+
if (sql.includes("UPDATE reports")) {
|
|
160
|
+
return {
|
|
161
|
+
...stmt,
|
|
162
|
+
run: (...args: unknown[]) => {
|
|
163
|
+
throw new Error("forced failure");
|
|
164
|
+
},
|
|
165
|
+
} as typeof stmt;
|
|
166
|
+
}
|
|
167
|
+
return stmt;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(() => store.updateReport(report.id, { title: "Broken" })).toThrow("forced failure");
|
|
171
|
+
expect(store.getReport(report.id)?.title).toBe("A");
|
|
172
|
+
|
|
173
|
+
spy.mockRestore();
|
|
174
|
+
const ok = store.updateReport(report.id, { title: "Good" });
|
|
175
|
+
expect(ok.title).toBe("Good");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { Database } from "@fusion/core";
|
|
4
|
+
import type { CombinedReview } from "../review-types.js";
|
|
5
|
+
import {
|
|
6
|
+
type Report,
|
|
7
|
+
type ReportCreateInput,
|
|
8
|
+
type ReportListFilter,
|
|
9
|
+
type ReportStatus,
|
|
10
|
+
type ReportUpdateInput,
|
|
11
|
+
isValidReportStatusTransition,
|
|
12
|
+
} from "./report-types.js";
|
|
13
|
+
|
|
14
|
+
interface ReportRow {
|
|
15
|
+
id: string;
|
|
16
|
+
cadence: Report["cadence"];
|
|
17
|
+
periodStart: string;
|
|
18
|
+
periodEnd: string;
|
|
19
|
+
title: string;
|
|
20
|
+
status: ReportStatus;
|
|
21
|
+
generationStartedAt: string;
|
|
22
|
+
generationCompletedAt: string | null;
|
|
23
|
+
reviewStartedAt: string | null;
|
|
24
|
+
reviewCompletedAt: string | null;
|
|
25
|
+
approvedAt: string | null;
|
|
26
|
+
approvedBy: string | null;
|
|
27
|
+
publishedAt: string | null;
|
|
28
|
+
archivedAt: string | null;
|
|
29
|
+
failureReason: string | null;
|
|
30
|
+
draftMarkdown: string | null;
|
|
31
|
+
renderedHtmlPath: string | null;
|
|
32
|
+
metadataJson: string;
|
|
33
|
+
combinedReviewJson: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReportStoreEvents {
|
|
39
|
+
"report:created": [Report];
|
|
40
|
+
"report:updated": [Report];
|
|
41
|
+
"report:status-changed": [Report];
|
|
42
|
+
"report:review-attached": [Report];
|
|
43
|
+
"report:deleted": [string];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ReportStoreError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ReportStoreError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|
54
|
+
constructor(private readonly db: Database) {
|
|
55
|
+
super();
|
|
56
|
+
this.setMaxListeners(50);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
createReport(input: ReportCreateInput): Report {
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const report: Report = {
|
|
62
|
+
id: `rep_${randomUUID().replaceAll("-", "")}`,
|
|
63
|
+
cadence: input.cadence,
|
|
64
|
+
periodStart: input.periodStart,
|
|
65
|
+
periodEnd: input.periodEnd,
|
|
66
|
+
title: input.title,
|
|
67
|
+
status: "generating",
|
|
68
|
+
generationStartedAt: now,
|
|
69
|
+
generationCompletedAt: null,
|
|
70
|
+
reviewStartedAt: null,
|
|
71
|
+
reviewCompletedAt: null,
|
|
72
|
+
approvedAt: null,
|
|
73
|
+
approvedBy: null,
|
|
74
|
+
publishedAt: null,
|
|
75
|
+
archivedAt: null,
|
|
76
|
+
failureReason: null,
|
|
77
|
+
draftMarkdown: input.draftMarkdown ?? null,
|
|
78
|
+
renderedHtmlPath: null,
|
|
79
|
+
metadata: input.metadata ?? {},
|
|
80
|
+
combinedReview: null,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
this.db.transaction(() => {
|
|
86
|
+
this.db.prepare(`
|
|
87
|
+
INSERT INTO reports (
|
|
88
|
+
id, cadence, periodStart, periodEnd, title, status,
|
|
89
|
+
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
|
90
|
+
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
|
91
|
+
draftMarkdown, renderedHtmlPath, metadataJson, combinedReviewJson, createdAt, updatedAt
|
|
92
|
+
) VALUES (
|
|
93
|
+
@id, @cadence, @periodStart, @periodEnd, @title, @status,
|
|
94
|
+
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
|
|
95
|
+
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
|
|
96
|
+
@draftMarkdown, @renderedHtmlPath, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
|
97
|
+
)
|
|
98
|
+
`).run(this.toDbParams(report, true));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.db.bumpLastModified();
|
|
102
|
+
this.emit("report:created", report);
|
|
103
|
+
return report;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getReport(id: string): Report | null {
|
|
107
|
+
const row = this.db.prepare("SELECT * FROM reports WHERE id = ?").get(id) as ReportRow | undefined;
|
|
108
|
+
return row ? this.rowToReport(row) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
listReports(filter: ReportListFilter = {}): Report[] {
|
|
112
|
+
const params: unknown[] = [];
|
|
113
|
+
const where: string[] = [];
|
|
114
|
+
|
|
115
|
+
if (filter.cadence) {
|
|
116
|
+
where.push("cadence = ?");
|
|
117
|
+
params.push(filter.cadence);
|
|
118
|
+
}
|
|
119
|
+
if (filter.statusIn && filter.statusIn.length > 0) {
|
|
120
|
+
where.push(`status IN (${filter.statusIn.map(() => "?").join(",")})`);
|
|
121
|
+
params.push(...filter.statusIn);
|
|
122
|
+
} else if (filter.status) {
|
|
123
|
+
where.push("status = ?");
|
|
124
|
+
params.push(filter.status);
|
|
125
|
+
}
|
|
126
|
+
if (filter.periodStartFrom) {
|
|
127
|
+
where.push("periodStart >= ?");
|
|
128
|
+
params.push(filter.periodStartFrom);
|
|
129
|
+
}
|
|
130
|
+
if (filter.periodStartTo) {
|
|
131
|
+
where.push("periodStart <= ?");
|
|
132
|
+
params.push(filter.periodStartTo);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const orderBy = filter.orderBy === "periodStart" ? "periodStart" : "createdAt";
|
|
136
|
+
const orderDir = filter.orderDir === "asc" ? "ASC" : "DESC";
|
|
137
|
+
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
|
|
138
|
+
const offset = Math.max(filter.offset ?? 0, 0);
|
|
139
|
+
|
|
140
|
+
const sql = `
|
|
141
|
+
SELECT * FROM reports
|
|
142
|
+
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
|
|
143
|
+
ORDER BY ${orderBy} ${orderDir}, id ${orderDir}
|
|
144
|
+
LIMIT ? OFFSET ?
|
|
145
|
+
`;
|
|
146
|
+
params.push(limit, offset);
|
|
147
|
+
|
|
148
|
+
const rows = this.db.prepare(sql).all(...params) as ReportRow[];
|
|
149
|
+
return rows.map((row) => this.rowToReport(row));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
updateReport(id: string, patch: ReportUpdateInput): Report {
|
|
153
|
+
const current = this.requireReport(id);
|
|
154
|
+
const next: Report = {
|
|
155
|
+
...current,
|
|
156
|
+
title: patch.title ?? current.title,
|
|
157
|
+
draftMarkdown: patch.draftMarkdown ?? current.draftMarkdown,
|
|
158
|
+
renderedHtmlPath: patch.renderedHtmlPath ?? current.renderedHtmlPath,
|
|
159
|
+
metadata: patch.metadata ?? current.metadata,
|
|
160
|
+
failureReason: patch.failureReason ?? current.failureReason,
|
|
161
|
+
updatedAt: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
this.db.transaction(() => this.persistExisting(next));
|
|
165
|
+
this.db.bumpLastModified();
|
|
166
|
+
this.emit("report:updated", next);
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setStatus(id: string, next: ReportStatus, opts: { failureReason?: string; approvedBy?: string } = {}): Report {
|
|
171
|
+
const current = this.requireReport(id);
|
|
172
|
+
if (current.status === next) return current;
|
|
173
|
+
if (!isValidReportStatusTransition(current.status, next)) {
|
|
174
|
+
throw new ReportStoreError(`Invalid status transition: ${current.status} -> ${next}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
const updated: Report = {
|
|
179
|
+
...current,
|
|
180
|
+
status: next,
|
|
181
|
+
updatedAt: now,
|
|
182
|
+
failureReason: next === "failed" ? (opts.failureReason ?? current.failureReason) : current.failureReason,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (next === "review_pending") updated.generationCompletedAt = now;
|
|
186
|
+
if (next === "review_in_progress") updated.reviewStartedAt = now;
|
|
187
|
+
if (next === "review_complete") updated.reviewCompletedAt = now;
|
|
188
|
+
if (next === "approved") {
|
|
189
|
+
updated.approvedAt = now;
|
|
190
|
+
updated.approvedBy = opts.approvedBy ?? current.approvedBy;
|
|
191
|
+
}
|
|
192
|
+
if (next === "published") updated.publishedAt = now;
|
|
193
|
+
if (next === "archived") updated.archivedAt = now;
|
|
194
|
+
|
|
195
|
+
this.db.transaction(() => this.persistExisting(updated));
|
|
196
|
+
this.db.bumpLastModified();
|
|
197
|
+
this.emit("report:status-changed", updated);
|
|
198
|
+
return updated;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
attachReview(id: string, combined: CombinedReview): Report {
|
|
202
|
+
const current = this.requireReport(id);
|
|
203
|
+
if (current.status !== "review_in_progress") {
|
|
204
|
+
throw new ReportStoreError(`attachReview requires review_in_progress status; got ${current.status}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const updated: Report = {
|
|
209
|
+
...current,
|
|
210
|
+
combinedReview: combined,
|
|
211
|
+
status: "review_complete",
|
|
212
|
+
reviewCompletedAt: now,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
this.db.transaction(() => this.persistExisting(updated));
|
|
217
|
+
this.db.bumpLastModified();
|
|
218
|
+
this.emit("report:review-attached", updated);
|
|
219
|
+
this.emit("report:status-changed", updated);
|
|
220
|
+
return updated;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
attachRenderedHtml(id: string, htmlPath: string): Report {
|
|
224
|
+
return this.updateReport(id, { renderedHtmlPath: htmlPath });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
deleteReport(id: string): void {
|
|
228
|
+
this.requireReport(id);
|
|
229
|
+
this.db.transaction(() => {
|
|
230
|
+
this.db.prepare("DELETE FROM reports WHERE id = ?").run(id);
|
|
231
|
+
});
|
|
232
|
+
this.db.bumpLastModified();
|
|
233
|
+
this.emit("report:deleted", id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private requireReport(id: string): Report {
|
|
237
|
+
const report = this.getReport(id);
|
|
238
|
+
if (!report) throw new ReportStoreError(`Report ${id} not found`);
|
|
239
|
+
return report;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private rowToReport(row: ReportRow): Report {
|
|
243
|
+
return {
|
|
244
|
+
id: row.id,
|
|
245
|
+
cadence: row.cadence,
|
|
246
|
+
periodStart: row.periodStart,
|
|
247
|
+
periodEnd: row.periodEnd,
|
|
248
|
+
title: row.title,
|
|
249
|
+
status: row.status,
|
|
250
|
+
generationStartedAt: row.generationStartedAt,
|
|
251
|
+
generationCompletedAt: row.generationCompletedAt,
|
|
252
|
+
reviewStartedAt: row.reviewStartedAt,
|
|
253
|
+
reviewCompletedAt: row.reviewCompletedAt,
|
|
254
|
+
approvedAt: row.approvedAt,
|
|
255
|
+
approvedBy: row.approvedBy,
|
|
256
|
+
publishedAt: row.publishedAt,
|
|
257
|
+
archivedAt: row.archivedAt,
|
|
258
|
+
failureReason: row.failureReason,
|
|
259
|
+
draftMarkdown: row.draftMarkdown,
|
|
260
|
+
renderedHtmlPath: row.renderedHtmlPath,
|
|
261
|
+
metadata: this.parseMetadata(row.metadataJson),
|
|
262
|
+
combinedReview: this.parseCombinedReview(row.combinedReviewJson),
|
|
263
|
+
createdAt: row.createdAt,
|
|
264
|
+
updatedAt: row.updatedAt,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private persistExisting(report: Report): void {
|
|
269
|
+
const result = this.db.prepare(`
|
|
270
|
+
UPDATE reports
|
|
271
|
+
SET cadence = @cadence,
|
|
272
|
+
periodStart = @periodStart,
|
|
273
|
+
periodEnd = @periodEnd,
|
|
274
|
+
title = @title,
|
|
275
|
+
status = @status,
|
|
276
|
+
generationStartedAt = @generationStartedAt,
|
|
277
|
+
generationCompletedAt = @generationCompletedAt,
|
|
278
|
+
reviewStartedAt = @reviewStartedAt,
|
|
279
|
+
reviewCompletedAt = @reviewCompletedAt,
|
|
280
|
+
approvedAt = @approvedAt,
|
|
281
|
+
approvedBy = @approvedBy,
|
|
282
|
+
publishedAt = @publishedAt,
|
|
283
|
+
archivedAt = @archivedAt,
|
|
284
|
+
failureReason = @failureReason,
|
|
285
|
+
draftMarkdown = @draftMarkdown,
|
|
286
|
+
renderedHtmlPath = @renderedHtmlPath,
|
|
287
|
+
metadataJson = @metadataJson,
|
|
288
|
+
combinedReviewJson = @combinedReviewJson,
|
|
289
|
+
updatedAt = @updatedAt
|
|
290
|
+
WHERE id = @id
|
|
291
|
+
`).run(this.toDbParams(report, false));
|
|
292
|
+
|
|
293
|
+
if (result.changes === 0) {
|
|
294
|
+
throw new ReportStoreError(`Report ${report.id} not found`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private toDbParams(report: Report, includeCreatedAt: boolean): Record<string, unknown> {
|
|
299
|
+
return {
|
|
300
|
+
id: report.id,
|
|
301
|
+
cadence: report.cadence,
|
|
302
|
+
periodStart: report.periodStart,
|
|
303
|
+
periodEnd: report.periodEnd,
|
|
304
|
+
title: report.title,
|
|
305
|
+
status: report.status,
|
|
306
|
+
generationStartedAt: report.generationStartedAt,
|
|
307
|
+
generationCompletedAt: report.generationCompletedAt,
|
|
308
|
+
reviewStartedAt: report.reviewStartedAt,
|
|
309
|
+
reviewCompletedAt: report.reviewCompletedAt,
|
|
310
|
+
approvedAt: report.approvedAt,
|
|
311
|
+
approvedBy: report.approvedBy,
|
|
312
|
+
publishedAt: report.publishedAt,
|
|
313
|
+
archivedAt: report.archivedAt,
|
|
314
|
+
failureReason: report.failureReason,
|
|
315
|
+
draftMarkdown: report.draftMarkdown,
|
|
316
|
+
renderedHtmlPath: report.renderedHtmlPath,
|
|
317
|
+
metadataJson: JSON.stringify(report.metadata ?? {}),
|
|
318
|
+
combinedReviewJson: report.combinedReview ? JSON.stringify(report.combinedReview) : null,
|
|
319
|
+
...(includeCreatedAt ? { createdAt: report.createdAt } : {}),
|
|
320
|
+
updatedAt: report.updatedAt,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private parseMetadata(json: string): Record<string, unknown> {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(json);
|
|
327
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
328
|
+
} catch {
|
|
329
|
+
return {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private parseCombinedReview(json: string | null): CombinedReview | null {
|
|
334
|
+
if (!json) return null;
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(json) as CombinedReview;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CombinedReview } from "../review-types.js";
|
|
2
|
+
|
|
3
|
+
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
|
4
|
+
|
|
5
|
+
export type ReportStatus =
|
|
6
|
+
| "generating"
|
|
7
|
+
| "review_pending"
|
|
8
|
+
| "review_in_progress"
|
|
9
|
+
| "review_complete"
|
|
10
|
+
| "approved"
|
|
11
|
+
| "published"
|
|
12
|
+
| "archived"
|
|
13
|
+
| "failed";
|
|
14
|
+
|
|
15
|
+
export interface Report {
|
|
16
|
+
id: string;
|
|
17
|
+
cadence: ReportCadence;
|
|
18
|
+
periodStart: string;
|
|
19
|
+
periodEnd: string;
|
|
20
|
+
title: string;
|
|
21
|
+
status: ReportStatus;
|
|
22
|
+
generationStartedAt: string;
|
|
23
|
+
generationCompletedAt: string | null;
|
|
24
|
+
reviewStartedAt: string | null;
|
|
25
|
+
reviewCompletedAt: string | null;
|
|
26
|
+
approvedAt: string | null;
|
|
27
|
+
approvedBy: string | null;
|
|
28
|
+
publishedAt: string | null;
|
|
29
|
+
archivedAt: string | null;
|
|
30
|
+
failureReason: string | null;
|
|
31
|
+
draftMarkdown: string | null;
|
|
32
|
+
renderedHtmlPath: string | null;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
combinedReview: CombinedReview | null;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReportCreateInput {
|
|
40
|
+
cadence: ReportCadence;
|
|
41
|
+
periodStart: string;
|
|
42
|
+
periodEnd: string;
|
|
43
|
+
title: string;
|
|
44
|
+
metadata?: Record<string, unknown>;
|
|
45
|
+
draftMarkdown?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "metadata" | "failureReason">>;
|
|
49
|
+
|
|
50
|
+
export interface ReportListFilter {
|
|
51
|
+
cadence?: ReportCadence;
|
|
52
|
+
status?: ReportStatus;
|
|
53
|
+
statusIn?: ReportStatus[];
|
|
54
|
+
periodStartFrom?: string;
|
|
55
|
+
periodStartTo?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
offset?: number;
|
|
58
|
+
orderBy?: "createdAt" | "periodStart";
|
|
59
|
+
orderDir?: "asc" | "desc";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TERMINAL_STATUSES = new Set<ReportStatus>(["published", "archived", "failed"]);
|
|
63
|
+
const LINEAR_TRANSITIONS: Record<Exclude<ReportStatus, "published" | "archived" | "failed">, ReportStatus> = {
|
|
64
|
+
generating: "review_pending",
|
|
65
|
+
review_pending: "review_in_progress",
|
|
66
|
+
review_in_progress: "review_complete",
|
|
67
|
+
review_complete: "approved",
|
|
68
|
+
approved: "published",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function isValidReportStatusTransition(from: ReportStatus, to: ReportStatus): boolean {
|
|
72
|
+
if (from === to) return true;
|
|
73
|
+
if (TERMINAL_STATUSES.has(from)) return false;
|
|
74
|
+
if (to === "failed" || to === "archived") return true;
|
|
75
|
+
if (!(from in LINEAR_TRANSITIONS)) return false;
|
|
76
|
+
return LINEAR_TRANSITIONS[from as keyof typeof LINEAR_TRANSITIONS] === to;
|
|
77
|
+
}
|