feat: v0.2.0 — Groups (@group routing, roles, wizard)
Some checks failed
Some checks failed
Phase A of the claudemesh spec. Peers can now join named groups with roles, and messages route to @group targets. Broker: - @group routing in fan-out (matches peer group membership) - @all alias for broadcast - join_group/leave_group WS messages + DB persistence - list_peers returns group metadata - drainForMember matches @group targetSpecs in SQL CLI: - join_group/leave_group MCP tools - send_message supports @group targets - list_peers shows group membership - PeerInfo includes groups array - Peer name cache for push notifications Launch: - --role flag (optional peer role) - --groups flag (comma-separated, e.g. "frontend:lead,reviewers") - Interactive wizard for role + groups when flags omitted - Groups written to session config for broker hello Spec: SPEC.md added with full v0.2 vision (groups, state, memory) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -328,6 +328,7 @@ export interface ConnectParams {
|
||||
displayName?: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
}
|
||||
|
||||
/** Create a presence row for a new WS connection. */
|
||||
@@ -347,6 +348,7 @@ export async function connectPresence(
|
||||
status: "idle",
|
||||
statusSource: "jsonl",
|
||||
statusUpdatedAt: now,
|
||||
groups: params.groups ?? [],
|
||||
connectedAt: now,
|
||||
lastPingAt: now,
|
||||
})
|
||||
@@ -384,6 +386,7 @@ export async function listPeersInMesh(
|
||||
displayName: string;
|
||||
status: string;
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: Date;
|
||||
}>
|
||||
@@ -396,6 +399,7 @@ export async function listPeersInMesh(
|
||||
presenceDisplayName: presence.displayName,
|
||||
status: presence.status,
|
||||
summary: presence.summary,
|
||||
groups: presence.groups,
|
||||
sessionId: presence.sessionId,
|
||||
connectedAt: presence.connectedAt,
|
||||
})
|
||||
@@ -414,6 +418,7 @@ export async function listPeersInMesh(
|
||||
displayName: r.presenceDisplayName || r.memberDisplayName,
|
||||
status: r.status,
|
||||
summary: r.summary,
|
||||
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||
sessionId: r.sessionId,
|
||||
connectedAt: r.connectedAt,
|
||||
}));
|
||||
@@ -430,6 +435,60 @@ export async function setSummary(
|
||||
.where(eq(presence.id, presenceId));
|
||||
}
|
||||
|
||||
// --- Group management ---
|
||||
|
||||
/**
|
||||
* Join a group (upsert). If the peer is already in the group, update the role.
|
||||
* Returns the updated groups array.
|
||||
*/
|
||||
export async function joinGroup(
|
||||
presenceId: string,
|
||||
name: string,
|
||||
role?: string,
|
||||
): Promise<Array<{ name: string; role?: string }>> {
|
||||
const [row] = await db
|
||||
.select({ groups: presence.groups })
|
||||
.from(presence)
|
||||
.where(eq(presence.id, presenceId));
|
||||
if (!row) return [];
|
||||
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).slice();
|
||||
const idx = groups.findIndex((g) => g.name === name);
|
||||
const entry: { name: string; role?: string } = { name };
|
||||
if (role) entry.role = role;
|
||||
if (idx >= 0) {
|
||||
groups[idx] = entry;
|
||||
} else {
|
||||
groups.push(entry);
|
||||
}
|
||||
await db
|
||||
.update(presence)
|
||||
.set({ groups })
|
||||
.where(eq(presence.id, presenceId));
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a group. Returns the updated groups array.
|
||||
*/
|
||||
export async function leaveGroup(
|
||||
presenceId: string,
|
||||
name: string,
|
||||
): Promise<Array<{ name: string; role?: string }>> {
|
||||
const [row] = await db
|
||||
.select({ groups: presence.groups })
|
||||
.from(presence)
|
||||
.where(eq(presence.id, presenceId));
|
||||
if (!row) return [];
|
||||
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).filter(
|
||||
(g) => g.name !== name,
|
||||
);
|
||||
await db
|
||||
.update(presence)
|
||||
.set({ groups })
|
||||
.where(eq(presence.id, presenceId));
|
||||
return groups;
|
||||
}
|
||||
|
||||
// --- Message queueing + delivery ---
|
||||
|
||||
export interface QueueParams {
|
||||
@@ -493,6 +552,7 @@ export async function drainForMember(
|
||||
status: PeerStatus,
|
||||
sessionPubkey?: string,
|
||||
excludeSenderSessionPubkey?: string,
|
||||
memberGroups?: string[],
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
@@ -510,6 +570,18 @@ export async function drainForMember(
|
||||
priorities.map((p) => `'${p}'`).join(","),
|
||||
);
|
||||
|
||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||
// for each group the peer belongs to.
|
||||
const groupTargets = ["@all"];
|
||||
if (memberGroups) {
|
||||
for (const g of memberGroups) {
|
||||
groupTargets.push(`@${g}`);
|
||||
}
|
||||
}
|
||||
const groupTargetList = sql.raw(
|
||||
groupTargets.map((t) => `'${t}'`).join(","),
|
||||
);
|
||||
|
||||
// Atomic claim with SQL-side ordering. The CTE claims rows via
|
||||
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
|
||||
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
|
||||
@@ -533,7 +605,7 @@ export async function drainForMember(
|
||||
WHERE mesh_id = ${meshId}
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
findMemberByPubkey,
|
||||
handleHookSetStatus,
|
||||
heartbeat,
|
||||
joinGroup,
|
||||
joinMesh,
|
||||
leaveGroup,
|
||||
listPeersInMesh,
|
||||
queueMessage,
|
||||
refreshQueueDepth,
|
||||
@@ -58,6 +60,7 @@ interface PeerConn {
|
||||
memberPubkey: string;
|
||||
sessionPubkey: string | null;
|
||||
cwd: string;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
}
|
||||
|
||||
const connections = new Map<string, PeerConn>();
|
||||
@@ -99,6 +102,7 @@ async function maybePushQueuedMessages(
|
||||
status,
|
||||
conn.sessionPubkey ?? undefined,
|
||||
excludeSenderSessionPubkey,
|
||||
conn.groups.map((g) => g.name),
|
||||
);
|
||||
for (const m of messages) {
|
||||
const push: WSPushMessage = {
|
||||
@@ -403,6 +407,7 @@ async function handleHello(
|
||||
ws.close(1008, "unauthorized");
|
||||
return null;
|
||||
}
|
||||
const initialGroups = hello.groups ?? [];
|
||||
const presenceId = await connectPresence({
|
||||
memberId: member.id,
|
||||
sessionId: hello.sessionId,
|
||||
@@ -410,6 +415,7 @@ async function handleHello(
|
||||
displayName: hello.displayName,
|
||||
pid: hello.pid,
|
||||
cwd: hello.cwd,
|
||||
groups: initialGroups,
|
||||
});
|
||||
connections.set(presenceId, {
|
||||
ws,
|
||||
@@ -418,6 +424,7 @@ async function handleHello(
|
||||
memberPubkey: hello.pubkey,
|
||||
sessionPubkey: hello.sessionPubkey ?? null,
|
||||
cwd: hello.cwd,
|
||||
groups: initialGroups,
|
||||
});
|
||||
incMeshCount(hello.meshId);
|
||||
const effectiveDisplayName = hello.displayName || member.displayName;
|
||||
@@ -463,13 +470,31 @@ async function handleSend(
|
||||
}
|
||||
|
||||
// Fan-out over connected peers in the same mesh — skip sender.
|
||||
// Resolve @group routing: "@all" is alias for "*", "@<name>" matches
|
||||
// peers whose in-memory groups array contains that group name.
|
||||
const isGroupTarget = msg.targetSpec.startsWith("@");
|
||||
const isBroadcast =
|
||||
msg.targetSpec === "*" ||
|
||||
(isGroupTarget && msg.targetSpec === "@all");
|
||||
const groupName = isGroupTarget && !isBroadcast
|
||||
? msg.targetSpec.slice(1)
|
||||
: null;
|
||||
|
||||
for (const [pid, peer] of connections) {
|
||||
if (pid === senderPresenceId) continue;
|
||||
if (peer.meshId !== conn.meshId) continue;
|
||||
if (msg.targetSpec !== "*"
|
||||
&& peer.memberPubkey !== msg.targetSpec
|
||||
&& peer.sessionPubkey !== msg.targetSpec)
|
||||
continue;
|
||||
|
||||
if (isBroadcast) {
|
||||
// broadcast — deliver to everyone
|
||||
} else if (groupName) {
|
||||
// group routing — deliver only if peer is in the group
|
||||
if (!peer.groups.some((g) => g.name === groupName)) continue;
|
||||
} else {
|
||||
// direct routing — match by pubkey
|
||||
if (peer.memberPubkey !== msg.targetSpec
|
||||
&& peer.sessionPubkey !== msg.targetSpec)
|
||||
continue;
|
||||
}
|
||||
void maybePushQueuedMessages(pid, conn.sessionPubkey ?? undefined);
|
||||
}
|
||||
}
|
||||
@@ -525,6 +550,7 @@ function handleConnection(ws: WebSocket): void {
|
||||
displayName: p.displayName,
|
||||
status: p.status as "idle" | "working" | "dnd",
|
||||
summary: p.summary,
|
||||
groups: p.groups,
|
||||
sessionId: p.sessionId,
|
||||
connectedAt: p.connectedAt.toISOString(),
|
||||
})),
|
||||
@@ -546,6 +572,27 @@ function handleConnection(ws: WebSocket): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "join_group": {
|
||||
const jg = msg as Extract<WSClientMessage, { type: "join_group" }>;
|
||||
const updatedGroups = await joinGroup(presenceId, jg.name, jg.role);
|
||||
conn.groups = updatedGroups;
|
||||
log.info("ws join_group", {
|
||||
presence_id: presenceId,
|
||||
group: jg.name,
|
||||
role: jg.role,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "leave_group": {
|
||||
const lg = msg as Extract<WSClientMessage, { type: "leave_group" }>;
|
||||
const updatedGroups = await leaveGroup(presenceId, lg.name);
|
||||
conn.groups = updatedGroups;
|
||||
log.info("ws leave_group", {
|
||||
presence_id: presenceId,
|
||||
group: lg.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
timestamp: number;
|
||||
/** ed25519 signature (hex) over the canonical hello bytes:
|
||||
@@ -103,6 +105,19 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: leave a group. */
|
||||
export interface WSLeaveGroupMessage {
|
||||
type: "leave_group";
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a send. */
|
||||
export interface WSAckMessage {
|
||||
type: "ack";
|
||||
@@ -126,6 +141,7 @@ export interface WSPeersListMessage {
|
||||
displayName: string;
|
||||
status: PeerStatus;
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
}>;
|
||||
@@ -144,7 +160,9 @@ export type WSClientMessage =
|
||||
| WSSendMessage
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage;
|
||||
| WSSetSummaryMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage;
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
|
||||
Reference in New Issue
Block a user