From 85cc30a8fe0f31952b76ed69d4e0579c31965eb4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:21 +0900 Subject: [PATCH] feat(team-mode): add team mailbox inbox with tests --- .../team-mode/team-mailbox/inbox.test.ts | 63 +++++++++++++++ src/features/team-mode/team-mailbox/inbox.ts | 76 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/features/team-mode/team-mailbox/inbox.test.ts create mode 100644 src/features/team-mode/team-mailbox/inbox.ts diff --git a/src/features/team-mode/team-mailbox/inbox.test.ts b/src/features/team-mode/team-mailbox/inbox.test.ts new file mode 100644 index 000000000..26b483ce7 --- /dev/null +++ b/src/features/team-mode/team-mailbox/inbox.test.ts @@ -0,0 +1,63 @@ +/// + +import { describe, expect, mock, test } from "bun:test" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { tmpdir } from "node:os" +import path from "node:path" + +const logCalls: Array<[string, unknown?]> = [] + +mock.module("../../../shared/logger", () => ({ + log: (message: string, data?: unknown) => { + logCalls.push([message, data]) + }, +})) + +const { listUnreadMessages } = await import("./inbox") +const { TeamModeConfigSchema } = await import("../../../config/schema/team-mode") +const { getInboxDir, resolveBaseDir } = await import("../team-registry/paths") + +async function createBaseDirectory(): Promise { + return await mkdtemp(path.join(tmpdir(), "team-mailbox-inbox-")) +} + +describe("listUnreadMessages", () => { + test("returns FIFO messages while skipping malformed, processed, and dot files", async () => { + // given + const config = TeamModeConfigSchema.parse({ base_dir: await createBaseDirectory() }) + const teamRunId = randomUUID() + const inboxDir = getInboxDir(resolveBaseDir(config), teamRunId, "m1") + await mkdir(path.join(inboxDir, "processed"), { recursive: true }) + + await writeFile(path.join(inboxDir, "later.json"), JSON.stringify({ + version: 1, + messageId: randomUUID(), + from: "m2", + to: "m1", + kind: "message", + body: "later", + timestamp: 200, + })) + await writeFile(path.join(inboxDir, "earlier.json"), JSON.stringify({ + version: 1, + messageId: randomUUID(), + from: "m3", + to: "m1", + kind: "message", + body: "earlier", + timestamp: 100, + })) + await writeFile(path.join(inboxDir, "bad.json"), "{not-json") + await writeFile(path.join(inboxDir, ".hidden.json"), "{}") + await writeFile(path.join(inboxDir, "processed", "done.json"), "{}") + + // when + const unreadMessages = await listUnreadMessages(teamRunId, "m1", config) + + // then + expect(unreadMessages.map((message) => message.body)).toEqual(["earlier", "later"]) + expect(logCalls).toHaveLength(1) + expect(logCalls[0]?.[0]).toContain("skipped unreadable message") + }) +}) diff --git a/src/features/team-mode/team-mailbox/inbox.ts b/src/features/team-mode/team-mailbox/inbox.ts new file mode 100644 index 000000000..5dacb48ea --- /dev/null +++ b/src/features/team-mode/team-mailbox/inbox.ts @@ -0,0 +1,76 @@ +import type { Dirent } from "node:fs" +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { log } from "../../../shared/logger" +import { getInboxDir, resolveBaseDir } from "../team-registry/paths" +import { MessageSchema } from "../types" +import type { Message } from "../types" + +function isInboxMessageFile(entry: Dirent): boolean { + return entry.isFile() && entry.name.endsWith(".json") && !entry.name.startsWith(".") +} + +function isMissingDirectoryError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === "ENOENT" +} + +async function readInboxMessage( + inboxDir: string, + fileName: string, + memberName: string, + teamRunId: string, +): Promise { + const filePath = path.join(inboxDir, fileName) + const messageContext = { memberName, teamRunId, fileName } + + try { + const fileContent = await readFile(filePath, "utf8") + const parsedMessage = MessageSchema.safeParse(JSON.parse(fileContent)) + if (!parsedMessage.success) { + log("team mailbox skipped malformed message", { + event: "team-mailbox-malformed-message", + ...messageContext, + issues: parsedMessage.error.issues, + }) + return null + } + + return parsedMessage.data + } catch (error) { + log("team mailbox skipped unreadable message", { + event: "team-mailbox-unreadable-message", + ...messageContext, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +export async function listUnreadMessages( + teamRunId: string, + memberName: string, + config: TeamModeConfig, +): Promise { + const inboxDir = getInboxDir(resolveBaseDir(config), teamRunId, memberName) + + try { + const directoryEntries = await readdir(inboxDir, { withFileTypes: true }) + const unreadMessages = await Promise.all( + directoryEntries + .filter(isInboxMessageFile) + .map((entry) => readInboxMessage(inboxDir, entry.name, memberName, teamRunId)), + ) + + return unreadMessages + .filter((message): message is Message => message !== null) + .sort((leftMessage, rightMessage) => leftMessage.timestamp - rightMessage.timestamp) + } catch (error) { + if (isMissingDirectoryError(error)) { + return [] + } + + throw error + } +}