diff --git a/package.json b/package.json index cab48ac..2605d1e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js", + "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", "test:all": "pnpm test && pnpm test:delivery", diff --git a/src/core/store.ts b/src/core/store.ts index 00df400..5d3036f 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -201,21 +201,27 @@ export class FileStore implements CommsStore { async listAgents(requesterId: string): Promise { const dir = path.join(this.root, "registry", "agents"); + let files: string[]; try { - const files = await fs.readdir(dir); - const agents: AgentIdentity[] = []; - for (const file of files) { - if (!file.endsWith(".json")) continue; - const agent = AgentIdentitySchema.parse( - await this.readJsonFile(path.join(dir, file)), - ); - if (agent.visibility === "ghost" && agent.id !== requesterId) continue; - agents.push(agent); + files = await fs.readdir(dir); + } catch (err) { + // A store with no registry yet legitimately lists no agents; a record + // that fails to parse is a real failure and must surface (#32). + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return []; } - return agents; - } catch { - return []; + throw err; } + const agents: AgentIdentity[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const agent = AgentIdentitySchema.parse( + await this.readJsonFile(path.join(dir, file)), + ); + if (agent.visibility === "ghost" && agent.id !== requesterId) continue; + agents.push(agent); + } + return agents; } async setAgentOffline(id: string): Promise { @@ -375,7 +381,11 @@ export class FileStore implements CommsStore { throw new CommsError("Only the room owner can invite", "NOT_OWNER"); if (!room.invited.includes(targetId) && !room.members.includes(targetId)) { - room.invited.push(targetId); + room.version += 1; + room.invitedJoins[targetId] = room.version; + room.invited = Object.keys(room.invitedJoins).filter( + (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0), + ); } await this.writeJsonFile(this.roomPath(roomId), room); @@ -405,7 +415,11 @@ export class FileStore implements CommsStore { "NOT_INVITED", ); - room.invited = room.invited.filter((id) => id !== agentId); + room.version += 1; + room.invitedLeaves[agentId] = room.version; + room.invited = Object.keys(room.invitedJoins).filter( + (id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0), + ); await this.writeJsonFile(this.roomPath(roomId), room); const decliner = await this.getAgent(agentId); diff --git a/src/test/filestore.test.ts b/src/test/filestore.test.ts new file mode 100644 index 0000000..47ef888 --- /dev/null +++ b/src/test/filestore.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for the FileStore, the filesystem-backed legacy store exported from the public API. Covers the membership operation maps (a pending invite must survive an unrelated join, #35) and record parsing (an unparseable record must surface, not read as an empty mesh, #32). + */ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { FileStore } from "../core/store.js"; + +function tempStore(): FileStore { + const root = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-filestore-")); + return new FileStore(root); +} + +void test("a pending invite survives an unrelated join to the room", async () => { + const store = tempStore(); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const invitee = await store.registerAgent({ + name: "invitee", + harness: "claude-code", + cwd: "/tmp/i", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const other = await store.registerAgent({ + name: "other", + harness: "codex", + cwd: "/tmp/o", + pid: process.pid, + visibility: "visible", + tags: [], + }); + await store.createRoom({ + name: "room", + type: "public", + owner: owner.id, + description: "x", + }); + await store.inviteToRoom("room", invitee.id, owner.id); + assert.deepEqual((await store.getRoom("room"))?.invited, [invitee.id]); + + // Any later join re-derives the invited view from the operation maps; the pending invite must survive it (#35). + await store.joinRoom("room", other.id); + assert.deepEqual((await store.getRoom("room"))?.invited, [invitee.id]); +}); + +void test("declining and kicking clear the invited view consistently", async () => { + const store = tempStore(); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const invitee = await store.registerAgent({ + name: "invitee", + harness: "claude-code", + cwd: "/tmp/i", + pid: process.pid, + visibility: "visible", + tags: [], + }); + await store.createRoom({ + name: "room", + type: "private", + owner: owner.id, + description: "x", + }); + await store.inviteToRoom("room", invitee.id, owner.id); + await store.declineInvite("room", invitee.id, "not now"); + const declined = await store.getRoom("room"); + assert.deepEqual(declined?.invited, []); + // Re-inviting after a decline works: the new join op outranks the leave. + await store.inviteToRoom("room", invitee.id, owner.id); + assert.deepEqual((await store.getRoom("room"))?.invited, [invitee.id]); + await store.kickFromRoom("room", invitee.id, owner.id); + assert.deepEqual((await store.getRoom("room"))?.invited, []); + assert.equal( + (await store.getRoom("room"))?.members.includes(invitee.id), + false, + ); +}); + +void test("joining through an invitation consumes it", async () => { + const store = tempStore(); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const invitee = await store.registerAgent({ + name: "invitee", + harness: "claude-code", + cwd: "/tmp/i", + pid: process.pid, + visibility: "visible", + tags: [], + }); + await store.createRoom({ + name: "room", + type: "private", + owner: owner.id, + description: "x", + }); + await store.inviteToRoom("room", invitee.id, owner.id); + await store.joinRoom("room", invitee.id); + const room = await store.getRoom("room"); + assert.deepEqual(room?.invited, []); + assert.equal(room?.members.includes(invitee.id), true); +}); + +void test("an unparseable stored record surfaces instead of an empty list", async () => { + const store = tempStore(); + await store.registerAgent({ + name: "good", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + // A record missing the required fields (written by an older build, say) must raise from listAgents, not read as an empty mesh (#32). + const agentsDir = path.join(store.root, "registry", "agents"); + fs.writeFileSync(path.join(agentsDir, "stale.json"), '{"id": "stale"}'); + await assert.rejects( + store.listAgents("whoever"), + (err: unknown) => err instanceof Error, + ); +}); + +void test("a store with no registry yet lists no agents", async () => { + const store = tempStore(); + assert.deepEqual(await store.listAgents("whoever"), []); +});