feat(FN-989): add inter-agent messaging system with mailbox UI and CLI commands

- Add Message types (Message, MessageThread, MessageRecipient) and exports to @fusion/core
- Create MessageStore with full CRUD: send, read, delete, inbox, threads, and search
- Add messages table migration (schema v12) with SQLite full-text search support
- Add REST API routes for messaging (CRUD, search, broadcast, unread count)
- Add frontend API client functions for all messaging endpoints
- Build MailboxModal and MessageComposer dashboard components with header integration
- Add CLI message commands (inbox, send, read, delete) with rich output formatting
- Add comprehensive test coverage for MessageStore, CLI commands, and UI components
- Update documentation (CLI STANDALONE.md, dashboard README) with messaging usage
This commit is contained in:
gsxdsm
2026-04-07 11:21:59 -07:00
parent 7af1317f34
commit 43b1f6e772
20 changed files with 3972 additions and 17 deletions

View File

@@ -2,11 +2,11 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { resolve, sep } from "node:path";
import { resolve, sep, join } from "node:path";
import * as nodeFs from "node:fs";
import * as nodeChildProcess from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData } from "@fusion/core";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -8356,6 +8356,234 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
// ── Messaging Routes ──────────────────────────────────────────────────
/** Cache of MessageStore instances keyed by rootDir */
const messageStoreCache = new Map<string, MessageStore>();
async function getMessageStore(req: Request): Promise<MessageStore> {
const scopedStore = await getScopedStore(req);
const rootDir = scopedStore.getRootDir();
let msgStore = messageStoreCache.get(rootDir);
if (!msgStore) {
msgStore = new MessageStore({ rootDir: join(rootDir, ".fusion") });
await msgStore.init();
messageStoreCache.set(rootDir, msgStore);
}
return msgStore;
}
const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"];
const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"];
const DASHBOARD_USER_ID = "dashboard";
/**
* GET /api/messages/inbox
* Fetch inbox messages for the dashboard user.
* Query params: limit, offset, unreadOnly, type
*/
router.get("/messages/inbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const filter = {
limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0,
read: req.query.unreadOnly === "true" ? false : undefined,
type: req.query.type as MessageType | undefined,
};
const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter);
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
res.json({ messages, total: messages.length, unreadCount: mailbox.unreadCount });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/messages/outbox
* Fetch sent messages for the dashboard user.
* Query params: limit, offset, type
*/
router.get("/messages/outbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const filter = {
limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0,
type: req.query.type as MessageType | undefined,
};
const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter);
res.json({ messages, total: messages.length });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/messages/unread-count
* Get unread message count (lightweight for header badge).
*/
router.get("/messages/unread-count", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
res.json({ unreadCount: mailbox.unreadCount });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/messages/read-all
* Mark all inbox messages as read.
* IMPORTANT: Must be registered before /messages/:id to avoid path conflicts.
*/
router.post("/messages/read-all", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const count = await msgStore.markAllAsRead(DASHBOARD_USER_ID, "user");
res.json({ markedAsRead: count });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/messages
* Send a new message.
* Body: { toId, toType, content, type, metadata? }
*/
router.post("/messages", async (req, res) => {
try {
const { toId, toType, content, type, metadata } = req.body;
// Validate required fields
if (!toId || typeof toId !== "string") {
res.status(400).json({ error: "toId is required" });
return;
}
if (!toType || !VALID_PARTICIPANT_TYPES.includes(toType)) {
res.status(400).json({ error: `toType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}` });
return;
}
if (!content || typeof content !== "string" || content.length === 0 || content.length > 2000) {
res.status(400).json({ error: "content is required and must be 1-2000 characters" });
return;
}
if (!type || !VALID_MESSAGE_TYPES.includes(type)) {
res.status(400).json({ error: `type must be one of: ${VALID_MESSAGE_TYPES.join(", ")}` });
return;
}
const msgStore = await getMessageStore(req);
const message = await msgStore.sendMessage({
fromId: DASHBOARD_USER_ID,
fromType: "user",
toId,
toType,
content,
type,
metadata,
});
res.status(201).json(message);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/messages/conversation/:participantType/:participantId
* Get conversation between dashboard user and a specific participant.
*/
router.get("/messages/conversation/:participantType/:participantId", async (req, res) => {
try {
const { participantType, participantId } = req.params;
if (!VALID_PARTICIPANT_TYPES.includes(participantType as ParticipantType)) {
res.status(400).json({ error: `participantType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}` });
return;
}
const msgStore = await getMessageStore(req);
const messages = await msgStore.getConversation(
{ id: DASHBOARD_USER_ID, type: "user" },
{ id: participantId, type: participantType as ParticipantType },
);
res.json(messages);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/messages/:id
* Fetch a single message.
*/
router.get("/messages/:id", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const message = await msgStore.getMessage(req.params.id);
if (!message) {
res.status(404).json({ error: "Message not found" });
return;
}
res.json(message);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/messages/:id/read
* Mark a specific message as read.
*/
router.post("/messages/:id/read", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const message = await msgStore.markAsRead(req.params.id);
res.json(message);
} catch (err: any) {
if (err.message.includes("not found")) {
res.status(404).json({ error: err.message });
return;
}
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/messages/:id
* Delete a message.
*/
router.delete("/messages/:id", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
await msgStore.deleteMessage(req.params.id);
res.status(204).send();
} catch (err: any) {
if (err.message.includes("not found")) {
res.status(404).json({ error: err.message });
return;
}
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/agents/:id/mailbox
* View an agent's mailbox (admin read-only access).
*/
router.get("/agents/:id/mailbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const agentId = req.params.id;
const mailbox = await msgStore.getMailbox(agentId, "agent");
const inbox = await msgStore.getInbox(agentId, "agent");
res.json({ ...mailbox, messages: inbox });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router;
}