feat(FN-3860): add cadence resolution seam (+7 more)
Commits merged: - feat(FN-3860): complete Step 8 — documentation and delivery - test(FN-3860): complete Step 7 — add scaffold seam coverage - feat(FN-3860): complete Step 6 — re-export scaffold seams - feat(FN-3860): complete Step 5 — add pipeline orchestrator seam - feat(FN-3860): complete Step 4 — add in-memory runs store seam - feat(FN-3860): complete Step 3 — add aggregation seam - feat(FN-3860): complete Step 2 — add cadence resolution seam - feat(FN-3846): short-circuit phantom merges for already-landed branches Files changed: .changeset/fn-3860-reports-scaffold-files.md | 5 ++ plugins/fusion-plugin-reports/README.md | 9 +++ .../src/__tests__/scaffold.test.ts | 60 +++++++++++++++++++ plugins/fusion-plugin-reports/src/aggregation.ts | 23 ++++++++ plugins/fusion-plugin-reports/src/cadence.ts | 23 ++++++++ plugins/fusion-plugin-reports/src/index.ts | 4 ++ plugins/fusion-plugin-reports/src/pipeline.ts | 58 ++++++++++++++++++ plugins/fusion-plugin-reports/src/runs-store.ts | 69 ++++++++++++++++++++++ 8 files changed, 251 insertions(+) Fusion-Task-Id: FN-3860
This commit is contained in:
5
.changeset/fn-3860-reports-scaffold-files.md
Normal file
5
.changeset/fn-3860-reports-scaffold-files.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Reports plugin: add interim cadence/aggregation/pipeline/runs-store seam exports so downstream tasks (FN-3780+) can plug in real implementations without scaffold churn.
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Generates HTML system activity reports with multi-agent review.
|
Generates HTML system activity reports with multi-agent review.
|
||||||
|
|
||||||
|
## Scaffold seams (interim)
|
||||||
|
|
||||||
|
The plugin currently exports four interim scaffold seams to unblock downstream implementation work:
|
||||||
|
|
||||||
|
- `resolveEnabledCadences` / `ReportsCadence` (`src/cadence.ts`) — interim cadence-resolution seam; scheduled cadence registry + cron/sentinel wiring lands in FN-3779.
|
||||||
|
- `aggregateReportData` + aggregation types (`src/aggregation.ts`) — interim aggregation seam; real aggregation orchestration lands in FN-3780.
|
||||||
|
- `startReportsPipeline` + pipeline dependency interfaces (`src/pipeline.ts`) — interim orchestrator seam to keep call sites stable while FN-3779/FN-3780 wire real runtime components.
|
||||||
|
- `createInMemoryReportsRunsStore` + run record/store types (`src/runs-store.ts`) — interim in-memory run state store; persistent store replacement lands in FN-3784.
|
||||||
|
|
||||||
## Review Panel
|
## Review Panel
|
||||||
|
|
||||||
The plugin exposes `runReviewPanel()` / `runGeneratedReportReview()` to fan out a generated report draft to multiple reviewer agents in parallel.
|
The plugin exposes `runReviewPanel()` / `runGeneratedReportReview()` to fan out a generated report draft to multiple reviewer agents in parallel.
|
||||||
|
|||||||
60
plugins/fusion-plugin-reports/src/__tests__/scaffold.test.ts
Normal file
60
plugins/fusion-plugin-reports/src/__tests__/scaffold.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { aggregateReportData } from "../aggregation.js";
|
||||||
|
import { resolveEnabledCadences } from "../cadence.js";
|
||||||
|
import { startReportsPipeline } from "../pipeline.js";
|
||||||
|
import { createInMemoryReportsRunsStore } from "../runs-store.js";
|
||||||
|
|
||||||
|
describe("reports scaffold seams", () => {
|
||||||
|
it("resolves daily and weekly cadence by default in UTC", () => {
|
||||||
|
expect(resolveEnabledCadences({})).toEqual([
|
||||||
|
{ cadence: "daily", timezone: "UTC" },
|
||||||
|
{ cadence: "weekly", timezone: "UTC" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves weekly-only cadence with configured timezone", () => {
|
||||||
|
expect(
|
||||||
|
resolveEnabledCadences({ dailyEnabled: false, weeklyEnabled: true, timezone: "America/Los_Angeles" }),
|
||||||
|
).toEqual([{ cadence: "weekly", timezone: "America/Los_Angeles" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty sections from scaffold aggregator", async () => {
|
||||||
|
const output = await aggregateReportData({ runId: "run-1", cadence: "daily", settings: {} });
|
||||||
|
expect(output.sections).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs pipeline to review status on happy path", async () => {
|
||||||
|
const runsStore = createInMemoryReportsRunsStore();
|
||||||
|
const result = await startReportsPipeline(
|
||||||
|
{ runId: "run-1", cadence: "daily", settings: {} },
|
||||||
|
{ runsStore, aggregate: aggregateReportData },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("review");
|
||||||
|
const stored = await runsStore.get("run-1");
|
||||||
|
expect(stored).toEqual(result);
|
||||||
|
expect(stored?.cadence).toBe("daily");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks pipeline run failed when aggregation throws and does not rethrow", async () => {
|
||||||
|
const runsStore = createInMemoryReportsRunsStore();
|
||||||
|
const result = await startReportsPipeline(
|
||||||
|
{ runId: "run-2", cadence: "weekly", settings: {} },
|
||||||
|
{
|
||||||
|
runsStore,
|
||||||
|
aggregate: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("failed");
|
||||||
|
expect(result.error).toBe("boom");
|
||||||
|
await expect(runsStore.get("run-2")).resolves.toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when updating unknown run", async () => {
|
||||||
|
const runsStore = createInMemoryReportsRunsStore();
|
||||||
|
await expect(runsStore.update("missing", { status: "failed" })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
plugins/fusion-plugin-reports/src/aggregation.ts
Normal file
23
plugins/fusion-plugin-reports/src/aggregation.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* INTERIM SEAM — replaced by FN-3780's real aggregation orchestrator.
|
||||||
|
* Keep the type surface stable so FN-3780 can swap the implementation without renaming exports.
|
||||||
|
*/
|
||||||
|
import type { ReportsCadence } from "./cadence.js";
|
||||||
|
|
||||||
|
export interface ReportAggregationInput {
|
||||||
|
runId: string;
|
||||||
|
cadence: ReportsCadence;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportAggregationOutput {
|
||||||
|
summary: string;
|
||||||
|
sections: Array<{ id: string; title: string; body: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportsAggregator = (input: ReportAggregationInput) => Promise<ReportAggregationOutput>;
|
||||||
|
|
||||||
|
export const aggregateReportData: ReportsAggregator = async ({ cadence }) => ({
|
||||||
|
summary: `Aggregation scaffold not yet implemented for ${cadence} reports.`,
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
23
plugins/fusion-plugin-reports/src/cadence.ts
Normal file
23
plugins/fusion-plugin-reports/src/cadence.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getDailyEnabled, getTimezone, getWeeklyEnabled } from "./settings.js";
|
||||||
|
|
||||||
|
export type ReportsCadence = "daily" | "weekly";
|
||||||
|
|
||||||
|
export interface CadenceResolution {
|
||||||
|
cadence: ReportsCadence;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEnabledCadences(settings: Record<string, unknown>): CadenceResolution[] {
|
||||||
|
const timezone = getTimezone(settings);
|
||||||
|
const enabled: CadenceResolution[] = [];
|
||||||
|
|
||||||
|
if (getDailyEnabled(settings)) {
|
||||||
|
enabled.push({ cadence: "daily", timezone });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getWeeklyEnabled(settings)) {
|
||||||
|
enabled.push({ cadence: "weekly", timezone });
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
@@ -121,6 +121,10 @@ export async function runGeneratedReportReview(input: RunGeneratedReportReviewIn
|
|||||||
export default plugin;
|
export default plugin;
|
||||||
|
|
||||||
export * from "./settings.js";
|
export * from "./settings.js";
|
||||||
|
export * from "./cadence.js";
|
||||||
|
export * from "./aggregation.js";
|
||||||
|
export * from "./pipeline.js";
|
||||||
|
export * from "./runs-store.js";
|
||||||
export * from "./review-types.js";
|
export * from "./review-types.js";
|
||||||
export * from "./review-panel.js";
|
export * from "./review-panel.js";
|
||||||
export { ensureReportSchema } from "./report-schema.js";
|
export { ensureReportSchema } from "./report-schema.js";
|
||||||
|
|||||||
58
plugins/fusion-plugin-reports/src/pipeline.ts
Normal file
58
plugins/fusion-plugin-reports/src/pipeline.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* INTERIM ORCHESTRATOR. FN-3779 replaces this with cadence-registry + cron-sentinel wiring;
|
||||||
|
* FN-3780 replaces the aggregate dependency with the real aggregation layer.
|
||||||
|
* Keep the `ReportsPipelineDependencies` shape stable so both can plug in without callsite churn.
|
||||||
|
*/
|
||||||
|
import type { ReportsAggregator } from "./aggregation.js";
|
||||||
|
import type { ReportsCadence } from "./cadence.js";
|
||||||
|
import type { ReportRunRecord, ReportsRunsStore } from "./runs-store.js";
|
||||||
|
|
||||||
|
export interface ReportsPipelineDependencies {
|
||||||
|
runsStore: ReportsRunsStore;
|
||||||
|
aggregate: ReportsAggregator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartPipelineInput {
|
||||||
|
runId: string;
|
||||||
|
cadence: ReportsCadence;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startReportsPipeline(
|
||||||
|
input: StartPipelineInput,
|
||||||
|
deps: ReportsPipelineDependencies,
|
||||||
|
): Promise<ReportRunRecord> {
|
||||||
|
const nowIso = (input.now ?? new Date()).toISOString();
|
||||||
|
|
||||||
|
const created = await deps.runsStore.create({
|
||||||
|
id: input.runId,
|
||||||
|
cadence: input.cadence,
|
||||||
|
status: "queued",
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deps.runsStore.update(input.runId, { status: "running", updatedAt: nowIso });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.aggregate({
|
||||||
|
runId: input.runId,
|
||||||
|
cadence: input.cadence,
|
||||||
|
settings: input.settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const reviewed = await deps.runsStore.update(input.runId, { status: "review", updatedAt });
|
||||||
|
return reviewed ?? { ...created, status: "review", updatedAt };
|
||||||
|
} catch (error) {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const failed = await deps.runsStore.update(input.runId, {
|
||||||
|
status: "failed",
|
||||||
|
error: message,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
return failed ?? { ...created, status: "failed", error: message, updatedAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
69
plugins/fusion-plugin-reports/src/runs-store.ts
Normal file
69
plugins/fusion-plugin-reports/src/runs-store.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* INTERIM IN-MEMORY STORE — replaced by FN-3784's persistent storage.
|
||||||
|
* The `ReportRunRecord` shape is intended to be a strict subset of FN-3784's eventual schema.
|
||||||
|
*/
|
||||||
|
import type { ReportsCadence } from "./cadence.js";
|
||||||
|
|
||||||
|
export type ReportRunStatus = "queued" | "running" | "review" | "approved" | "published" | "failed";
|
||||||
|
|
||||||
|
export interface ReportRunRecord {
|
||||||
|
id: string;
|
||||||
|
cadence: ReportsCadence;
|
||||||
|
status: ReportRunStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
error?: string;
|
||||||
|
reportId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportsRunsStore {
|
||||||
|
create(record: ReportRunRecord): Promise<ReportRunRecord>;
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<ReportRunRecord, "id" | "createdAt">>,
|
||||||
|
): Promise<ReportRunRecord | undefined>;
|
||||||
|
get(id: string): Promise<ReportRunRecord | undefined>;
|
||||||
|
list(limit?: number): Promise<ReportRunRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRecord(record: ReportRunRecord): ReportRunRecord {
|
||||||
|
return { ...record };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInMemoryReportsRunsStore(seed: ReportRunRecord[] = []): ReportsRunsStore {
|
||||||
|
const records = new Map<string, ReportRunRecord>(seed.map((record) => [record.id, cloneRecord(record)]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
async create(record) {
|
||||||
|
const stored = cloneRecord(record);
|
||||||
|
records.set(stored.id, stored);
|
||||||
|
return cloneRecord(stored);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, patch) {
|
||||||
|
const current = records.get(id);
|
||||||
|
if (!current) return undefined;
|
||||||
|
|
||||||
|
const next: ReportRunRecord = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
records.set(id, next);
|
||||||
|
return cloneRecord(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id) {
|
||||||
|
const record = records.get(id);
|
||||||
|
return record ? cloneRecord(record) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(limit = 50) {
|
||||||
|
const clampedLimit = Math.max(0, limit);
|
||||||
|
return Array.from(records.values())
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
.slice(0, clampedLimit)
|
||||||
|
.map((record) => cloneRecord(record));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user