feat(FN-1399): add background memory summarization after task completion
- Add MemoryInsights class in @fusion/core for AI-powered memory audit generation - Add post-run hook to CronRunner for triggering memory summarization after scheduled tasks - Wire memory background processing in both dashboard and serve commands - Add memoryAuditEnabled and memoryAuditSchedule settings for configurable automation - Fix startup ordering: sync automation before cronRunner.start() to prevent race conditions - Add comprehensive tests for memory-insights and dashboard/serve integration - Update contributing.md and settings-reference.md with documentation
This commit is contained in:
@@ -40,6 +40,16 @@ function makeMockStore() {
|
||||
|
||||
// ── Mock @fusion/core ──────────────────────────────────────────────────
|
||||
|
||||
const mockSyncInsightExtraction = vi.fn().mockResolvedValue(undefined);
|
||||
const mockProcessAndAudit = vi.fn().mockResolvedValue({
|
||||
generatedAt: new Date().toISOString(),
|
||||
health: "healthy",
|
||||
checks: [],
|
||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
|
||||
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||
AutomationStore: vi.fn().mockImplementation(() => ({
|
||||
@@ -55,6 +65,9 @@ vi.mock("@fusion/core", () => ({
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
deleteAgent: vi.fn(),
|
||||
})),
|
||||
syncInsightExtractionAutomation: mockSyncInsightExtraction,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
processAndAuditInsightExtraction: mockProcessAndAudit,
|
||||
}));
|
||||
|
||||
// ── Mock @fusion/dashboard ─────────────────────────────────────────────
|
||||
@@ -381,3 +394,97 @@ describe("runDashboard — MissionAutopilot wiring", () => {
|
||||
expect(autopilotInstance.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — Memory Insight Automation wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
});
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("syncs insight extraction automation on startup", async () => {
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncInsightExtraction).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
|
||||
const { CronRunner } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(CronRunner).toHaveBeenCalledTimes(1);
|
||||
const cronOptions = (CronRunner as ReturnType<typeof vi.fn>).mock.calls[0][2];
|
||||
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
|
||||
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
|
||||
});
|
||||
|
||||
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
|
||||
await runDashboard(0, {});
|
||||
|
||||
// Get the store mock to emit settings change
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
|
||||
|
||||
// Simulate settings update
|
||||
mockSyncInsightExtraction.mockClear();
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: {
|
||||
insightExtractionEnabled: true,
|
||||
insightExtractionSchedule: "0 3 * * *",
|
||||
},
|
||||
previous: {
|
||||
insightExtractionEnabled: false,
|
||||
insightExtractionSchedule: "0 2 * * *",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
|
||||
await runDashboard(0, {});
|
||||
|
||||
// Get the store mock to emit settings change
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
|
||||
|
||||
// Simulate unrelated settings update
|
||||
mockSyncInsightExtraction.mockClear();
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: {
|
||||
maxConcurrent: 5,
|
||||
},
|
||||
previous: {
|
||||
maxConcurrent: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSyncInsightExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockSyncInsightExtraction.mockRejectedValueOnce(new Error("Sync failed"));
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,6 +263,16 @@ vi.mock("@fusion/core", () => ({
|
||||
AgentStore: mocks.agentStoreCtor,
|
||||
CentralCore: mocks.centralCoreCtor,
|
||||
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
|
||||
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
processAndAuditInsightExtraction: vi.fn().mockResolvedValue({
|
||||
generatedAt: new Date().toISOString(),
|
||||
health: "healthy",
|
||||
checks: [],
|
||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
|
||||
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
@@ -468,3 +478,143 @@ describe("runServe", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe — Memory Insight Automation wiring", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
});
|
||||
|
||||
it("syncs insight extraction automation on startup", async () => {
|
||||
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
|
||||
expect(syncInsightExtractionAutomation).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
maxConcurrent: 2,
|
||||
recycleWorktrees: false,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
|
||||
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
|
||||
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
|
||||
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
|
||||
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
// Simulate settings update
|
||||
syncInsightExtractionAutomation.mockClear();
|
||||
mocks.taskStores[0].emit("settings:updated", {
|
||||
settings: {
|
||||
insightExtractionEnabled: true,
|
||||
insightExtractionSchedule: "0 3 * * *",
|
||||
},
|
||||
previous: {
|
||||
insightExtractionEnabled: false,
|
||||
insightExtractionSchedule: "0 2 * * *",
|
||||
},
|
||||
});
|
||||
|
||||
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
|
||||
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
// Simulate unrelated settings update
|
||||
syncInsightExtractionAutomation.mockClear();
|
||||
mocks.taskStores[0].emit("settings:updated", {
|
||||
settings: {
|
||||
maxConcurrent: 5,
|
||||
},
|
||||
previous: {
|
||||
maxConcurrent: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(syncInsightExtractionAutomation).not.toHaveBeenCalled();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
|
||||
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine";
|
||||
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
||||
@@ -864,8 +864,65 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
missionAutopilot.setScheduler(scheduler);
|
||||
|
||||
// ── CronRunner: scheduled task execution ──────────────────────────
|
||||
|
||||
// Post-run callback for memory insight extraction processing
|
||||
const onMemoryInsightRunProcessed = async (
|
||||
schedule: ScheduledTask,
|
||||
result: AutomationRunResult,
|
||||
): Promise<void> => {
|
||||
// Only process the memory insight extraction schedule
|
||||
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the AI step output from the result
|
||||
const stepResults = result.stepResults ?? [];
|
||||
const aiStep = stepResults.find((sr) => sr.stepName === "Extract Memory Insights");
|
||||
|
||||
if (!aiStep) {
|
||||
console.log(`[memory-audit] No insight extraction step found in ${schedule.name} result`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[memory-audit] Processing memory insight extraction run...`);
|
||||
|
||||
try {
|
||||
const auditReport = await processAndAuditInsightExtraction(cwd, {
|
||||
rawResponse: aiStep.output ?? "",
|
||||
stepSuccess: aiStep.success,
|
||||
runAt: result.startedAt,
|
||||
error: aiStep.error,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[memory-audit] ✓ Audit complete — Health: ${auditReport.health}, ` +
|
||||
`Insights: ${auditReport.insightsMemory.insightCount}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] ✗ Failed to process insight extraction: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const aiPromptExecutor = await createAiPromptExecutor(cwd);
|
||||
const cronRunner = new CronRunner(store, automationStore, { aiPromptExecutor });
|
||||
const cronRunner = new CronRunner(store, automationStore, {
|
||||
aiPromptExecutor,
|
||||
onScheduleRunProcessed: onMemoryInsightRunProcessed,
|
||||
});
|
||||
|
||||
// ── Sync insight extraction automation on startup ─────────────────
|
||||
// Run sync BEFORE starting the cron runner to avoid stale config races.
|
||||
// This ensures the insight extraction schedule is created/updated/deleted
|
||||
// before the first tick can execute it.
|
||||
try {
|
||||
await syncInsightExtractionAutomation(automationStore, settings);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
cronRunner.start();
|
||||
|
||||
triage.start();
|
||||
@@ -965,6 +1022,29 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
});
|
||||
|
||||
// ── Insight extraction automation sync on settings change ─────────
|
||||
// When insight extraction settings change (enable/disable/schedule/min interval),
|
||||
// resync the automation schedule without requiring a restart.
|
||||
registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => {
|
||||
const insightKeys = [
|
||||
"insightExtractionEnabled",
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
] as const;
|
||||
|
||||
const relevantKeyChanged = insightKeys.some((key) => s[key] !== prev[key]);
|
||||
if (relevantKeyChanged) {
|
||||
try {
|
||||
await syncInsightExtractionAutomation(automationStore, s);
|
||||
console.log("[memory-audit] Insight extraction automation synced with settings");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Periodic retry: catch failed merges on each poll cycle ────────
|
||||
// Uses a setTimeout chain so the interval dynamically follows
|
||||
// settings.pollIntervalMs without requiring an engine restart.
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
CentralCore,
|
||||
AgentStore,
|
||||
getTaskMergeBlocker,
|
||||
syncInsightExtractionAutomation,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||
processAndAuditInsightExtraction,
|
||||
} from "@fusion/core";
|
||||
import type { ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import {
|
||||
TriageProcessor,
|
||||
@@ -562,8 +566,64 @@ export async function runServe(
|
||||
|
||||
missionAutopilot.setScheduler(scheduler);
|
||||
|
||||
// Post-run callback for memory insight extraction processing
|
||||
const onMemoryInsightRunProcessed = async (
|
||||
schedule: ScheduledTask,
|
||||
result: AutomationRunResult,
|
||||
): Promise<void> => {
|
||||
// Only process the memory insight extraction schedule
|
||||
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the AI step output from the result
|
||||
const stepResults = result.stepResults ?? [];
|
||||
const aiStep = stepResults.find((sr) => sr.stepName === "Extract Memory Insights");
|
||||
|
||||
if (!aiStep) {
|
||||
console.log(`[memory-audit] No insight extraction step found in ${schedule.name} result`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[memory-audit] Processing memory insight extraction run...`);
|
||||
|
||||
try {
|
||||
const auditReport = await processAndAuditInsightExtraction(cwd, {
|
||||
rawResponse: aiStep.output ?? "",
|
||||
stepSuccess: aiStep.success,
|
||||
runAt: result.startedAt,
|
||||
error: aiStep.error,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[memory-audit] ✓ Audit complete — Health: ${auditReport.health}, ` +
|
||||
`Insights: ${auditReport.insightsMemory.insightCount}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] ✗ Failed to process insight extraction: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const aiPromptExecutor = await createAiPromptExecutor(cwd);
|
||||
const cronRunner = new CronRunner(store, automationStore, { aiPromptExecutor });
|
||||
const cronRunner = new CronRunner(store, automationStore, {
|
||||
aiPromptExecutor,
|
||||
onScheduleRunProcessed: onMemoryInsightRunProcessed,
|
||||
});
|
||||
|
||||
// ── Sync insight extraction automation on startup ─────────────────
|
||||
// Run sync BEFORE starting the cron runner to avoid stale config races.
|
||||
// This ensures the insight extraction schedule is created/updated/deleted
|
||||
// before the first tick can execute it.
|
||||
try {
|
||||
await syncInsightExtractionAutomation(automationStore, settings);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
cronRunner.start();
|
||||
|
||||
triage.start();
|
||||
@@ -656,6 +716,29 @@ export async function runServe(
|
||||
}
|
||||
});
|
||||
|
||||
// ── Insight extraction automation sync on settings change ─────────
|
||||
// When insight extraction settings change (enable/disable/schedule/min interval),
|
||||
// resync the automation schedule without requiring a restart.
|
||||
store.on("settings:updated", async ({ settings: s, previous: prev }) => {
|
||||
const insightKeys = [
|
||||
"insightExtractionEnabled",
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
] as const;
|
||||
|
||||
const relevantKeyChanged = insightKeys.some((key) => s[key] !== prev[key]);
|
||||
if (relevantKeyChanged) {
|
||||
try {
|
||||
await syncInsightExtractionAutomation(automationStore, s);
|
||||
console.log("[memory-audit] Insight extraction automation synced with settings");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] Failed to sync insight extraction automation: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mergeRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
async function scheduleMergeRetry(): Promise<void> {
|
||||
const currentSettings = await store.getSettings().catch(() => settings);
|
||||
|
||||
@@ -239,6 +239,7 @@ export {
|
||||
export {
|
||||
MEMORY_WORKING_PATH,
|
||||
MEMORY_INSIGHTS_PATH,
|
||||
MEMORY_AUDIT_PATH,
|
||||
DEFAULT_INSIGHT_SCHEDULE,
|
||||
DEFAULT_MIN_INTERVAL_MS,
|
||||
MIN_INSIGHT_GROWTH_CHARS,
|
||||
@@ -246,6 +247,8 @@ export {
|
||||
readWorkingMemory,
|
||||
readInsightsMemory,
|
||||
writeInsightsMemory,
|
||||
readMemoryAudit,
|
||||
writeMemoryAudit,
|
||||
buildInsightExtractionPrompt,
|
||||
parseInsightExtractionResponse,
|
||||
mergeInsights,
|
||||
@@ -253,11 +256,18 @@ export {
|
||||
getDefaultInsightsTemplate,
|
||||
createInsightExtractionAutomation,
|
||||
syncInsightExtractionAutomation,
|
||||
processInsightExtractionRun,
|
||||
processAndAuditInsightExtraction,
|
||||
generateMemoryAudit,
|
||||
renderMemoryAuditMarkdown,
|
||||
} from "./memory-insights.js";
|
||||
export type {
|
||||
MemoryInsightCategory,
|
||||
MemoryInsight,
|
||||
InsightExtractionResult,
|
||||
MemoryAuditCheck,
|
||||
MemoryAuditReport,
|
||||
ProcessRunInput,
|
||||
} from "./memory-insights.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -536,3 +536,401 @@ describe("memory-insights", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Audit File Operations ──────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
MEMORY_AUDIT_PATH,
|
||||
readMemoryAudit,
|
||||
writeMemoryAudit,
|
||||
} from "./memory-insights.js";
|
||||
|
||||
describe("memory-insights audit file operations", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readMemoryAudit", () => {
|
||||
it("should return null when audit file does not exist", async () => {
|
||||
const result = await readMemoryAudit(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return content when audit file exists", async () => {
|
||||
const content = "# Memory Audit Report\n\nGenerated...";
|
||||
writeFileSync(join(tempDir, MEMORY_AUDIT_PATH), content);
|
||||
|
||||
const result = await readMemoryAudit(tempDir);
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeMemoryAudit", () => {
|
||||
it("should create the audit file", async () => {
|
||||
const content = "# Memory Audit Report\n\nGenerated...";
|
||||
await writeMemoryAudit(tempDir, content);
|
||||
|
||||
const filePath = join(tempDir, MEMORY_AUDIT_PATH);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(content);
|
||||
});
|
||||
|
||||
it("should overwrite existing content", async () => {
|
||||
writeFileSync(join(tempDir, MEMORY_AUDIT_PATH), "old content");
|
||||
await writeMemoryAudit(tempDir, "new content");
|
||||
|
||||
expect(readFileSync(join(tempDir, MEMORY_AUDIT_PATH), "utf-8")).toBe("new content");
|
||||
});
|
||||
|
||||
it("should create .fusion directory if it does not exist", async () => {
|
||||
const newDir = join(tempDir, "new-project");
|
||||
await mkdir(newDir, { recursive: true });
|
||||
await writeMemoryAudit(newDir, "test content");
|
||||
expect(existsSync(join(newDir, MEMORY_AUDIT_PATH))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Run Processing ─────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
processInsightExtractionRun,
|
||||
processAndAuditInsightExtraction,
|
||||
} from "./memory-insights.js";
|
||||
|
||||
describe("memory-insights run processing", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-run-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("processInsightExtractionRun", () => {
|
||||
it("should parse and merge successful extraction", async () => {
|
||||
// No existing insights
|
||||
const rawResponse = JSON.stringify({
|
||||
summary: "Found 2 new insights",
|
||||
insights: [
|
||||
{ category: "pattern", content: "Use TypeScript for type safety" },
|
||||
{ category: "pitfall", content: "Avoid any type assertions" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await processInsightExtractionRun(tempDir, {
|
||||
rawResponse,
|
||||
stepSuccess: true,
|
||||
runAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(result.insights).toHaveLength(2);
|
||||
expect(result.summary).toBe("Found 2 new insights");
|
||||
expect(result.newInsightCount).toBe(2);
|
||||
expect(result.duplicateCount).toBe(0);
|
||||
|
||||
// Verify insights file was written
|
||||
const insightsPath = join(tempDir, MEMORY_INSIGHTS_PATH);
|
||||
expect(existsSync(insightsPath)).toBe(true);
|
||||
const content = readFileSync(insightsPath, "utf-8");
|
||||
expect(content).toContain("Use TypeScript for type safety");
|
||||
expect(content).toContain("Avoid any type assertions");
|
||||
});
|
||||
|
||||
it("should handle existing insights without duplicating", async () => {
|
||||
// Create existing insights
|
||||
const existingInsights = `# Memory Insights
|
||||
|
||||
## Patterns
|
||||
- Already existing pattern
|
||||
|
||||
## Last Updated: 2026-01-01
|
||||
`;
|
||||
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), existingInsights);
|
||||
|
||||
const rawResponse = JSON.stringify({
|
||||
summary: "Found 1 new insight",
|
||||
insights: [
|
||||
{ category: "pattern", content: "Already existing pattern" }, // duplicate
|
||||
{ category: "principle", content: "New principle" }, // new
|
||||
],
|
||||
});
|
||||
|
||||
const result = await processInsightExtractionRun(tempDir, {
|
||||
rawResponse,
|
||||
stepSuccess: true,
|
||||
runAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(result.insights).toHaveLength(2);
|
||||
expect(result.duplicateCount).toBe(1);
|
||||
expect(result.newInsightCount).toBe(1);
|
||||
|
||||
// Verify only new insight was added
|
||||
const content = readFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "utf-8");
|
||||
expect(content).toContain("Already existing pattern");
|
||||
expect(content).toContain("New principle");
|
||||
// Should only have "Already existing pattern" once
|
||||
expect(content.match(/Already existing pattern/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle malformed JSON gracefully", async () => {
|
||||
const result = await processInsightExtractionRun(tempDir, {
|
||||
rawResponse: "not valid json at all",
|
||||
stepSuccess: true,
|
||||
runAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(result.insights).toHaveLength(0);
|
||||
expect(result.summary).toContain("Parse error");
|
||||
});
|
||||
|
||||
it("should handle failed step", async () => {
|
||||
const result = await processInsightExtractionRun(tempDir, {
|
||||
rawResponse: "",
|
||||
stepSuccess: false,
|
||||
runAt: new Date().toISOString(),
|
||||
error: "AI timeout",
|
||||
});
|
||||
|
||||
expect(result.insights).toHaveLength(0);
|
||||
expect(result.summary).toContain("AI timeout");
|
||||
});
|
||||
|
||||
it("should preserve existing insights on failure", async () => {
|
||||
// Create existing insights
|
||||
const existingInsights = `# Memory Insights
|
||||
|
||||
## Patterns
|
||||
- Important existing pattern
|
||||
|
||||
## Last Updated: 2026-01-01
|
||||
`;
|
||||
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), existingInsights);
|
||||
|
||||
// Process with malformed JSON
|
||||
await processInsightExtractionRun(tempDir, {
|
||||
rawResponse: "invalid",
|
||||
stepSuccess: true,
|
||||
runAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Existing insights should be preserved
|
||||
const content = readFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "utf-8");
|
||||
expect(content).toContain("Important existing pattern");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processAndAuditInsightExtraction", () => {
|
||||
it("should process run and generate audit report", async () => {
|
||||
// Create working memory
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_WORKING_PATH),
|
||||
"## Architecture\n\nSome architecture notes\n## Conventions\n\nSome conventions",
|
||||
);
|
||||
|
||||
const rawResponse = JSON.stringify({
|
||||
summary: "Extracted insights",
|
||||
insights: [{ category: "pattern", content: "Test pattern" }],
|
||||
});
|
||||
|
||||
const report = await processAndAuditInsightExtraction(tempDir, {
|
||||
rawResponse,
|
||||
stepSuccess: true,
|
||||
runAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(report).toBeDefined();
|
||||
expect(report.health).toBeDefined();
|
||||
expect(report.checks).toBeDefined();
|
||||
expect(report.checks.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify audit file was written
|
||||
const auditPath = join(tempDir, MEMORY_AUDIT_PATH);
|
||||
expect(existsSync(auditPath)).toBe(true);
|
||||
const auditContent = readFileSync(auditPath, "utf-8");
|
||||
expect(auditContent).toContain("Memory Audit Report");
|
||||
});
|
||||
|
||||
it("should generate audit with failed extraction", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_WORKING_PATH),
|
||||
"## Architecture\n\nNotes",
|
||||
);
|
||||
|
||||
const report = await processAndAuditInsightExtraction(tempDir, {
|
||||
rawResponse: "",
|
||||
stepSuccess: false,
|
||||
runAt: new Date().toISOString(),
|
||||
error: "Step timed out",
|
||||
});
|
||||
|
||||
expect(report.extraction.success).toBe(false);
|
||||
expect(report.extraction.error).toBe("Step timed out");
|
||||
expect(report.checks).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Audit Generation ──────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
generateMemoryAudit,
|
||||
renderMemoryAuditMarkdown,
|
||||
} from "./memory-insights.js";
|
||||
|
||||
describe("memory-insights audit generation", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-gen-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("generateMemoryAudit", () => {
|
||||
it("should detect missing working memory", async () => {
|
||||
const report = await generateMemoryAudit(tempDir);
|
||||
|
||||
const check = report.checks.find((c) => c.id === "working-memory-exists");
|
||||
expect(check).toBeDefined();
|
||||
expect(check!.passed).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect present working memory", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_WORKING_PATH),
|
||||
"## Architecture\n\nSome architecture\n## Conventions\n\nSome conventions",
|
||||
);
|
||||
|
||||
const report = await generateMemoryAudit(tempDir);
|
||||
|
||||
const check = report.checks.find((c) => c.id === "working-memory-exists");
|
||||
expect(check!.passed).toBe(true);
|
||||
expect(report.workingMemory.exists).toBe(true);
|
||||
expect(report.workingMemory.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should count insights in insights memory", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_INSIGHTS_PATH),
|
||||
`# Memory Insights
|
||||
|
||||
## Patterns
|
||||
- Pattern 1
|
||||
- Pattern 2
|
||||
|
||||
## Principles
|
||||
- Principle 1
|
||||
|
||||
## Last Updated: 2026-04-09
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await generateMemoryAudit(tempDir);
|
||||
|
||||
expect(report.insightsMemory.exists).toBe(true);
|
||||
expect(report.insightsMemory.categories.pattern).toBe(2);
|
||||
expect(report.insightsMemory.categories.principle).toBe(1);
|
||||
expect(report.insightsMemory.insightCount).toBe(3);
|
||||
});
|
||||
|
||||
it("should include extraction info when provided", async () => {
|
||||
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");
|
||||
|
||||
const report = await generateMemoryAudit(tempDir, {
|
||||
runAt: new Date().toISOString(),
|
||||
success: true,
|
||||
insightCount: 5,
|
||||
duplicateCount: 2,
|
||||
skippedCount: 0,
|
||||
summary: "Found 5 new patterns",
|
||||
});
|
||||
|
||||
expect(report.extraction.runAt).toBeTruthy();
|
||||
expect(report.extraction.success).toBe(true);
|
||||
expect(report.extraction.insightCount).toBe(5);
|
||||
expect(report.extraction.duplicateCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should calculate health status", async () => {
|
||||
// No files = issues
|
||||
const noFilesReport = await generateMemoryAudit(tempDir);
|
||||
expect(["healthy", "warning", "issues"]).toContain(noFilesReport.health);
|
||||
|
||||
// All good
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_WORKING_PATH),
|
||||
"## Architecture\n\n## Conventions\n\n## Pitfalls\n\nNotes",
|
||||
);
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_INSIGHTS_PATH),
|
||||
`# Memory Insights
|
||||
|
||||
## Patterns
|
||||
- Pattern 1
|
||||
|
||||
## Last Updated: 2026-04-09
|
||||
`,
|
||||
);
|
||||
|
||||
const goodReport = await generateMemoryAudit(tempDir, {
|
||||
runAt: new Date().toISOString(),
|
||||
success: true,
|
||||
insightCount: 1,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: "Found patterns",
|
||||
});
|
||||
|
||||
expect(goodReport.health).toBe("healthy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMemoryAuditMarkdown", () => {
|
||||
it("should render a complete audit report", async () => {
|
||||
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");
|
||||
writeFileSync(join(tempDir, MEMORY_INSIGHTS_PATH), "# Memory Insights\n\n## Patterns\n- Pattern 1\n\n## Last Updated: 2026-04-09");
|
||||
|
||||
const report = await generateMemoryAudit(tempDir, {
|
||||
runAt: new Date().toISOString(),
|
||||
success: true,
|
||||
insightCount: 1,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: "Test summary",
|
||||
});
|
||||
|
||||
const markdown = renderMemoryAuditMarkdown(report);
|
||||
|
||||
expect(markdown).toContain("# Memory Audit Report");
|
||||
expect(markdown).toContain("## Working Memory");
|
||||
expect(markdown).toContain("## Insights Memory");
|
||||
expect(markdown).toContain("## Last Extraction");
|
||||
expect(markdown).toContain("## Audit Checks");
|
||||
expect(markdown).toContain("Health:");
|
||||
});
|
||||
|
||||
it("should handle empty report", async () => {
|
||||
const report = await generateMemoryAudit(tempDir);
|
||||
const markdown = renderMemoryAuditMarkdown(report);
|
||||
|
||||
expect(markdown).toContain("Memory Audit Report");
|
||||
expect(markdown).toContain("Working Memory");
|
||||
expect(markdown).toContain("Insights Memory");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,9 @@ export const MEMORY_WORKING_PATH = ".fusion/memory.md";
|
||||
/** Path to insights memory relative to project root. */
|
||||
export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
|
||||
|
||||
/** Path to memory audit report relative to project root. */
|
||||
export const MEMORY_AUDIT_PATH = ".fusion/memory-audit.md";
|
||||
|
||||
/** Default cron schedule for insight extraction: daily at 2 AM. */
|
||||
export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *";
|
||||
|
||||
@@ -122,6 +125,67 @@ export interface InsightExtractionResult {
|
||||
extractedAt: string;
|
||||
}
|
||||
|
||||
// ── Run Processing Types ───────────────────────────────────────────────
|
||||
|
||||
/** Individual audit check result. */
|
||||
export interface MemoryAuditCheck {
|
||||
/** Unique identifier for this check. */
|
||||
id: string;
|
||||
/** Human-readable check name. */
|
||||
name: string;
|
||||
/** Whether the check passed. */
|
||||
passed: boolean;
|
||||
/** Details about the check result. */
|
||||
details: string;
|
||||
}
|
||||
|
||||
/** Result of a memory audit run. */
|
||||
export interface MemoryAuditReport {
|
||||
/** ISO-8601 timestamp of the audit. */
|
||||
generatedAt: string;
|
||||
/** Working memory file status. */
|
||||
workingMemory: {
|
||||
exists: boolean;
|
||||
size: number;
|
||||
sectionCount: number;
|
||||
lastModified?: string;
|
||||
};
|
||||
/** Insights memory file status. */
|
||||
insightsMemory: {
|
||||
exists: boolean;
|
||||
size: number;
|
||||
insightCount: number;
|
||||
categories: Record<MemoryInsightCategory, number>;
|
||||
lastUpdated?: string;
|
||||
};
|
||||
/** Extraction metadata. */
|
||||
extraction: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
};
|
||||
/** Individual audit checks. */
|
||||
checks: MemoryAuditCheck[];
|
||||
/** Overall health status. */
|
||||
health: "healthy" | "warning" | "issues";
|
||||
}
|
||||
|
||||
/** Input for processing an insight extraction run. */
|
||||
export interface ProcessRunInput {
|
||||
/** Raw AI response text from the insight extraction step. */
|
||||
rawResponse: string;
|
||||
/** Whether the AI step itself succeeded. */
|
||||
stepSuccess: boolean;
|
||||
/** Timestamp of the run. */
|
||||
runAt: string;
|
||||
/** Optional error message if the step failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── File I/O ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -176,6 +240,39 @@ export async function writeInsightsMemory(rootDir: string, content: string): Pro
|
||||
await writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the memory audit file (`memory-audit.md`).
|
||||
*
|
||||
* Returns `null` if the file does not exist.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @returns The audit file content, or null if not found.
|
||||
*/
|
||||
export async function readMemoryAudit(rootDir: string): Promise<string | null> {
|
||||
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return readFile(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the memory audit file (`memory-audit.md`).
|
||||
*
|
||||
* Creates the `.fusion` directory if it does not exist.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param content - The markdown content to write.
|
||||
*/
|
||||
export async function writeMemoryAudit(rootDir: string, content: string): Promise<void> {
|
||||
const filePath = join(rootDir, MEMORY_AUDIT_PATH);
|
||||
const dir = join(rootDir, ".fusion");
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
await writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
// ── AI Prompt Construction ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -621,3 +718,606 @@ export async function syncInsightExtractionAutomation(
|
||||
return await automationStore.createSchedule(input);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run Processing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process an insight extraction run and persist results.
|
||||
*
|
||||
* This function takes the raw AI response from an insight extraction step,
|
||||
* parses it, merges new insights into the insights memory file, and generates
|
||||
* an audit report.
|
||||
*
|
||||
* **Safety guarantees:**
|
||||
* - Malformed AI output does not destroy existing insights content
|
||||
* - Failures are surfaced as controlled errors and keep prior files intact
|
||||
* - All operations are atomic where possible
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param input - The run processing input containing raw response and metadata.
|
||||
* @returns The processed insight extraction result, or throws on failure.
|
||||
* @throws Error if processing fails after all recovery attempts.
|
||||
*/
|
||||
export async function processInsightExtractionRun(
|
||||
rootDir: string,
|
||||
input: ProcessRunInput,
|
||||
): Promise<InsightExtractionResult & { newInsightCount: number; duplicateCount: number }> {
|
||||
const { rawResponse, stepSuccess, runAt, error: stepError } = input;
|
||||
|
||||
// Read existing insights before any modification
|
||||
const existingInsights = await readInsightsMemory(rootDir);
|
||||
|
||||
let parsedResult: InsightExtractionResult;
|
||||
let parseError: string | undefined;
|
||||
|
||||
// Try to parse the AI response
|
||||
if (stepSuccess && rawResponse.trim()) {
|
||||
try {
|
||||
parsedResult = parseInsightExtractionResponse(rawResponse);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
// Create a fallback result that indicates the parse failure
|
||||
parsedResult = {
|
||||
insights: [],
|
||||
summary: `Parse error: ${parseError}`,
|
||||
extractedAt: runAt,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
parsedResult = {
|
||||
insights: [],
|
||||
summary: stepError || "Step did not produce output",
|
||||
extractedAt: runAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate duplicate count before merging
|
||||
const duplicateCount = countDuplicateInsights(existingInsights ?? "", parsedResult.insights);
|
||||
const newInsights = parsedResult.insights;
|
||||
|
||||
// Merge new insights into insights memory
|
||||
let newInsightsContent: string;
|
||||
if (existingInsights !== null) {
|
||||
newInsightsContent = mergeInsights(existingInsights, newInsights);
|
||||
} else {
|
||||
newInsightsContent = mergeInsights("", newInsights);
|
||||
}
|
||||
|
||||
// Write the updated insights file
|
||||
// Note: We write unconditionally to capture even empty merges
|
||||
// The merge function returns existing unchanged when there are no new insights
|
||||
await writeInsightsMemory(rootDir, newInsightsContent);
|
||||
|
||||
// Count actual new insights added (by comparing before/after)
|
||||
const beforeCount = countInsightsInMarkdown(existingInsights ?? "");
|
||||
const afterCount = countInsightsInMarkdown(newInsightsContent);
|
||||
const newInsightCount = Math.max(0, afterCount - beforeCount);
|
||||
|
||||
return {
|
||||
...parsedResult,
|
||||
newInsightCount,
|
||||
duplicateCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Count insights that would be duplicates against existing content.
|
||||
*
|
||||
* @param existingContent - The existing insights markdown content.
|
||||
* @param newInsights - Array of new insights to check.
|
||||
* @returns The number of insights that already exist (case-insensitive match).
|
||||
*/
|
||||
function countDuplicateInsights(
|
||||
existingContent: string,
|
||||
newInsights: MemoryInsight[],
|
||||
): number {
|
||||
if (!existingContent || newInsights.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let duplicateCount = 0;
|
||||
for (const insight of newInsights) {
|
||||
if (existingContent.toLowerCase().includes(insight.content.toLowerCase())) {
|
||||
duplicateCount++;
|
||||
}
|
||||
}
|
||||
return duplicateCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of insights (bullet points) in a markdown string.
|
||||
*
|
||||
* @param markdown - The markdown content to count insights in.
|
||||
* @returns The number of insight bullet points found.
|
||||
*/
|
||||
function countInsightsInMarkdown(markdown: string): number {
|
||||
if (!markdown) return 0;
|
||||
// Count lines that start with "- " and are inside section headers
|
||||
// Simple heuristic: count all "- " lines that are indented or follow section headers
|
||||
const lines = markdown.split("\n");
|
||||
let inSection = false;
|
||||
let count = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("## ")) {
|
||||
inSection = true;
|
||||
} else if (trimmed.startsWith("# ") || trimmed.startsWith("<!--")) {
|
||||
// Title or comment, not a section
|
||||
} else if (inSection && trimmed.startsWith("- ")) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Audit Generation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a memory audit report.
|
||||
*
|
||||
* The audit checks include:
|
||||
* - Working memory file presence and structure
|
||||
* - Insights memory file presence and content
|
||||
* - Required section headers in working memory
|
||||
* - Memory size and change metadata
|
||||
* - Duplicate/empty insight handling
|
||||
* - Extraction timestamp and summary
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param lastExtraction - Optional information about the last extraction run.
|
||||
* @returns The generated audit report.
|
||||
*/
|
||||
export async function generateMemoryAudit(
|
||||
rootDir: string,
|
||||
lastExtraction?: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
},
|
||||
): Promise<MemoryAuditReport> {
|
||||
const checks: MemoryAuditCheck[] = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// ── Check 1: Working memory file presence ──────────────────────────
|
||||
const workingMemoryPath = join(rootDir, MEMORY_WORKING_PATH);
|
||||
const workingMemoryExists = existsSync(workingMemoryPath);
|
||||
let workingMemorySize = 0;
|
||||
let workingMemorySectionCount = 0;
|
||||
let workingMemoryContent = "";
|
||||
|
||||
if (workingMemoryExists) {
|
||||
try {
|
||||
workingMemoryContent = await readFile(workingMemoryPath, "utf-8");
|
||||
workingMemorySize = workingMemoryContent.length;
|
||||
workingMemorySectionCount = countMarkdownSections(workingMemoryContent);
|
||||
|
||||
checks.push({
|
||||
id: "working-memory-exists",
|
||||
name: "Working memory file exists",
|
||||
passed: true,
|
||||
details: `File exists with ${workingMemorySize} characters and ${workingMemorySectionCount} sections`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
id: "working-memory-exists",
|
||||
name: "Working memory file exists",
|
||||
passed: false,
|
||||
details: `File exists but could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
checks.push({
|
||||
id: "working-memory-exists",
|
||||
name: "Working memory file exists",
|
||||
passed: false,
|
||||
details: "File .fusion/memory.md does not exist",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 2: Working memory has meaningful content ──────────────────
|
||||
if (workingMemoryExists) {
|
||||
const hasContent = workingMemorySize > 0;
|
||||
const hasSections = workingMemorySectionCount >= 2;
|
||||
|
||||
checks.push({
|
||||
id: "working-memory-content",
|
||||
name: "Working memory has meaningful content",
|
||||
passed: hasContent && hasSections,
|
||||
details: hasContent
|
||||
? `Has ${workingMemorySize} characters with ${workingMemorySectionCount} sections`
|
||||
: "Working memory is empty",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 3: Working memory has required sections ──────────────────
|
||||
if (workingMemoryContent) {
|
||||
const requiredSections = ["Architecture", "Conventions", "Pitfalls"];
|
||||
const foundSections = requiredSections.filter((section) =>
|
||||
workingMemoryContent.includes(`## ${section}`),
|
||||
);
|
||||
|
||||
checks.push({
|
||||
id: "working-memory-sections",
|
||||
name: "Working memory has required sections",
|
||||
passed: foundSections.length >= 2, // At least 2 of 3 recommended
|
||||
details: foundSections.length >= 2
|
||||
? `Found sections: ${foundSections.join(", ")}`
|
||||
: `Missing sections: ${requiredSections.filter((s) => !foundSections.includes(s)).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 4: Insights memory file presence ──────────────────────────
|
||||
const insightsMemoryPath = join(rootDir, MEMORY_INSIGHTS_PATH);
|
||||
const insightsMemoryExists = existsSync(insightsMemoryPath);
|
||||
let insightsMemorySize = 0;
|
||||
let insightsMemoryContent = "";
|
||||
const categoryCounts: Record<MemoryInsightCategory, number> = {
|
||||
pattern: 0,
|
||||
principle: 0,
|
||||
convention: 0,
|
||||
pitfall: 0,
|
||||
context: 0,
|
||||
};
|
||||
let lastUpdated: string | undefined;
|
||||
|
||||
if (insightsMemoryExists) {
|
||||
try {
|
||||
insightsMemoryContent = await readFile(insightsMemoryPath, "utf-8");
|
||||
insightsMemorySize = insightsMemoryContent.length;
|
||||
|
||||
// Count insights by category
|
||||
for (const [category, header] of Object.entries({
|
||||
pattern: "## Patterns",
|
||||
principle: "## Principles",
|
||||
convention: "## Conventions",
|
||||
pitfall: "## Pitfalls",
|
||||
context: "## Context",
|
||||
})) {
|
||||
categoryCounts[category as MemoryInsightCategory] = countInsightsInSection(
|
||||
insightsMemoryContent,
|
||||
header,
|
||||
);
|
||||
}
|
||||
|
||||
// Find last updated timestamp
|
||||
const lastUpdatedMatch = insightsMemoryContent.match(/## Last Updated:\s*(.+)$/m);
|
||||
if (lastUpdatedMatch) {
|
||||
lastUpdated = lastUpdatedMatch[1].trim();
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: "insights-memory-exists",
|
||||
name: "Insights memory file exists",
|
||||
passed: true,
|
||||
details: `File exists with ${insightsMemorySize} characters`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
id: "insights-memory-exists",
|
||||
name: "Insights memory file exists",
|
||||
passed: false,
|
||||
details: `File exists but could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
checks.push({
|
||||
id: "insights-memory-exists",
|
||||
name: "Insights memory file exists",
|
||||
passed: false,
|
||||
details: "File .fusion/memory-insights.md does not exist yet",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 5: Insights memory has content ───────────────────────────
|
||||
if (insightsMemoryExists) {
|
||||
const totalInsights = Object.values(categoryCounts).reduce((a, b) => a + b, 0);
|
||||
checks.push({
|
||||
id: "insights-memory-content",
|
||||
name: "Insights memory has content",
|
||||
passed: totalInsights > 0,
|
||||
details: totalInsights > 0
|
||||
? `Contains ${totalInsights} insights across categories: patterns=${categoryCounts.pattern}, principles=${categoryCounts.principle}, conventions=${categoryCounts.convention}, pitfalls=${categoryCounts.pitfall}, context=${categoryCounts.context}`
|
||||
: "No insights extracted yet",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 6: Recent extraction activity ───────────────────────────
|
||||
if (lastExtraction) {
|
||||
const extractionAge = Date.now() - new Date(lastExtraction.runAt).getTime();
|
||||
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
checks.push({
|
||||
id: "recent-extraction",
|
||||
name: "Recent extraction activity",
|
||||
passed: lastExtraction.success && extractionAge < oneWeekMs,
|
||||
details: lastExtraction.success
|
||||
? `Last successful extraction ${formatTimeAgo(lastExtraction.runAt)} (${lastExtraction.insightCount} insights, ${lastExtraction.duplicateCount} duplicates skipped)`
|
||||
: `Last extraction failed: ${lastExtraction.error || "Unknown error"}`,
|
||||
});
|
||||
|
||||
// Check 7: Extraction summary quality
|
||||
checks.push({
|
||||
id: "extraction-summary",
|
||||
name: "Extraction produces meaningful summaries",
|
||||
passed: lastExtraction.success && lastExtraction.summary.length > 10,
|
||||
details: lastExtraction.success
|
||||
? `Summary: "${lastExtraction.summary.slice(0, 100)}${lastExtraction.summary.length > 100 ? "..." : ""}"`
|
||||
: "No meaningful summary available",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "recent-extraction",
|
||||
name: "Recent extraction activity",
|
||||
passed: false,
|
||||
details: "No extraction runs recorded",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Calculate overall health ──────────────────────────────────────
|
||||
const failedChecks = checks.filter((c) => !c.passed);
|
||||
let health: "healthy" | "warning" | "issues" = "healthy";
|
||||
|
||||
if (failedChecks.length >= 3) {
|
||||
health = "issues";
|
||||
} else if (failedChecks.length >= 1) {
|
||||
// Critical checks that affect health
|
||||
const criticalFailed = failedChecks.filter((c) =>
|
||||
["working-memory-exists", "insights-memory-exists", "recent-extraction"].includes(c.id),
|
||||
);
|
||||
health = criticalFailed.length > 0 ? "issues" : "warning";
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: now,
|
||||
workingMemory: {
|
||||
exists: workingMemoryExists,
|
||||
size: workingMemorySize,
|
||||
sectionCount: workingMemorySectionCount,
|
||||
},
|
||||
insightsMemory: {
|
||||
exists: insightsMemoryExists,
|
||||
size: insightsMemorySize,
|
||||
insightCount: Object.values(categoryCounts).reduce((a, b) => a + b, 0),
|
||||
categories: categoryCounts,
|
||||
lastUpdated,
|
||||
},
|
||||
extraction: lastExtraction
|
||||
? {
|
||||
runAt: lastExtraction.runAt,
|
||||
success: lastExtraction.success,
|
||||
insightCount: lastExtraction.insightCount,
|
||||
duplicateCount: lastExtraction.duplicateCount,
|
||||
skippedCount: lastExtraction.skippedCount,
|
||||
summary: lastExtraction.summary,
|
||||
error: lastExtraction.error,
|
||||
}
|
||||
: {
|
||||
runAt: "",
|
||||
success: false,
|
||||
insightCount: 0,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: "No extraction runs recorded",
|
||||
},
|
||||
checks,
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Count markdown sections (## headers) in content.
|
||||
*
|
||||
* @param content - The markdown content to analyze.
|
||||
* @returns The number of level-2 section headers.
|
||||
*/
|
||||
function countMarkdownSections(content: string): number {
|
||||
const matches = content.match(/^##\s+.+$/gm);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count insight bullet points in a specific section.
|
||||
*
|
||||
* @param content - The markdown content.
|
||||
* @param sectionHeader - The section header to look for (e.g., "## Patterns").
|
||||
* @returns The number of insights (bullet points) in that section.
|
||||
*/
|
||||
function countInsightsInSection(content: string, sectionHeader: string): number {
|
||||
const headerIndex = content.indexOf(sectionHeader);
|
||||
if (headerIndex === -1) return 0;
|
||||
|
||||
// Find the next section header or end of file
|
||||
const afterHeader = headerIndex + sectionHeader.length;
|
||||
const nextSection = content.indexOf("\n## ", afterHeader);
|
||||
const sectionContent =
|
||||
nextSection === -1
|
||||
? content.slice(afterHeader)
|
||||
: content.slice(afterHeader, nextSection);
|
||||
|
||||
// Count lines starting with "- "
|
||||
const lines = sectionContent.split("\n");
|
||||
return lines.filter((line) => line.trim().startsWith("- ")).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as a human-readable "time ago" string.
|
||||
*
|
||||
* @param isoTimestamp - ISO-8601 timestamp.
|
||||
* @returns Human-readable time ago string.
|
||||
*/
|
||||
function formatTimeAgo(isoTimestamp: string): string {
|
||||
const date = new Date(isoTimestamp);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a memory audit report as markdown.
|
||||
*
|
||||
* @param report - The audit report to render.
|
||||
* @returns Markdown-formatted audit report.
|
||||
*/
|
||||
export function renderMemoryAuditMarkdown(report: MemoryAuditReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# Memory Audit Report`);
|
||||
lines.push(``);
|
||||
lines.push(`**Generated:** ${report.generatedAt}`);
|
||||
lines.push(`**Health:** ${getHealthEmoji(report.health)} ${report.health}`);
|
||||
lines.push(``);
|
||||
|
||||
// Working Memory Section
|
||||
lines.push(`## Working Memory`);
|
||||
lines.push(`- **Exists:** ${report.workingMemory.exists ? "✓" : "✗"}`);
|
||||
lines.push(`- **Size:** ${report.workingMemory.size.toLocaleString()} characters`);
|
||||
lines.push(`- **Sections:** ${report.workingMemory.sectionCount}`);
|
||||
lines.push(``);
|
||||
|
||||
// Insights Memory Section
|
||||
lines.push(`## Insights Memory`);
|
||||
lines.push(`- **Exists:** ${report.insightsMemory.exists ? "✓" : "✗"}`);
|
||||
lines.push(`- **Size:** ${report.insightsMemory.size.toLocaleString()} characters`);
|
||||
lines.push(`- **Total Insights:** ${report.insightsMemory.insightCount}`);
|
||||
lines.push(`- **By Category:**`);
|
||||
for (const [category, count] of Object.entries(report.insightsMemory.categories)) {
|
||||
lines.push(` - ${category}: ${count}`);
|
||||
}
|
||||
if (report.insightsMemory.lastUpdated) {
|
||||
lines.push(`- **Last Updated:** ${report.insightsMemory.lastUpdated}`);
|
||||
}
|
||||
lines.push(``);
|
||||
|
||||
// Extraction Section
|
||||
lines.push(`## Last Extraction`);
|
||||
if (report.extraction.runAt) {
|
||||
lines.push(`- **Run At:** ${report.extraction.runAt}`);
|
||||
lines.push(`- **Success:** ${report.extraction.success ? "✓" : "✗"}`);
|
||||
lines.push(`- **Insights Extracted:** ${report.extraction.insightCount}`);
|
||||
lines.push(`- **Duplicates Skipped:** ${report.extraction.duplicateCount}`);
|
||||
lines.push(`- **Skipped:** ${report.extraction.skippedCount}`);
|
||||
lines.push(`- **Summary:** ${report.extraction.summary}`);
|
||||
if (report.extraction.error) {
|
||||
lines.push(`- **Error:** ${report.extraction.error}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`- No extraction runs recorded`);
|
||||
}
|
||||
lines.push(``);
|
||||
|
||||
// Checks Section
|
||||
lines.push(`## Audit Checks`);
|
||||
for (const check of report.checks) {
|
||||
const icon = check.passed ? "✓" : "✗";
|
||||
lines.push(`### ${icon} ${check.name}`);
|
||||
lines.push(`**${check.details}**`);
|
||||
lines.push(``);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the emoji for a health status.
|
||||
*
|
||||
* @param health - The health status.
|
||||
* @returns The corresponding emoji.
|
||||
*/
|
||||
function getHealthEmoji(health: "healthy" | "warning" | "issues"): string {
|
||||
switch (health) {
|
||||
case "healthy":
|
||||
return "✅";
|
||||
case "warning":
|
||||
return "⚠️";
|
||||
case "issues":
|
||||
return "❌";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an insight extraction run and generate an audit report.
|
||||
*
|
||||
* This is a convenience function that combines:
|
||||
* 1. Processing the insight extraction run (parsing, merging, writing)
|
||||
* 2. Generating an audit report
|
||||
* 3. Writing the audit report to disk
|
||||
*
|
||||
* All operations are performed as best-effort side effects. Failures are
|
||||
* logged but do not cause the function to throw. The audit report always
|
||||
* reflects the current state of the memory files.
|
||||
*
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param input - The run processing input containing raw response and metadata.
|
||||
* @returns The audit report generated after processing (never throws).
|
||||
*/
|
||||
export async function processAndAuditInsightExtraction(
|
||||
rootDir: string,
|
||||
input: ProcessRunInput,
|
||||
): Promise<MemoryAuditReport> {
|
||||
// Track extraction info for the audit
|
||||
let extractionInfo: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
// Process the run
|
||||
const result = await processInsightExtractionRun(rootDir, input);
|
||||
|
||||
extractionInfo = {
|
||||
runAt: input.runAt,
|
||||
success: input.stepSuccess,
|
||||
insightCount: result.newInsightCount,
|
||||
duplicateCount: result.duplicateCount,
|
||||
skippedCount: result.insights.length - result.newInsightCount,
|
||||
summary: result.summary,
|
||||
error: input.error,
|
||||
};
|
||||
} catch (err) {
|
||||
// On processing failure, generate audit with error info
|
||||
extractionInfo = {
|
||||
runAt: input.runAt,
|
||||
success: false,
|
||||
insightCount: 0,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: `Processing failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate the audit report
|
||||
const auditReport = await generateMemoryAudit(rootDir, extractionInfo);
|
||||
|
||||
// Write the audit report (best-effort)
|
||||
try {
|
||||
const auditMarkdown = renderMemoryAuditMarkdown(auditReport);
|
||||
await writeMemoryAudit(rootDir, auditMarkdown);
|
||||
} catch (err) {
|
||||
// Best-effort: log but don't throw
|
||||
console.error(
|
||||
`[memory-audit] Failed to write audit report: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return auditReport;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,120 @@ describe("CronRunner", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("err");
|
||||
});
|
||||
|
||||
it("calls recordRun before callback on success", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "echo success" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
const callOrder: string[] = [];
|
||||
const onProcessed = vi.fn().mockImplementation(() => {
|
||||
callOrder.push("callback");
|
||||
});
|
||||
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(callOrder).toContain("callback");
|
||||
// recordRun should have been called first
|
||||
expect(automationStore.recordRun).toHaveBeenCalledBefore(onProcessed);
|
||||
});
|
||||
|
||||
it("invokes callback on successful execution", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "echo success" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
const onProcessed = vi.fn();
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(onProcessed).toHaveBeenCalledTimes(1);
|
||||
expect(onProcessed).toHaveBeenCalledWith(
|
||||
schedule,
|
||||
expect.objectContaining({ success: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes callback on failed execution", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "exit 1" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
const onProcessed = vi.fn();
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(onProcessed).toHaveBeenCalledTimes(1);
|
||||
expect(onProcessed).toHaveBeenCalledWith(
|
||||
schedule,
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invoke callback when not provided", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "echo test" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
// Should not throw
|
||||
await runner.executeSchedule(schedule);
|
||||
});
|
||||
|
||||
it("callback errors do not throw or alter returned result", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "echo success" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
|
||||
// Should not throw
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("success");
|
||||
});
|
||||
|
||||
it("callback errors do not prevent recordRun", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({ command: "echo test" });
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes schedule and result to callback", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({
|
||||
id: "custom-id",
|
||||
name: "Custom Schedule",
|
||||
command: "echo custom",
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
|
||||
let receivedSchedule: ScheduledTask | undefined;
|
||||
let receivedResult: AutomationRunResult | undefined;
|
||||
const onProcessed = vi.fn().mockImplementation((s: ScheduledTask, r: AutomationRunResult) => {
|
||||
receivedSchedule = s;
|
||||
receivedResult = r;
|
||||
});
|
||||
|
||||
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(receivedSchedule).toEqual(schedule);
|
||||
expect(receivedResult).toBeDefined();
|
||||
expect(receivedResult!.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent schedule prevention in tick", () => {
|
||||
|
||||
@@ -34,6 +34,26 @@ export interface CronRunnerOptions {
|
||||
pollIntervalMs?: number;
|
||||
/** Optional AI prompt executor. When not provided, ai-prompt steps return a configuration error. */
|
||||
aiPromptExecutor?: AiPromptExecutor;
|
||||
/**
|
||||
* Optional post-run callback invoked after schedule execution and run recording.
|
||||
*
|
||||
* Called as a best-effort side effect — callback errors are logged but do not
|
||||
* alter the returned `AutomationRunResult` or flip successful runs to failed.
|
||||
* This keeps post-processing isolated from the core execution contract.
|
||||
*
|
||||
* The callback receives:
|
||||
* - `schedule`: The schedule that was executed
|
||||
* - `result`: The `AutomationRunResult` from execution (success/failure)
|
||||
*
|
||||
* Use this for schedule-specific processing such as:
|
||||
* - Persisting memory insight extraction results
|
||||
* - Updating external systems based on automation output
|
||||
* - Triggering downstream side effects
|
||||
*/
|
||||
onScheduleRunProcessed?: (
|
||||
schedule: ScheduledTask,
|
||||
result: AutomationRunResult,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +70,7 @@ export class CronRunner {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollIntervalMs: number;
|
||||
private aiPromptExecutor?: AiPromptExecutor;
|
||||
private onScheduleRunProcessed?: CronRunnerOptions["onScheduleRunProcessed"];
|
||||
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
|
||||
private inFlight = new Set<string>();
|
||||
|
||||
@@ -63,6 +84,7 @@ export class CronRunner {
|
||||
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
);
|
||||
this.aiPromptExecutor = options.aiPromptExecutor;
|
||||
this.onScheduleRunProcessed = options.onScheduleRunProcessed;
|
||||
}
|
||||
|
||||
/** Start the polling loop. */
|
||||
@@ -163,6 +185,18 @@ export class CronRunner {
|
||||
log.error(`Failed to record run for ${schedule.id}: ${(recordErr as Error).message}`);
|
||||
}
|
||||
|
||||
// Invoke post-run callback (best-effort side effect)
|
||||
// Errors here do not alter the returned result or flip success/failure
|
||||
if (this.onScheduleRunProcessed) {
|
||||
try {
|
||||
await this.onScheduleRunProcessed(schedule, result);
|
||||
} catch (callbackErr) {
|
||||
log.error(
|
||||
`Post-run callback failed for ${schedule.name} (${schedule.id}): ${(callbackErr as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user