Files
fusion/packages/core/src/chat-store.ts
gsxdsm a9d5b06013 FN-8562: add tags to chat conversations
Enable project-scoped reusable tags for Direct chat sessions.

- Add tag storage, migrations, and scoped assignment APIs.
- Add chat sidebar tag creation, assignment, and filtering controls.
- Document tag behavior and cover schema and chat interactions.
- Establish the session project/id candidate key before creating scoped tag foreign keys.

Files changed:
 .changeset/fn-8562-chat-conversation-tags.md       |   7 ++
 docs/dashboard-guide.md                            |   4 +
 docs/storage.md                                    |   2 +
 .../src/__tests__/postgres/schema-applier.test.ts  |   8 +-
 packages/core/src/async-chat-store.ts              | 117 ++++++++++++++++++++-
 packages/core/src/chat-store.ts                    |  32 ++++++
 packages/core/src/chat-types.ts                    |  14 +++
 packages/core/src/index.gate.ts                    |   3 +
 packages/core/src/index.ts                         |   3 +
 .../postgres/migrations/0036_chat_session_tags.sql |  64 +++++++++++
 packages/core/src/postgres/schema-applier.ts       |  16 ++-
 packages/core/src/postgres/schema/project.ts       |  34 ++++++
 packages/dashboard/app/api.ts                      |   1 +
 packages/dashboard/app/api/chat.ts                 |  18 ++++
 packages/dashboard/app/components/ChatView.css     |  17 +++
 packages/dashboard/app/components/ChatView.tsx     |  70 +++++++++++-
 .../__tests__/ChatView.core-interactions.test.tsx  |  27 +++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |   1 +
 packages/dashboard/app/hooks/useChat.ts            |  52 ++++++++-
 .../dashboard/src/routes/register-chat-routes.ts   |  70 ++++++++++++
 20 files changed, 550 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8562

Fusion-Task-Lineage: 4efe798c-0865-451a-adbd-6b85f9f8b149

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-24 18:09:32 -07:00

742 lines
31 KiB
TypeScript

