Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 28 additions & 14 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,21 +201,27 @@ export class FileStore implements CommsStore {

async listAgents(requesterId: string): Promise<AgentIdentity[]> {
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<void> {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
149 changes: 149 additions & 0 deletions src/test/filestore.test.ts
Original file line number Diff line number Diff line change
@@ -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"), []);
});