feat(api): humans-as-peers in /v1/peers
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Recently-active apikey holders (used in the last 5 minutes) appear
in the peer list alongside WS-connected sessions. The dashboard
chat user now becomes visible to CLI peers calling list_peers,
closing the v1.6.0 humans-as-peers loop.

Presence rows take precedence when both exist; rest-only rows
get via:"rest" flag and idle status (no presence channel to
infer working/dnd from).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 19:28:47 +01:00
parent a83133a4c6
commit f4601f4d9c

View File

@@ -29,6 +29,7 @@ import { z } from "zod";
import { db } from "@turbostarter/db/server"; import { db } from "@turbostarter/db/server";
import { import {
mesh, mesh,
meshApiKey,
meshMember, meshMember,
meshTopic, meshTopic,
meshTopicMember, meshTopicMember,
@@ -586,13 +587,22 @@ export const v1Router = new Hono<Env>()
}) })
// GET /v1/peers — connected peers in the key's mesh // GET /v1/peers — connected peers in the key's mesh
// Dedupe by memberId — a member can have multiple active presence //
// rows (one per session). Status reflects the most recent presence; // Sources, deduped by memberId:
// summary/groups come from the latest row. // 1. presence rows — WS-connected peers (CLI sessions, MCP push-pipes)
// 2. recently-active apikey holders — humans driving the dashboard
// chat over REST. We treat any apikey used in the last 5 minutes
// as a live "human peer" so other CLIs can see them.
//
// Presence wins when both exist (more accurate status). Apikey-only
// rows get a `via: "rest"` flag and inherit the issuing member's
// identity — that's the only way the dashboard chat user appears in
// /list_peers from a CLI today.
.get("/peers", async (c) => { .get("/peers", async (c) => {
const key = c.var.apiKey; const key = c.var.apiKey;
requireCapability(key, "read"); requireCapability(key, "read");
const rows = await db
const presenceRows = await db
.select({ .select({
memberId: meshMember.id, memberId: meshMember.id,
pubkey: meshMember.peerPubkey, pubkey: meshMember.peerPubkey,
@@ -608,6 +618,28 @@ export const v1Router = new Hono<Env>()
and(eq(meshMember.meshId, key.meshId), isNull(presence.disconnectedAt)), and(eq(meshMember.meshId, key.meshId), isNull(presence.disconnectedAt)),
) )
.orderBy(desc(presence.connectedAt)); .orderBy(desc(presence.connectedAt));
const restCutoff = new Date(Date.now() - 5 * 60 * 1000);
const restRows = await db
.select({
memberId: meshMember.id,
pubkey: meshMember.peerPubkey,
displayName: meshMember.displayName,
lastUsedAt: meshApiKey.lastUsedAt,
})
.from(meshApiKey)
.innerJoin(
meshMember,
eq(meshApiKey.issuedByMemberId, meshMember.id),
)
.where(
and(
eq(meshApiKey.meshId, key.meshId),
isNull(meshApiKey.revokedAt),
gt(meshApiKey.lastUsedAt, restCutoff),
),
);
const seen = new Set<string>(); const seen = new Set<string>();
const peers: Array<{ const peers: Array<{
pubkey: string; pubkey: string;
@@ -615,8 +647,10 @@ export const v1Router = new Hono<Env>()
status: string; status: string;
summary: string | null; summary: string | null;
groups: unknown; groups: unknown;
via: "ws" | "rest";
}> = []; }> = [];
for (const r of rows) {
for (const r of presenceRows) {
if (seen.has(r.memberId)) continue; if (seen.has(r.memberId)) continue;
seen.add(r.memberId); seen.add(r.memberId);
peers.push({ peers.push({
@@ -625,6 +659,19 @@ export const v1Router = new Hono<Env>()
status: r.status, status: r.status,
summary: r.summary, summary: r.summary,
groups: r.groups, groups: r.groups,
via: "ws",
});
}
for (const r of restRows) {
if (seen.has(r.memberId)) continue;
seen.add(r.memberId);
peers.push({
pubkey: r.pubkey,
displayName: r.displayName,
status: "idle",
summary: null,
groups: [],
via: "rest",
}); });
} }
return c.json({ peers }); return c.json({ peers });