From cdb0dcc20eed31e904901275b92371b38c7e6c91 Mon Sep 17 00:00:00 2001 From: "rayson951005@gmail.com" Date: Fri, 26 Jun 2026 08:23:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(rooms):=20Room=20=E5=BB=BA/list/join=20+?= =?UTF-8?q?=20membership=20=E6=8C=81=E4=B9=85=E5=8C=96=20+=20cwd=E2=86=92r?= =?UTF-8?q?oom=20=E8=87=AA=E5=8A=A8=20join=20(PR5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 spec §2.3–2.4 落地协作房间,消费 Store。Room = 一个需求/工作流,跨仓跨人;membership 绑 logical agent id 且持久化(重启不掉)。 - src/room-service.ts:RoomService(Store-backed)。createRoom/getRoom/listRooms;join/leave/ getMembers/getRoomsForAgent/isMember(持久 membership);mapCwd/resolveRoomForCwd(cwd→room, **realpath 归一化** symlink/.. → 同房间,映射存 Store **绝不写仓库标记文件** §2.4); autoJoinByCwd(解析 cwd→room 未入则 join)。 - src/cli/room.ts:abg room create/list + abg join。currentIdentityId(从 /auth-token 解析登录身份);slugify(name→roomId,**支持中文房名**);create 自动 join 创建者 + mapCwd; join 显式加入 + 映射当前 cwd。0700 目录锁(同 auth/broker)。cli.ts 接 room/join。 测试:room-service(create/list/membership/cwd-autojoin/realpath 归一化)、cli-room(全链路 + 中文房名 + 自动 join + 无 token 友好报错)。full check exit 0:1751 pass / 0 fail。 Cross-review:轮 1 thorough 0 真实 issue;主动采纳 3 条 UX 改进(中文房名/create 自动 join/ created-reused 准确);轮 2/轮 3 lean 连续两轮 0 收敛。 Backlog:slugify NFC 归一化 + roomId 长度上限(marginal);broker-subscribe-on-join + MCP join_room = adapter 接线后续 PR;resolveDbPath/0700 在 auth/broker/room 副本可抽 helper(DRY)。 --- feat(rooms): room create/list/join + persistent membership + cwd→room auto-join (PR5) Land collaboration rooms (§2.3–2.4) over the Store. A room spans repos + people; membership binds to the logical-agent id and is persistent. RoomService owns create/list/join/leave/members + the cwd→room map (realpath-normalised, stored in the DB — NEVER a repo marker file, §2.4). CLI: abg room create/list + abg join (create auto-joins the creator + maps cwd; Chinese room names supported). Converged after 1 thorough (0 real) + 2 lean clean rounds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- plugins/agentbridge/server/bridge-server.js | 4 +- plugins/agentbridge/server/daemon.js | 4 +- src/cli.ts | 11 ++ src/cli/room.ts | 188 ++++++++++++++++++++ src/room-service.ts | 83 +++++++++ src/unit-test/cli-room.test.ts | 102 +++++++++++ src/unit-test/room-service.test.ts | 68 +++++++ 7 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 src/cli/room.ts create mode 100644 src/room-service.ts create mode 100644 src/unit-test/cli-room.test.ts create mode 100644 src/unit-test/room-service.test.ts diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 7a61776..5352ddf 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("6b31d53", "source"), + commit: defineString("3b71186", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("539a761f8766", "source") + codeHash: defineString("d4792cf81ed0", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index 11a576b..38be71e 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("6b31d53", "source"), + commit: defineString("3b71186", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("539a761f8766", "source") + codeHash: defineString("d4792cf81ed0", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; diff --git a/src/cli.ts b/src/cli.ts index af51142..20d9f30 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -127,6 +127,14 @@ async function main(command: string | undefined, restArgs: string[]) { const { runBroker } = await import("./cli/broker"); await runBroker(restArgs); break; + case "room": + const { runRoom } = await import("./cli/room"); + await runRoom(restArgs); + break; + case "join": + const { runJoin } = await import("./cli/room"); + await runJoin(restArgs); + break; case "--help": case "-h": case undefined: @@ -173,6 +181,9 @@ Commands: budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state) auth login --id --name Issue a collaboration PSK token and write it to /auth-token (0600) + room create | room list + Create a collaboration room (id = slugified name) or list rooms + join Join a room and auto-join this directory next time (§2.4) logs [--codex] [-f] [-n N] Tail this pair's daemon log (or the codex wrapper log with --codex). -n N: last N lines (default 100). -f: follow/stream. diff --git a/src/cli/room.ts b/src/cli/room.ts new file mode 100644 index 0000000..dea9e6d --- /dev/null +++ b/src/cli/room.ts @@ -0,0 +1,188 @@ +/** + * `abg room create/list` + `abg join` — collaboration room CLI (§2.3–2.4). + * + * A room = one requirement/workflow, cross-repo + cross-person. Membership binds + * to the logical agent (the logged-in collab identity) and is persistent. `join` + * also records a cwd→room mapping so this directory auto-joins next time (§2.4). + * + * Shares the same collab Store + 0700 directory lockdown as `abg auth login` / + * `abg broker start`; the logged-in identity is resolved from `/auth-token`. + */ + +import { chmodSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { RoomService } from "../room-service"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import type { RoomRecord, Store } from "../backbone/store"; +import { StateDirResolver } from "../state-dir"; + +/** Resolve the collab DB path: explicit > env override > `/collab.db`. */ +function resolveDbPath(explicit?: string): string { + if (explicit) return explicit; + const env = process.env.AGENTBRIDGE_COLLAB_DB; + if (env && env.length > 0) return env; + return join(new StateDirResolver().dir, "collab.db"); +} + +/** + * Resolve the currently logged-in collab identity id from `/auth-token` + * (written by `abg auth login`). The token file is a local secret; a missing or + * unresolvable token means the user has not logged in yet. + */ +export async function currentIdentityId(store: Store, dbPath: string): Promise { + const tokenFile = join(dirname(dbPath), "auth-token"); + let token: string; + try { + token = readFileSync(tokenFile, "utf-8").trim(); + } catch { + throw new Error("未找到登录令牌,请先运行 abg auth login"); + } + if (token === "") throw new Error("登录令牌为空,请先运行 abg auth login"); + const identityId = await store.resolveToken(token); + if (!identityId) throw new Error("登录令牌无效,请先运行 abg auth login"); + return identityId; +} + +/** + * Turn a human room name into a room id: lowercase, whitespace→`-`, keep unicode + * letters/numbers (Chinese-first, so "结账" is valid) + dash, drop everything + * else, collapse runs of `-`, trim leading/trailing `-`. Throws when nothing + * usable remains (e.g. a name of only punctuation). + */ +export function slugify(name: string): string { + // Keep unicode letters/numbers (the project is Chinese-first, so "结账" is a + // valid room id) + dash; whitespace → dash; drop everything else. The room id + // is an internal topic key / Store key, not a URL, so CJK is fine. + const slug = name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\p{L}\p{N}-]/gu, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + if (slug === "") throw new Error(`无法从「${name}」生成有效的房间 ID(需含字母或数字)`); + return slug; +} + +/** Open the collab Store with the same 0700 lockdown as `abg auth login`. */ +function openStore(dbPath: string): SqliteStore { + const dir = dirname(dbPath); + // The collab DB holds raw PSK tokens + PII; lock the containing dir to 0700 + // (matches auth.ts/broker.ts — bun:sqlite files are 0644 so dir is the gate). + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + return new SqliteStore(dbPath); +} + +/** + * Create a room owned by the logged-in identity (roomId = slugify(name)), join + * the creator to it, and map the cwd so this directory auto-joins next time. If + * the slug already exists it is reused (created=false) — the creator still joins. + */ +export async function createRoom(opts: { + name: string; + cwd?: string; + dbPath?: string; +}): Promise<{ roomId: string; created: boolean }> { + const dbPath = resolveDbPath(opts.dbPath); + const store = openStore(dbPath); + try { + const roomId = slugify(opts.name); + const createdBy = await currentIdentityId(store, dbPath); + const svc = new RoomService(store); + const existed = (await svc.getRoom(roomId)) !== null; + await svc.createRoom(roomId, opts.name, createdBy); // INSERT OR IGNORE — reuse if existed + await svc.join(roomId, createdBy); // the creator is a member + await svc.mapCwd(opts.cwd ?? process.cwd(), roomId); + return { roomId, created: !existed }; + } finally { + await store.close(); + } +} + +/** List all rooms in the collab store. */ +export async function listRooms(opts: { dbPath?: string }): Promise { + const dbPath = resolveDbPath(opts.dbPath); + const store = openStore(dbPath); + try { + return await new RoomService(store).listRooms(); + } finally { + await store.close(); + } +} + +/** + * Join the logged-in identity to a room and map the cwd to it (so the same + * directory auto-joins next time, §2.4). Throws if the room does not exist. + */ +export async function joinRoom(opts: { + roomId: string; + cwd?: string; + dbPath?: string; +}): Promise<{ roomId: string; agentId: string }> { + const dbPath = resolveDbPath(opts.dbPath); + const store = openStore(dbPath); + try { + const agentId = await currentIdentityId(store, dbPath); + const svc = new RoomService(store); + if ((await svc.getRoom(opts.roomId)) === null) { + throw new Error(`房间不存在:${opts.roomId}(先用 abg room create 创建)`); + } + await svc.join(opts.roomId, agentId); + await svc.mapCwd(opts.cwd ?? process.cwd(), opts.roomId); + return { roomId: opts.roomId, agentId }; + } finally { + await store.close(); + } +} + +const ROOM_USAGE = "用法:abg room create | abg room list"; + +/** Dispatch `abg room `: `create ` / `list`. */ +export async function runRoom(args: string[]): Promise { + const sub = args[0]; + switch (sub) { + case "create": { + const name = args.slice(1).join(" ").trim(); + if (!name) { + console.error("缺少房间名称。"); + console.error(ROOM_USAGE); + process.exit(1); + return; + } + const { roomId, created } = await createRoom({ name }); + console.log( + created + ? `已创建房间 ${roomId}(${name}),你已加入;该目录今后会自动加入` + : `房间 ${roomId} 已存在,已为你加入;该目录今后会自动加入`, + ); + break; + } + case "list": { + const rooms = await listRooms({}); + if (rooms.length === 0) { + console.log("(暂无房间)"); + break; + } + for (const r of rooms) { + console.log(`${r.roomId}\t${r.name}\t${r.createdBy}`); + } + break; + } + default: + console.error(`未知的 room 子命令:${sub ?? "(空)"}`); + console.error(ROOM_USAGE); + process.exit(1); + } +} + +/** Dispatch `abg join `. */ +export async function runJoin(args: string[]): Promise { + const roomId = args[0]; + if (!roomId) { + console.error("用法:abg join "); + process.exit(1); + return; + } + const result = await joinRoom({ roomId }); + console.log(`已加入房间 ${result.roomId}(agent ${result.agentId});该目录今后会自动加入`); +} diff --git a/src/room-service.ts b/src/room-service.ts new file mode 100644 index 0000000..6771841 --- /dev/null +++ b/src/room-service.ts @@ -0,0 +1,83 @@ +import { realpathSync } from "node:fs"; +import type { Store, RoomRecord } from "./backbone/store"; + +export interface AutoJoinResult { + roomId: string; + /** true if this call newly joined the agent; false if it was already a member. */ + joined: boolean; +} + +/** + * §2.3–2.4 room service over a Store. + * + * A room = one requirement/workflow, cross-repo + cross-person. Anyone can create + * a room; others join. Membership binds to a LOGICAL AGENT id and is PERSISTENT + * (survives restart) — never a session id (§2.3). Three join paths (§2.4): the + * cwd→room map (automatic), explicit `join`, and worktree (just another cwd). + * + * The cwd→room map keys on the REALPATH of the workspace dir so symlinks/`..` + * don't fork a room; it never writes anything into the repo (no marker files that + * could be committed). Broker subscription on join is the adapter's job + * (BrokerClient.subscribe) — this service owns only the persistent membership. + */ +export class RoomService { + constructor(private readonly store: Store) {} + + // --- rooms --- + async createRoom(roomId: string, name: string, createdBy: string): Promise { + await this.store.createRoom(roomId, name, createdBy); + } + async getRoom(roomId: string): Promise { + return this.store.getRoom(roomId); + } + async listRooms(): Promise { + return this.store.listRooms(); + } + + // --- membership (persistent, bound to logical agent id) --- + async join(roomId: string, agentId: string): Promise { + await this.store.addMember(roomId, agentId); + } + async leave(roomId: string, agentId: string): Promise { + await this.store.removeMember(roomId, agentId); + } + async getMembers(roomId: string): Promise { + return this.store.getMembers(roomId); + } + async getRoomsForAgent(agentId: string): Promise { + return this.store.getRoomsForAgent(agentId); + } + async isMember(roomId: string, agentId: string): Promise { + return (await this.store.getMembers(roomId)).includes(agentId); + } + + // --- cwd → room map (§2.4 automatic join) --- + async mapCwd(workspacePath: string, roomId: string): Promise { + await this.store.mapCwd(this.normalizeCwd(workspacePath), roomId); + } + async resolveRoomForCwd(workspacePath: string): Promise { + return this.store.getRoomForCwd(this.normalizeCwd(workspacePath)); + } + + /** + * Resolve `workspacePath` to its mapped room and join `agentId` to it if not + * already a member. Returns null when the cwd has no mapping (caller falls back + * to an explicit join). The primary auto-join path (§2.4). + */ + async autoJoinByCwd(workspacePath: string, agentId: string): Promise { + const roomId = await this.resolveRoomForCwd(workspacePath); + if (!roomId) return null; + const already = await this.isMember(roomId, agentId); + if (!already) await this.join(roomId, agentId); + return { roomId, joined: !already }; + } + + /** Realpath the workspace dir so symlinks/`..` map to the same room; fall back on error. */ + private normalizeCwd(workspacePath: string): string { + try { + return realpathSync(workspacePath); + } catch { + return workspacePath; + } + } +} diff --git a/src/unit-test/cli-room.test.ts b/src/unit-test/cli-room.test.ts new file mode 100644 index 0000000..bf501ee --- /dev/null +++ b/src/unit-test/cli-room.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRoom, joinRoom, listRooms } from "../cli/room"; +import { IdentityService } from "../backbone/identity-service"; +import { RoomService } from "../room-service"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import { atomicWriteText } from "../atomic-json"; + +/** Mimic `abg auth login`: register an identity, issue a token, persist it. */ +async function seedLogin(dir: string, dbPath: string): Promise { + const store = new SqliteStore(dbPath); + try { + const svc = new IdentityService(store); + const identity = await svc.registerIdentity("alice@x.com", "Alice"); + const token = await svc.issueToken(identity.id); + atomicWriteText(join(dir, "auth-token"), token, { mode: 0o600 }); + return identity.id; + } finally { + await store.close(); + } +} + +describe("cli/room", () => { + let dir: string | undefined; + let cwd: string | undefined; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + if (cwd) rmSync(cwd, { recursive: true, force: true }); + dir = undefined; + cwd = undefined; + }); + + it("create → list → join round-trips through the collab store", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + const identityId = await seedLogin(dir, dbPath); + + const created = await createRoom({ name: "My Checkout", dbPath }); + expect(created.roomId).toBe("my-checkout"); + + const rooms = await listRooms({ dbPath }); + expect(rooms.map((r) => r.roomId)).toContain("my-checkout"); + + cwd = mkdtempSync(join(tmpdir(), "agentbridge-room-cwd-")); + const joined = await joinRoom({ roomId: created.roomId, cwd, dbPath }); + expect(joined).toEqual({ roomId: "my-checkout", agentId: identityId }); + + // membership + cwd→room map persisted under a fresh service over the same DB + const store = new SqliteStore(dbPath); + try { + const svc = new RoomService(store); + expect(await svc.isMember("my-checkout", identityId)).toBe(true); + expect(await svc.resolveRoomForCwd(cwd)).toBe("my-checkout"); + } finally { + await store.close(); + } + }); + + it("supports Chinese room names, auto-joins the creator, and reports created vs reused", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + const identityId = await seedLogin(dir, dbPath); + cwd = mkdtempSync(join(tmpdir(), "agentbridge-room-cwd-")); + + const first = await createRoom({ name: "结账", cwd, dbPath }); + expect(first).toEqual({ roomId: "结账", created: true }); + + // creator is auto-joined + cwd mapped — no separate `abg join` needed + const store = new SqliteStore(dbPath); + try { + const svc = new RoomService(store); + expect(await svc.isMember("结账", identityId)).toBe(true); + expect(await svc.resolveRoomForCwd(cwd)).toBe("结账"); + } finally { + await store.close(); + } + + // creating the same slug again reuses it (created=false), creator still a member + expect(await createRoom({ name: "结账", cwd, dbPath })).toEqual({ roomId: "结账", created: false }); + }); + + it("throws a friendly 'abg auth login' error when not logged in", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + // no seedLogin → no auth-token file at all + + await expect(createRoom({ name: "X", dbPath })).rejects.toThrow(/abg auth login/); + + // pre-create the room so joinRoom would pass the existence check; it must + // still fail at the auth gate, not the room-existence one. + const store = new SqliteStore(dbPath); + try { + await new RoomService(store).createRoom("x", "X", "someone"); + } finally { + await store.close(); + } + await expect(joinRoom({ roomId: "x", dbPath })).rejects.toThrow(/abg auth login/); + }); +}); diff --git a/src/unit-test/room-service.test.ts b/src/unit-test/room-service.test.ts new file mode 100644 index 0000000..e650f8b --- /dev/null +++ b/src/unit-test/room-service.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { mkdtempSync, mkdirSync, symlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RoomService } from "../room-service"; +import { InMemoryStore } from "../backbone/store/memory-store"; + +describe("RoomService — rooms + persistent membership + cwd→room (§2.3–2.4)", () => { + let store: InMemoryStore; + let svc: RoomService; + beforeEach(() => { + store = new InMemoryStore(); + svc = new RoomService(store); + }); + + test("create / get / list", async () => { + await svc.createRoom("room-checkout", "checkout", "ag-1"); + expect(await svc.getRoom("room-checkout")).toEqual({ + roomId: "room-checkout", + name: "checkout", + createdBy: "ag-1", + }); + await svc.createRoom("room-auth", "auth", "ag-2"); + expect((await svc.listRooms()).map((r) => r.roomId).sort()).toEqual([ + "room-auth", + "room-checkout", + ]); + }); + + test("membership: join is persistent + bidirectional + idempotent; leave works", async () => { + await svc.createRoom("r1", "r1", "ag-1"); + await svc.join("r1", "ag-1"); + await svc.join("r1", "ag-2"); + await svc.join("r1", "ag-1"); // idempotent + expect((await svc.getMembers("r1")).sort()).toEqual(["ag-1", "ag-2"]); + expect(await svc.getRoomsForAgent("ag-1")).toEqual(["r1"]); + expect(await svc.isMember("r1", "ag-2")).toBe(true); + await svc.leave("r1", "ag-2"); + expect(await svc.isMember("r1", "ag-2")).toBe(false); + }); + + test("cwd→room map + autoJoinByCwd (joins once, then no-op; unmapped → null)", async () => { + await svc.createRoom("r1", "r1", "ag-1"); + expect(await svc.resolveRoomForCwd("/repo/a")).toBeNull(); + await svc.mapCwd("/repo/a", "r1"); + expect(await svc.resolveRoomForCwd("/repo/a")).toBe("r1"); + expect(await svc.autoJoinByCwd("/repo/a", "ag-9")).toEqual({ roomId: "r1", joined: true }); + expect(await svc.autoJoinByCwd("/repo/a", "ag-9")).toEqual({ roomId: "r1", joined: false }); + expect(await svc.isMember("r1", "ag-9")).toBe(true); + expect(await svc.autoJoinByCwd("/repo/unmapped", "ag-9")).toBeNull(); + }); + + test("cwd map normalizes via realpath — a symlinked path resolves the same room", async () => { + const dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + try { + const real = join(dir, "real"); + mkdirSync(real); + const link = join(dir, "link"); + symlinkSync(real, link); + await svc.createRoom("rr", "rr", "ag-1"); + await svc.mapCwd(real, "rr"); + // resolving through the SYMLINK lands the same room (realpath normalization) + expect(await svc.resolveRoomForCwd(link)).toBe("rr"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});