From b2359c0a63ba574c19a162932a0744d699e18b0c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:47 +0900 Subject: [PATCH] feat(team-mode): add team registry path utilities with tests --- .../team-mode/team-registry/paths.test.ts | 119 +++++++++++++++++ src/features/team-mode/team-registry/paths.ts | 122 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/features/team-mode/team-registry/paths.test.ts create mode 100644 src/features/team-mode/team-registry/paths.ts diff --git a/src/features/team-mode/team-registry/paths.test.ts b/src/features/team-mode/team-registry/paths.test.ts new file mode 100644 index 000000000..c2262eed1 --- /dev/null +++ b/src/features/team-mode/team-registry/paths.test.ts @@ -0,0 +1,119 @@ +/// + +import { afterEach, describe, expect, mock, test } from "bun:test" +import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" +import path from "node:path" +import { randomUUID } from "node:crypto" + +import { TeamModeConfigSchema } from "../../../config/schema/team-mode" + +const logCalls: Array<[string, unknown?]> = [] + +mock.module("../../../shared/logger", () => ({ + log: (message: string, data?: unknown) => { + logCalls.push([message, data]) + }, +})) + +const { discoverTeamSpecs, ensureBaseDirs, resolveBaseDir } = await import("./paths") + +async function createTemporaryRoot(): Promise { + return await mkdtemp(path.join(tmpdir(), "team-mode-paths-")) +} + +describe("paths", () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + logCalls.splice(0) + await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => { + await rm(directoryPath, { recursive: true, force: true }) + })) + }) + + test("resolveBaseDir defaults to ~/.omo", () => { + // given + const config = TeamModeConfigSchema.parse({ base_dir: undefined }) + + // when + const resolvedBaseDir = resolveBaseDir(config) + + // then + expect(resolvedBaseDir).toBe(path.join(homedir(), ".omo")) + }) + + test("resolveBaseDir honors override", () => { + // given + const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/test-abc" }) + + // when + const resolvedBaseDir = resolveBaseDir(config) + + // then + expect(resolvedBaseDir).toBe("/tmp/test-abc") + }) + + test("discoverTeamSpecs prefers project scope", async () => { + // given + const rootDirectory = await createTemporaryRoot() + temporaryDirectories.push(rootDirectory) + + const projectRoot = path.join(rootDirectory, "project") + const userBaseDir = path.join(rootDirectory, "home", ".omo") + const projectTeamDir = path.join(projectRoot, ".omo", "teams", "foo") + const userTeamDir = path.join(userBaseDir, "teams", "foo") + + await mkdir(projectTeamDir, { recursive: true }) + await mkdir(userTeamDir, { recursive: true }) + + await writeFile(path.join(projectTeamDir, "config.json"), "{}") + await writeFile(path.join(userTeamDir, "config.json"), "{}") + + // when + const teamSpecs = await discoverTeamSpecs(TeamModeConfigSchema.parse({ base_dir: userBaseDir }), projectRoot) + + // then + expect(teamSpecs).toEqual([ + { + name: "foo", + scope: "project", + path: path.join(projectTeamDir, "config.json"), + }, + ]) + expect(logCalls).toEqual([ + [ + "team-spec collision", + { + event: "team-spec-collision", + teamName: "foo", + projectPath: path.join(projectTeamDir, "config.json"), + userPath: path.join(userTeamDir, "config.json"), + }, + ], + ]) + }) + + test("ensureBaseDirs creates all dirs with mode 0700", async () => { + // given + const baseDir = path.join(tmpdir(), `omo-test-${randomUUID()}`) + + // when + await ensureBaseDirs(baseDir) + await ensureBaseDirs(baseDir) + + // then + const directoryPaths = [ + baseDir, + path.join(baseDir, "teams"), + path.join(baseDir, "runtime"), + path.join(baseDir, "worktrees"), + ] + + for (const directoryPath of directoryPaths) { + const directoryStat = await stat(directoryPath) + expect(directoryStat.isDirectory()).toBe(true) + expect(directoryStat.mode & 0o777).toBe(0o700) + } + }) +}) diff --git a/src/features/team-mode/team-registry/paths.ts b/src/features/team-mode/team-registry/paths.ts new file mode 100644 index 000000000..c80032575 --- /dev/null +++ b/src/features/team-mode/team-registry/paths.ts @@ -0,0 +1,122 @@ +import { mkdir, readdir, stat, chmod } from "node:fs/promises" +import { homedir } from "node:os" +import path from "node:path" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { log } from "../../../shared/logger" + +type TeamSpecEntry = { + name: string + scope: "project" | "user" + path: string +} + +function getTeamDirectory(baseDir: string, teamName: string, scope: "user" | "project", projectRoot?: string): string { + if (scope === "project") { + return path.join(projectRoot ?? "", ".omo", "teams", teamName) + } + + return path.join(baseDir, "teams", teamName) +} + +export function resolveBaseDir(config: TeamModeConfig): string { + return config.base_dir ?? path.join(homedir(), ".omo") +} + +export function getTeamSpecPath( + baseDir: string, + teamName: string, + scope: "user" | "project", + projectRoot?: string, +): string { + return path.join(getTeamDirectory(baseDir, teamName, scope, projectRoot), "config.json") +} + +export function getRuntimeStateDir(baseDir: string, teamRunId: string): string { + return path.join(baseDir, "runtime", teamRunId) +} + +export function getInboxDir(baseDir: string, teamRunId: string, memberName: string): string { + return path.join(baseDir, "runtime", teamRunId, "inboxes", memberName) +} + +export function getTasksDir(baseDir: string, teamRunId: string): string { + return path.join(baseDir, "runtime", teamRunId, "tasks") +} + +export function getWorktreeDir(baseDir: string, teamRunId: string, memberName: string): string { + return path.join(baseDir, "worktrees", teamRunId, memberName) +} + +async function readTeamSpecDirectories(directoryPath: string, scope: "project" | "user"): Promise { + try { + const entries = await readdir(directoryPath, { withFileTypes: true }) + + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + scope, + path: path.resolve(directoryPath, entry.name, "config.json"), + })) + } catch { + return [] + } +} + +export async function discoverTeamSpecs( + config: TeamModeConfig, + projectRoot: string, +): Promise> { + const baseDir = resolveBaseDir(config) + const projectTeamsDir = path.resolve(projectRoot, ".omo", "teams") + const userTeamsDir = path.resolve(baseDir, "teams") + + const [projectTeamSpecs, userTeamSpecs] = await Promise.all([ + readTeamSpecDirectories(projectTeamsDir, "project"), + readTeamSpecDirectories(userTeamsDir, "user"), + ]) + + const discoveredTeamSpecs: TeamSpecEntry[] = [...projectTeamSpecs] + const projectTeamNames = new Set(projectTeamSpecs.map((entry) => entry.name)) + + for (const userTeamSpec of userTeamSpecs) { + if (projectTeamNames.has(userTeamSpec.name)) { + const projectTeamSpec = projectTeamSpecs.find((entry) => entry.name === userTeamSpec.name) + if (projectTeamSpec) { + log("team-spec collision", { + event: "team-spec-collision", + teamName: userTeamSpec.name, + projectPath: projectTeamSpec.path, + userPath: userTeamSpec.path, + }) + } + continue + } + + discoveredTeamSpecs.push(userTeamSpec) + } + + return discoveredTeamSpecs +} + +export async function ensureBaseDirs(baseDir: string): Promise { + const directories = [ + baseDir, + path.join(baseDir, "teams"), + path.join(baseDir, "runtime"), + path.join(baseDir, "worktrees"), + ] + + for (const directoryPath of directories) { + await mkdir(directoryPath, { recursive: true, mode: 0o700 }) + await chmod(directoryPath, 0o700) + } + + await Promise.all(directories.map(async (directoryPath) => { + const directoryStat = await stat(directoryPath) + if ((directoryStat.mode & 0o777) !== 0o700) { + await chmod(directoryPath, 0o700) + } + })) +}