/**
* ChatStore - Data layer for the agent chat system.
*
* Manages CRUD operations for chat sessions and messages.
* Provides event emission for dashboard reactivity.
*
* Uses PostgreSQL through the project AsyncDataLayer and emits change events
* for dashboard reactivity.
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js";
import * as asyncChatStore from "./async-chat-store.js";
import type {
ChatSession,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
ChatSessionStatus,
ChatMessage,
ChatAttachment,
ChatMessageCreateInput,
ChatSessionCreateInput,
ChatSessionUpdateInput,
ChatMessagesFilter,
ChatRoom,
ChatInFlightGenerationState,
ChatRoomCreateInput,
ChatRoomMember,
ChatRoomMessage,
ChatRoomMessageCreateInput,
ChatRoomMessagesFilter,
ChatRoomStatus,
ChatRoomUpdateInput,
RoomMemberRole,
ChatTokenUsageCreateInput,
ChatTokenUsageRecord,
ChatTokenUsageSourceKind,
} from "./chat-types.js";
// ── Event Types ─────────────────────────────────────────────────────
export interface ChatStoreEvents {
/** Emitted when a chat session is created */
"chat:session:created": [session: ChatSession];
/** Emitted when a chat session is updated */
"chat:session:updated": [session: ChatSession];
/** Emitted when a chat session is deleted */
"chat:session:deleted": [sessionId: string];
/** Emitted when a message is added to a session */
"chat:message:added": [message: ChatMessage];
/** Emitted when a message is deleted from a session */
"chat:message:deleted": [messageId: string];
/** Emitted when a message is updated (e.g., attachment appended) */
"chat:message:updated": [message: ChatMessage];
/** Emitted when a room is created */
"chat:room:created": [room: ChatRoom];
/** Emitted when a room is updated */
"chat:room:updated": [room: ChatRoom];
/** Emitted when a room is deleted */
"chat:room:deleted": [roomId: string];
/** Emitted when a room member is added */
"chat:room:member:added": [member: ChatRoomMember];
/** Emitted when a room member is removed */
"chat:room:member:removed": [payload: { roomId: string; agentId: string }];
/** Emitted when a room message is added */
"chat:room:message:added": [message: ChatRoomMessage];
/** Emitted when a room message is updated */
"chat:room:message:updated": [message: ChatRoomMessage];
/** Emitted when a room message is deleted */
"chat:room:message:deleted": [messageId: string];
/** Emitted when all room messages are cleared */
"chat:room:messages:cleared": [payload: { roomId: string; deletedCount: number }];
}
// ── ChatStore Class ─────────────────────────────────────────────────
export class ChatStore extends EventEmitter<ChatStoreEvents> {
/**
* FNXC:PostgresChatStore 2026-07-14-19:15:
* Chat persistence is PostgreSQL-only after the storage cutover. Requiring
* AsyncDataLayer at construction prevents a reachable SQLite fallback.
*/
constructor(private readonly asyncLayer: AsyncDataLayer) {
super();
this.setMaxListeners(100);
}
private normalizeRoomName(name: string): string {
return name.trim().replace(/^#+/, "").trim();
}
private buildRoomSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// ── Session CRUD Operations ───────────────────────────────────────
/**
* Create a new chat session.
*
* @param input - Session creation input
* @returns The created session
*/
async createSession(input: ChatSessionCreateInput): Promise<ChatSession> {
const now = new Date().toISOString();
const session: ChatSession = {
id: `chat-${randomUUID().slice(0, 8)}`,
agentId: input.agentId,
tags: [],
title: input.title ?? null,
status: "active",
projectId: input.projectId ?? null,
modelProvider: input.modelProvider ?? null,
modelId: input.modelId ?? null,
thinkingLevel: input.thinkingLevel ?? null,
createdAt: now,
updatedAt: now,
pinnedAt: null,
cliSessionFile: null,
inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
};
const created = await asyncChatStore.createChatSession(this.asyncLayer.db, session);
this.emit("chat:session:created", created);
return created;
}
/**
* Get a chat session by ID.
*
* @param id - Session ID
* @returns The session, or undefined if not found
*/
async getSession(id: string): Promise<ChatSession | undefined> {
return asyncChatStore.getChatSession(this.asyncLayer.db, id);
}
/**
* List chat sessions with optional filtering.
*
* @param options - Optional filter options
* @returns Array of sessions ordered by updatedAt DESC
*/
async listSessions(options?: {
projectId?: string;
agentId?: string;
status?: ChatSessionStatus;
}): Promise<ChatSession[]> {
return asyncChatStore.listChatSessions(this.asyncLayer.db, options);
}
/**
* Find the newest active session for a specific quick-chat target.
*
* Matching semantics:
* - model target (`modelProvider` + `modelId`): exact agent+model match
* - agent target (no model): prefer model-less sessions, then newest agent session fallback
*/
async findLatestActiveSessionForTarget(options: {
agentId: string;
projectId?: string;
modelProvider?: string;
modelId?: string;
}): Promise<ChatSession | undefined> {
return asyncChatStore.findLatestActiveChatSessionForTarget(this.asyncLayer.db, options);
}
/**
* Update a chat session.
*
* @param id - Session ID
* @param input - Partial session updates
* @returns The updated session, or undefined if not found
*/
async updateSession(id: string, input: ChatSessionUpdateInput): Promise<ChatSession | undefined> {
const update = {
...input,
...(input.status === "archived" ? { pinnedAt: null } : {}),
};
/*
FNXC:ChatPinned 2026-07-16-12:30:
Archive and pin operations lock the target row in their transactions.
This prevents an archive from clearing a pin while a prior pin read later
writes it back, preserving both archived-session pin invariants.
*/
const updated = input.status === "archived"
? await this.asyncLayer.transactionImmediate(async (tx) => {
const session = await asyncChatStore.getChatSessionForUpdate(tx, id);
return session ? asyncChatStore.updateChatSession(tx, id, update) : undefined;
})
: await asyncChatStore.updateChatSession(this.asyncLayer.db, id, update);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
/**
* Archive a chat session.
* Convenience method that sets status to "archived".
*
* @param id - Session ID
* @returns The archived session, or undefined if not found
*/
async archiveSession(id: string): Promise<ChatSession | undefined> {
return this.updateSession(id, { status: "archived", pinnedAt: null });
}
/**
* Pin or unpin a Direct chat session.
*
* FNXC:ChatPinned 2026-07-16-12:00:
* PostgreSQL READ COMMITTED transactions do not serialize count-and-write
* operations. The non-null, namespaced advisory key serializes mutations for
* one scope; null ownerProjectId is counted with isNull and canonicalized as
* `default`, while a real project named default remains collision-safe.
*/
async setSessionPinned(id: string, pinned: boolean, _options?: { projectId?: string }): Promise<ChatSession | undefined> {
const updated = await this.asyncLayer.transactionImmediate(async (tx) => {
const session = await asyncChatStore.getChatSessionForUpdate(tx, id);
if (!session) return undefined;
if (!pinned) return asyncChatStore.updateChatSession(tx, id, { pinnedAt: null });
if (session.status === "archived") {
throw new Error("Archived conversations cannot be pinned");
}
const scopeKey = session.projectId ?? "default";
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`chat-pin:${scopeKey}`}, 0))`);
const scopePredicate = session.projectId === null
? isNull(schema.project.chatSessions.ownerProjectId)
: eq(schema.project.chatSessions.ownerProjectId, session.projectId);
const existingPins = await tx
.select({ id: schema.project.chatSessions.id })
.from(schema.project.chatSessions)
.where(and(
scopePredicate,
eq(schema.project.chatSessions.status, "active"),
isNotNull(schema.project.chatSessions.pinnedAt),
));
if (existingPins.length >= 3 && session.pinnedAt === null) {
throw new Error("You can pin up to 3 conversations per project");
}
return asyncChatStore.updateChatSession(tx, id, { pinnedAt: session.pinnedAt ?? new Date().toISOString() });
});
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
/**
* Persist the pi/Claude CLI session file path for a chat. Called once,
* after the SessionManager for the chat first creates its on-disk file,
* so subsequent turns can reopen it via SessionManager.open.
*
* Does not bump updatedAt or emit events — this is internal plumbing,
* not a user-visible state change.
*
* @param id - Session ID
* @param cliSessionFile - Absolute path to the session file, or null to clear
*/
async setCliSessionFile(id: string, cliSessionFile: string | null): Promise<void> {
await asyncChatStore.setCliSessionFile(this.asyncLayer.db, id, cliSessionFile);
return;
}
/**
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
* When set, the chat is CLI-backed: composer sends route through the inject
* path and adapter transcript events map to chat_messages rows. Emits a
* session update so the client can switch to the CLI-backed rendering path.
*
* @param id - Session ID
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
*/
async setCliExecutorAdapterId(id: string, adapterId: string | null): Promise<ChatSession | undefined> {
const updated = await asyncChatStore.setCliExecutorAdapterId(this.asyncLayer.db, id, adapterId);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
async setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): Promise<ChatSession | undefined> {
const updated = await asyncChatStore.setInFlightGeneration(this.asyncLayer.db, id, inFlightGeneration);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
/**
* Delete a chat session and all its messages.
* Messages are cascade-deleted via foreign key constraint.
*
* @param id - Session ID
* @returns true if deleted, false if not found
*/
async deleteSession(id: string): Promise<boolean> {
const deleted = await asyncChatStore.deleteChatSession(this.asyncLayer.db, id);
if (deleted) this.emit("chat:session:deleted", id);
return deleted;
}
async deleteSessionsForAgentId(agentId: string, options?: { projectId?: string | null }): Promise<number> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return 0;
const projectId = options?.projectId ?? undefined;
const sessions = await this.listSessions({
agentId: normalizedAgentId,
...(projectId ? { projectId } : {}),
});
let deletedCount = 0;
for (const session of sessions) {
if (await this.deleteSession(session.id)) {
deletedCount += 1;
}
}
return deletedCount;
}
/*
FNXC:ChatTags 2026-08-05-10:55:
Direct-conversation tags are project-scoped by the session's owner project.
Tag mutations are transactional and return an enriched session so SSE clients
never receive an assignment update without its deterministic tag array.
*/
async listTags(projectId: string | null = null): Promise<ChatTag[]> {
return asyncChatStore.listChatTags(this.asyncLayer.db, projectId);
}
async createTag(input: ChatTagCreateInput): Promise<ChatTag> {
return asyncChatStore.createChatTag(this.asyncLayer, input);
}
async renameTag(id: string, projectId: string | null, input: ChatTagUpdateInput): Promise<ChatTag | undefined> {
return asyncChatStore.renameChatTag(this.asyncLayer, id, projectId, input);
}
async deleteTag(id: string, projectId: string | null): Promise<boolean> {
return asyncChatStore.deleteChatTag(this.asyncLayer, id, projectId);
}
async replaceSessionTags(id: string, projectId: string | null, tagIds: string[]): Promise<ChatSession | undefined> {
const updated = await asyncChatStore.replaceChatSessionTags(this.asyncLayer, id, projectId, tagIds);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
// ── Message CRUD Operations ───────────────────────────────────────
/**
* Add a message to a chat session.
*
* @param sessionId - Parent session ID
* @param input - Message content and metadata
* @returns The created message
* @throws Error if session does not exist
*/
async addMessage(sessionId: string, input: ChatMessageCreateInput): Promise<ChatMessage> {
const session = await this.getSession(sessionId);
if (!session) {
throw new Error(`Chat session ${sessionId} not found`);
}
const now = new Date().toISOString();
const message: ChatMessage = {
id: `msg-${randomUUID().slice(0, 8)}`,
sessionId,
role: input.role,
content: input.content,
thinkingOutput: input.thinkingOutput ?? null,
metadata: input.metadata ?? null,
attachments: input.attachments,
createdAt: now,
};
const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message);
this.emit("chat:message:added", created);
return created;
}
/**
* Append a file attachment metadata record to an existing message.
*/
async addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): Promise<ChatMessage> {
const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment);
this.emit("chat:message:updated", updated);
return updated;
}
/**
* Get messages for a chat session with optional filtering.
*
* @param sessionId - Session ID
* @param filter - Optional filter (limit, offset, before cursor)
* @returns Array of messages ordered by createdAt ASC (default) or DESC
*/
async getMessages(sessionId: string, filter?: ChatMessagesFilter): Promise<ChatMessage[]> {
return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter);
}
/**
* Get a message by ID.
*
* @param id - Message ID
* @returns The message, or undefined if not found
*/
async getMessage(id: string): Promise<ChatMessage | undefined> {
return asyncChatStore.getChatMessage(this.asyncLayer.db, id);
}
/**
* Get the latest message for each session in the provided list.
* Uses a single SQL query with GROUP BY and MAX to efficiently fetch last messages.
*
* @param sessionIds - Array of session IDs to fetch last messages for
* @returns Map of sessionId -> latest ChatMessage for that session
*/
async getLastMessageForSessions(sessionIds: string[]): Promise<Map<string, ChatMessage>> {
return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds);
}
async hasMessages(sessionId: string): Promise<boolean> {
return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 })).length > 0;
}
/**
* Search sessions by message content (not just title/agentId).
*
* FNXC:ChatSearch 2026-07-07-00:00:
* Message content is not fully loaded client-side (only sessions + a last-message preview
* are), so "find a conversation by something that was said in it" requires a server round
* trip against chat_messages. There is no FTS table for chat_messages (see db.ts schema), so
* this uses a parameterized SQL `LIKE ... ESCAPE '\'` query — never string-concatenated —
* with `%`/`_`/`\` escaped in the search term so a literal `%` or `_` typed by the user is
* matched literally rather than acting as a wildcard (injection- and wildcard-safety).
*
* Scoped to the given session IDs (already filtered by projectId/status/agentId by the
* caller via listSessions) to keep the query bounded and avoid re-deriving scope filters
* against chat_sessions here. Deduplicates to one row per session using MAX(createdAt) so a
* session with multiple matching messages appears once, with a preview of its most recent
* matching message (truncated to ~100 chars, mirroring getLastMessageForSessions).
*
* @param query - Raw user search text (content match)
* @param sessionIds - Session IDs to search within (already scope-filtered by the caller)
* @returns Map of sessionId -> truncated preview of the most recent matching message
*/
async searchSessionsByMessageContent(query: string, sessionIds: string[]): Promise<Map<string, string>> {
const trimmed = query.trim();
if (!trimmed || !sessionIds || sessionIds.length === 0) {
return new Map();
}
return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds);
}
/**
* Delete a message by ID.
*
* @param id - Message ID
* @returns true if deleted, false if not found
*/
async deleteMessage(id: string): Promise<boolean> {
const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id);
if (!existing) return false;
const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id);
if (deleted) {
this.emit("chat:message:deleted", id);
const updatedSession = await this.getSession(existing.sessionId);
if (updatedSession) this.emit("chat:session:updated", updatedSession);
}
return deleted;
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Truncate a chat session from (and including) a target message onward. Editing an earlier
* user turn must "forget" that turn and every turn after it — both from the persisted
* transcript here AND from the model's resumable pi session context (rewound separately by
* ChatManager.rewindSessionForEdit) — so future responses are not biased by discarded turns.
*
* Ordering uses (createdAt ASC, id ASC), so same-millisecond messages have a
* deterministic PostgreSQL tiebreaker matching getLastMessageForSessions.
*
* @param sessionId - Parent session ID
* @param fromMessageId - Id of the earliest message to delete (inclusive)
* @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order)
*/
async deleteMessagesFrom(sessionId: string, fromMessageId: string): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> {
const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId);
if (result.deletedIds.length > 0) {
for (const id of result.deletedIds) {
this.emit("chat:message:deleted", id);
}
const updatedSession = await this.getSession(sessionId);
if (updatedSession) this.emit("chat:session:updated", updatedSession);
}
return result;
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Merge (default) or replace a persisted message's metadata. Used by the model-loop generation
* path to record the pi SessionManager parent-leaf id (`metadata.piParentLeafId`) onto the
* just-created user message, without disturbing other metadata (e.g. `mentions`). This linkage
* is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf().
*/
async updateMessageMetadata(messageId: string, metadata: Record<string, unknown> | null, options?: { merge?: boolean }): Promise<ChatMessage> {
const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options);
this.emit("chat:message:updated", updated);
return updated;
}
async createRoom(input: ChatRoomCreateInput & { memberAgentIds?: string[] }): Promise<ChatRoom> {
const normalizedName = this.normalizeRoomName(input.name);
if (!normalizedName) throw new Error("Room name cannot be empty");
const slug = this.buildRoomSlug(normalizedName);
if (!slug) throw new Error("Room name must include letters or numbers");
const now = new Date().toISOString();
const room: ChatRoom = {
id: `room-${randomUUID().slice(0, 8)}`,
name: normalizedName,
slug,
description: input.description ?? null,
projectId: input.projectId ?? null,
createdBy: input.createdBy ?? null,
status: "active",
thinkingLevel: input.thinkingLevel ?? null,
createdAt: now,
updatedAt: now,
};
const memberIds = [...new Set((input.memberAgentIds ?? []).map((id) => id.trim()).filter(Boolean))];
const result = await asyncChatStore.createChatRoom(this.asyncLayer, room, memberIds);
this.emit("chat:room:created", result.room);
for (const member of result.members) {
this.emit("chat:room:member:added", member);
}
return result.room;
}
async getRoom(id: string): Promise<ChatRoom | undefined> {
return asyncChatStore.getChatRoom(this.asyncLayer.db, id);
}
async getRoomBySlug(projectId: string | null, slug: string): Promise<ChatRoom | undefined> {
return asyncChatStore.getChatRoomBySlug(this.asyncLayer.db, projectId, slug);
}
async listRooms(options?: { projectId?: string; status?: ChatRoomStatus }): Promise<ChatRoom[]> {
return asyncChatStore.listChatRooms(this.asyncLayer.db, options);
}
async updateRoom(id: string, input: ChatRoomUpdateInput): Promise<ChatRoom | undefined> {
// Build slug/name from the input mirroring the sync path.
let updateInput: Parameters<typeof asyncChatStore.updateChatRoom>[2] = {};
if (input.name !== undefined) {
const normalizedName = this.normalizeRoomName(input.name);
if (!normalizedName) throw new Error("Room name cannot be empty");
const slug = this.buildRoomSlug(normalizedName);
if (!slug) throw new Error("Room name must include letters or numbers");
const existing = await this.getRoom(id);
if (existing) {
const slugConflict = await asyncChatStore.getChatRoomBySlug(this.asyncLayer.db, existing.projectId, slug);
if (slugConflict && slugConflict.id !== id) {
throw new Error(`Room slug ${slug} already exists in this project`);
}
}
updateInput = { name: normalizedName, slug };
}
if (input.description !== undefined) updateInput.description = input.description;
if (input.status !== undefined) updateInput.status = input.status;
const updated = await asyncChatStore.updateChatRoom(this.asyncLayer.db, id, updateInput);
if (updated) this.emit("chat:room:updated", updated);
return updated;
}
async deleteRoom(id: string): Promise<boolean> {
const deleted = await asyncChatStore.deleteChatRoom(this.asyncLayer.db, id);
if (deleted) this.emit("chat:room:deleted", id);
return deleted;
}
async cleanupOldChats(maxAgeMs: number): Promise<{ sessionsDeleted: number; roomsDeleted: number }> {
const result = await asyncChatStore.cleanupOldChats(this.asyncLayer.db, maxAgeMs);
for (const sessionId of result.deletedSessionIds) {
this.emit("chat:session:deleted", sessionId);
}
for (const roomId of result.deletedRoomIds) {
this.emit("chat:room:deleted", roomId);
}
return { sessionsDeleted: result.sessionsDeleted, roomsDeleted: result.roomsDeleted };
}
async addRoomMember(roomId: string, agentId: string, role: RoomMemberRole = "member"): Promise<ChatRoomMember> {
const now = new Date().toISOString();
await asyncChatStore.addChatRoomMember(this.asyncLayer.db, roomId, agentId, role, now);
const members = await this.listRoomMembers(roomId);
const member = members.find((m) => m.agentId === agentId);
if (!member) throw new Error(`Failed to load room member ${agentId}`);
this.emit("chat:room:member:added", member);
return member;
}
async removeRoomMember(roomId: string, agentId: string): Promise<boolean> {
const removed = await asyncChatStore.removeChatRoomMember(this.asyncLayer.db, roomId, agentId);
if (removed) this.emit("chat:room:member:removed", { roomId, agentId });
return removed;
}
async listRoomMembers(roomId: string): Promise<ChatRoomMember[]> {
return asyncChatStore.listChatRoomMembers(this.asyncLayer.db, roomId);
}
async listRoomsForAgent(agentId: string, options?: { projectId?: string; status?: ChatRoomStatus }): Promise<ChatRoom[]> {
return asyncChatStore.listChatRoomsForAgent(this.asyncLayer.db, agentId, options);
}
async addRoomMessage(roomId: string, input: ChatRoomMessageCreateInput): Promise<ChatRoomMessage> {
const room = await this.getRoom(roomId);
if (!room) {
throw new Error(`Chat room ${roomId} not found`);
}
const now = new Date().toISOString();
const message: ChatRoomMessage = {
id: `rmsg-${randomUUID().slice(0, 8)}`,
roomId,
role: input.role,
content: input.content,
thinkingOutput: input.thinkingOutput ?? null,
metadata: input.metadata ?? null,
attachments: input.attachments,
senderAgentId: input.senderAgentId ?? null,
mentions: input.mentions ?? [],
createdAt: now,
};
const created = await asyncChatStore.addChatRoomMessage(this.asyncLayer.db, message);
this.emit("chat:room:message:added", created);
return created;
}
async getRoomMessages(roomId: string, filter?: ChatRoomMessagesFilter): Promise<ChatRoomMessage[]> {
return asyncChatStore.getChatRoomMessages(this.asyncLayer.db, roomId, filter);
}
async listRoomMessagesSince(
roomId: string,
sinceIso: string,
options?: { excludeSenderAgentId?: string; limit?: number },
): Promise<ChatRoomMessage[]> {
return asyncChatStore.listChatRoomMessagesSince(this.asyncLayer.db, roomId, sinceIso, options);
}
async getRoomMessage(id: string): Promise<ChatRoomMessage | undefined> {
return asyncChatStore.getChatRoomMessage(this.asyncLayer.db, id);
}
async deleteRoomMessage(id: string): Promise<boolean> {
const existing = await asyncChatStore.getChatRoomMessage(this.asyncLayer.db, id);
if (!existing) return false;
const deleted = await asyncChatStore.deleteChatRoomMessage(this.asyncLayer.db, id);
if (deleted) {
this.emit("chat:room:message:deleted", id);
const updatedRoom = await this.getRoom(existing.roomId);
if (updatedRoom) this.emit("chat:room:updated", updatedRoom);
}
return deleted;
}
async clearRoomMessages(roomId: string): Promise<number> {
const deleted = await asyncChatStore.clearChatRoomMessages(this.asyncLayer.db, roomId);
if (deleted > 0) this.emit("chat:room:messages:cleared", { roomId, deletedCount: deleted });
return deleted;
}
async addRoomMessageAttachment(roomId: string, messageId: string, attachment: ChatAttachment): Promise<ChatRoomMessage> {
const updated = await asyncChatStore.addChatRoomMessageAttachment(this.asyncLayer.db, roomId, messageId, attachment);
this.emit("chat:room:message:updated", updated);
return updated;
}
async recordTokenUsage(input: ChatTokenUsageCreateInput): Promise<ChatTokenUsageRecord | undefined> {
const inputTokens = Math.max(0, Math.trunc(input.inputTokens));
const outputTokens = Math.max(0, Math.trunc(input.outputTokens));
const cachedTokens = Math.max(0, Math.trunc(input.cachedTokens));
const cacheWriteTokens = Math.max(0, Math.trunc(input.cacheWriteTokens));
const totalTokens = Math.max(0, Math.trunc(input.totalTokens ?? (inputTokens + outputTokens + cachedTokens + cacheWriteTokens)));
if (inputTokens === 0 && outputTokens === 0 && cachedTokens === 0 && cacheWriteTokens === 0 && totalTokens === 0) {
return undefined;
}
const record: ChatTokenUsageRecord = {
id: `chat-tokens-${randomUUID().slice(0, 12)}`,
sourceKind: input.sourceKind,
chatSessionId: input.chatSessionId ?? null,
roomId: input.roomId ?? null,
messageId: input.messageId ?? null,
projectId: input.projectId ?? null,
agentId: input.agentId ?? null,
modelProvider: input.modelProvider ?? null,
modelId: input.modelId ?? null,
inputTokens,
outputTokens,
cachedTokens,
cacheWriteTokens,
totalTokens,
createdAt: input.createdAt ?? new Date().toISOString(),
};
const layer = this.asyncLayer;
/* FNXC:PostgresChatUsage 2026-07-14-18:49: Token accounting is durable before a chat turn reports completion; callers await the insert so shutdown and immediate analytics cannot lose or race the record. */
/* FNXC:MultiProjectIsolation 2026-07-15-23:40: the record's domain projectId is written to owner_project_id; project_id (the RLS partition) is omitted so the fusion_assign_project_id trigger/GUC owns it (migration 0011). */
await layer.db.execute(sql`INSERT INTO project.chat_token_usage (
id, source_kind, chat_session_id, room_id, message_id, owner_project_id, agent_id,
model_provider, model_id, input_tokens, output_tokens, cached_tokens,
cache_write_tokens, total_tokens, created_at
) VALUES (
${record.id}, ${record.sourceKind}, ${record.chatSessionId}, ${record.roomId},
${record.messageId}, ${record.projectId}, ${record.agentId},
${record.modelProvider}, ${record.modelId}, ${record.inputTokens}, ${record.outputTokens},
${record.cachedTokens}, ${record.cacheWriteTokens}, ${record.totalTokens}, ${record.createdAt}
)`);
return record;
}
/** Authoritative PostgreSQL token-usage reader. */
async listTokenUsageAsync(): Promise<ChatTokenUsageRecord[]> {
/* FNXC:PostgresChatUsage 2026-07-14-18:40: Public chat accounting reads must return durable PostgreSQL records instead of the synchronous compatibility facade's empty value. */
const rows = await this.asyncLayer.db
.select()
.from(schema.project.chatTokenUsage)
.orderBy(asc(schema.project.chatTokenUsage.createdAt), asc(schema.project.chatTokenUsage.id));
return rows.map((row) => ({
id: row.id, sourceKind: row.sourceKind as ChatTokenUsageSourceKind,
chatSessionId: row.chatSessionId, roomId: row.roomId, messageId: row.messageId,
projectId: row.ownerProjectId, agentId: row.agentId, modelProvider: row.modelProvider,
modelId: row.modelId, inputTokens: row.inputTokens, outputTokens: row.outputTokens,
cachedTokens: row.cachedTokens, cacheWriteTokens: row.cacheWriteTokens,
totalTokens: row.totalTokens, createdAt: row.createdAt,
}));
}
}