refactor(FN-1633): migrate MessageStore from filesystem to SQLite backend
- Replace filesystem-based message storage with SQLite backend - Add MessageStore class using better-sqlite3 with WAL mode - Update message.ts CLI command to use new MessageStore API - Update dashboard routes and engine runtime for SQLite integration - Update all related tests for new storage implementation
This commit is contained in:
@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ── Mock MessageStore ────────────────────────────────────────────────
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetInbox = vi.fn();
|
||||
const mockGetOutbox = vi.fn();
|
||||
const mockGetMailbox = vi.fn();
|
||||
@@ -11,18 +10,24 @@ const mockSendMessage = vi.fn();
|
||||
const mockMarkAsRead = vi.fn();
|
||||
const mockDeleteMessage = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
MessageStore: vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
getInbox: mockGetInbox,
|
||||
getOutbox: mockGetOutbox,
|
||||
getMailbox: mockGetMailbox,
|
||||
getMessage: mockGetMessage,
|
||||
sendMessage: mockSendMessage,
|
||||
markAsRead: mockMarkAsRead,
|
||||
deleteMessage: mockDeleteMessage,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@fusion/core", () => {
|
||||
const mockDb = {
|
||||
init: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
return {
|
||||
createDatabase: vi.fn().mockReturnValue(mockDb),
|
||||
MessageStore: vi.fn().mockImplementation(() => ({
|
||||
getInbox: mockGetInbox,
|
||||
getOutbox: mockGetOutbox,
|
||||
getMailbox: mockGetMailbox,
|
||||
getMessage: mockGetMessage,
|
||||
sendMessage: mockSendMessage,
|
||||
markAsRead: mockMarkAsRead,
|
||||
deleteMessage: mockDeleteMessage,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock project-context ─────────────────────────────────────────────
|
||||
|
||||
@@ -36,7 +41,7 @@ vi.mock("../project-context.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Spies ────────────────────────────────────────────────────────────
|
||||
// ── Spies ───────────────────────────────────────────────────────────
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
@@ -78,12 +83,12 @@ const mockReadMessage = {
|
||||
content: "This is read",
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("runMessageInbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 2, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockResolvedValue([mockMessage, mockReadMessage]);
|
||||
mockGetMailbox.mockReturnValue({ unreadCount: 2, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockReturnValue([mockMessage, mockReadMessage]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -99,8 +104,8 @@ describe("runMessageInbox", () => {
|
||||
});
|
||||
|
||||
it("should show 'No messages' when inbox is empty", async () => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockResolvedValue([]);
|
||||
mockGetMailbox.mockReturnValue({ unreadCount: 0, ownerId: "cli", ownerType: "user" });
|
||||
mockGetInbox.mockReturnValue([]);
|
||||
|
||||
await runMessageInbox();
|
||||
|
||||
@@ -114,11 +119,11 @@ describe("runMessageInbox", () => {
|
||||
});
|
||||
|
||||
it("should truncate long messages", async () => {
|
||||
mockGetInbox.mockResolvedValue([{
|
||||
mockGetInbox.mockReturnValue([{
|
||||
...mockMessage,
|
||||
content: "A".repeat(200),
|
||||
}]);
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "cli", ownerType: "user" });
|
||||
mockGetMailbox.mockReturnValue({ unreadCount: 1, ownerId: "cli", ownerType: "user" });
|
||||
|
||||
await runMessageInbox();
|
||||
|
||||
@@ -129,7 +134,7 @@ describe("runMessageInbox", () => {
|
||||
|
||||
describe("runMessageOutbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetOutbox.mockResolvedValue([]);
|
||||
mockGetOutbox.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -145,7 +150,7 @@ describe("runMessageOutbox", () => {
|
||||
toType: "agent" as const,
|
||||
type: "user-to-agent" as const,
|
||||
};
|
||||
mockGetOutbox.mockResolvedValue([sentMessage]);
|
||||
mockGetOutbox.mockReturnValue([sentMessage]);
|
||||
|
||||
await runMessageOutbox();
|
||||
|
||||
@@ -155,7 +160,7 @@ describe("runMessageOutbox", () => {
|
||||
});
|
||||
|
||||
it("should show 'No sent messages' when outbox is empty", async () => {
|
||||
mockGetOutbox.mockResolvedValue([]);
|
||||
mockGetOutbox.mockReturnValue([]);
|
||||
|
||||
await runMessageOutbox();
|
||||
|
||||
@@ -165,7 +170,7 @@ describe("runMessageOutbox", () => {
|
||||
|
||||
describe("runMessageSend", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockResolvedValue(mockMessage);
|
||||
mockSendMessage.mockReturnValue(mockMessage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -197,8 +202,8 @@ describe("runMessageSend", () => {
|
||||
|
||||
describe("runMessageRead", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessage.mockResolvedValue(mockMessage);
|
||||
mockMarkAsRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
mockGetMessage.mockReturnValue(mockMessage);
|
||||
mockMarkAsRead.mockReturnValue({ ...mockMessage, read: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -222,7 +227,7 @@ describe("runMessageRead", () => {
|
||||
});
|
||||
|
||||
it("should not mark as read if already read", async () => {
|
||||
mockGetMessage.mockResolvedValue(mockReadMessage);
|
||||
mockGetMessage.mockReturnValue(mockReadMessage);
|
||||
|
||||
await runMessageRead("msg-002");
|
||||
|
||||
@@ -230,7 +235,7 @@ describe("runMessageRead", () => {
|
||||
});
|
||||
|
||||
it("should exit with error for missing message", async () => {
|
||||
mockGetMessage.mockResolvedValue(null);
|
||||
mockGetMessage.mockReturnValue(null);
|
||||
|
||||
await expect(runMessageRead("msg-nonexistent")).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found"));
|
||||
@@ -240,7 +245,7 @@ describe("runMessageRead", () => {
|
||||
|
||||
describe("runMessageDelete", () => {
|
||||
beforeEach(() => {
|
||||
mockDeleteMessage.mockResolvedValue(undefined);
|
||||
mockDeleteMessage.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -257,8 +262,8 @@ describe("runMessageDelete", () => {
|
||||
|
||||
describe("runAgentMailbox", () => {
|
||||
beforeEach(() => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 1, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockResolvedValue([mockMessage]);
|
||||
mockGetMailbox.mockReturnValue({ unreadCount: 1, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockReturnValue([mockMessage]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -275,8 +280,8 @@ describe("runAgentMailbox", () => {
|
||||
});
|
||||
|
||||
it("should show 'No messages' when agent mailbox is empty", async () => {
|
||||
mockGetMailbox.mockResolvedValue({ unreadCount: 0, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockResolvedValue([]);
|
||||
mockGetMailbox.mockReturnValue({ unreadCount: 0, ownerId: "agent-001", ownerType: "agent" });
|
||||
mockGetInbox.mockReturnValue([]);
|
||||
|
||||
await runAgentMailbox("agent-001");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MessageStore } from "@fusion/core";
|
||||
import type { ParticipantType } from "@fusion/core";
|
||||
import { MessageStore, createDatabase } from "@fusion/core";
|
||||
import type { Database, ParticipantType } from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
@@ -21,13 +21,16 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initialized MessageStore for the given project.
|
||||
* Create a MessageStore for the given project.
|
||||
* Returns both the store and database for proper cleanup.
|
||||
*/
|
||||
async function createMessageStore(projectName?: string): Promise<MessageStore> {
|
||||
async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const msgStore = new MessageStore({ rootDir: projectPath + "/.fusion" });
|
||||
await msgStore.init();
|
||||
return msgStore;
|
||||
const kbDir = projectPath + "/.fusion";
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
return { store, db };
|
||||
}
|
||||
|
||||
/** User ID for CLI-originated messages */
|
||||
@@ -37,28 +40,32 @@ const CLI_USER_ID = "cli";
|
||||
* List inbox messages.
|
||||
*/
|
||||
export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = await store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const mailbox = store.getMailbox(CLI_USER_ID, "user");
|
||||
const messages = store.getInbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = msg.fromType === "agent" ? `Agent ${msg.fromId}` : msg.fromId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log(` 📬 Inbox (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = msg.fromType === "agent" ? `Agent ${msg.fromId}` : msg.fromId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,26 +73,30 @@ export async function runMessageInbox(projectName?: string): Promise<void> {
|
||||
* List sent messages.
|
||||
*/
|
||||
export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const messages = await store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const messages = store.getOutbox(CLI_USER_ID, "user", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(" 📤 Outbox");
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No sent messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const toLabel = msg.toType === "agent" ? `Agent ${msg.toId}` : msg.toId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` To: ${toLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log(" 📤 Outbox");
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No sent messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const toLabel = msg.toType === "agent" ? `Agent ${msg.toId}` : msg.toId;
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` To: ${toLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,92 +104,108 @@ export async function runMessageOutbox(projectName?: string): Promise<void> {
|
||||
* Send a message to an agent.
|
||||
*/
|
||||
export async function runMessageSend(toId: string, content: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
toType: "agent",
|
||||
content,
|
||||
type: "user-to-agent",
|
||||
});
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const message = store.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId,
|
||||
toType: "agent",
|
||||
content,
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message sent: ${message.id}`);
|
||||
console.log(` To: Agent ${toId}`);
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(` ✓ Message sent: ${message.id}`);
|
||||
console.log(` To: Agent ${toId}`);
|
||||
console.log();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and display a specific message.
|
||||
*/
|
||||
export async function runMessageRead(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const message = await store.getMessage(id);
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const message = store.getMessage(id);
|
||||
|
||||
if (!message) {
|
||||
console.error(`Message ${id} not found`);
|
||||
process.exit(1);
|
||||
if (!message) {
|
||||
console.error(`Message ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (!message.read) {
|
||||
store.markAsRead(id);
|
||||
}
|
||||
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
const toLabel = formatParticipant(message.toId, message.toType);
|
||||
const timeStr = new Date(message.createdAt).toLocaleString();
|
||||
|
||||
console.log();
|
||||
console.log(` Message: ${message.id}`);
|
||||
console.log(` Type: ${message.type}`);
|
||||
console.log(` From: ${fromLabel}`);
|
||||
console.log(` To: ${toLabel}`);
|
||||
console.log(` Time: ${timeStr}`);
|
||||
console.log();
|
||||
console.log(` ${message.content}`);
|
||||
console.log();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (!message.read) {
|
||||
await store.markAsRead(id);
|
||||
}
|
||||
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
const toLabel = formatParticipant(message.toId, message.toType);
|
||||
const timeStr = new Date(message.createdAt).toLocaleString();
|
||||
|
||||
console.log();
|
||||
console.log(` Message: ${message.id}`);
|
||||
console.log(` Type: ${message.type}`);
|
||||
console.log(` From: ${fromLabel}`);
|
||||
console.log(` To: ${toLabel}`);
|
||||
console.log(` Time: ${timeStr}`);
|
||||
console.log();
|
||||
console.log(` ${message.content}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*/
|
||||
export async function runMessageDelete(id: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
await store.deleteMessage(id);
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
store.deleteMessage(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Message ${id} deleted`);
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(` ✓ Message ${id} deleted`);
|
||||
console.log();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View an agent's mailbox.
|
||||
*/
|
||||
export async function runAgentMailbox(agentId: string, projectName?: string): Promise<void> {
|
||||
const store = await createMessageStore(projectName);
|
||||
const mailbox = await store.getMailbox(agentId, "agent");
|
||||
const messages = await store.getInbox(agentId, "agent", { limit: 20 });
|
||||
const { store, db } = await createMessageStore(projectName);
|
||||
try {
|
||||
const mailbox = store.getMailbox(agentId, "agent");
|
||||
const messages = store.getInbox(agentId, "agent", { limit: 20 });
|
||||
|
||||
console.log();
|
||||
console.log(` 🤖 Agent Mailbox: ${agentId} (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = formatParticipant(msg.fromId, msg.fromType);
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}From: ${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log(` 🤖 Agent Mailbox: ${agentId} (${mailbox.unreadCount} unread)`);
|
||||
console.log();
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(" No messages");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const readMarker = msg.read ? " " : "● ";
|
||||
const fromLabel = formatParticipant(msg.fromId, msg.fromType);
|
||||
const timeStr = formatTime(msg.createdAt);
|
||||
const preview = msg.content.length > 80 ? msg.content.slice(0, 80) + "…" : msg.content;
|
||||
console.log(` ${readMarker}From: ${fromLabel} — ${timeStr}`);
|
||||
console.log(` ${preview}`);
|
||||
console.log();
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user