feat: persist peer session state across disconnects ("welcome back" on reconnect)

Save groups, profile, visibility, summary, display name, and cumulative
stats to a new mesh.peer_state table on disconnect. On reconnect (same
meshId + memberId), restore them automatically — hello groups take
precedence over stored groups if provided. Broadcast peer_returned
system event with last-seen time and summary to other peers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-08 00:20:20 +01:00
parent e09671cdcb
commit fc8a7edc23
6 changed files with 315 additions and 17 deletions

View File

@@ -0,0 +1,16 @@
-- Peer session persistence: save state on disconnect, restore on reconnect.
CREATE TABLE IF NOT EXISTS mesh.peer_state (
id TEXT PRIMARY KEY NOT NULL,
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
member_id TEXT NOT NULL REFERENCES mesh.member(id) ON DELETE CASCADE ON UPDATE CASCADE,
groups JSONB DEFAULT '[]',
profile JSONB DEFAULT '{}',
visible BOOLEAN NOT NULL DEFAULT true,
last_summary TEXT,
last_display_name TEXT,
cumulative_stats JSONB DEFAULT '{"messagesIn":0,"messagesOut":0,"toolCalls":0,"errors":0}',
last_seen_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT peer_state_mesh_member_idx UNIQUE (mesh_id, member_id)
);

View File

@@ -731,6 +731,53 @@ export const insertMeshStreamSchema = createInsertSchema(meshStream);
export type SelectMeshStream = typeof meshStream.$inferSelect;
export type InsertMeshStream = typeof meshStream.$inferInsert;
/**
* Persisted peer session state. Survives disconnects — when a peer
* reconnects (same meshId + memberId), the broker restores groups,
* profile, visibility, summary, and cumulative stats automatically.
* Keyed by (meshId, memberId) — one row per member per mesh.
*/
export const peerState = meshSchema.table(
"peer_state",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
profile: jsonb().$type<{ avatar?: string; title?: string; bio?: string; capabilities?: string[] }>().default({}),
visible: boolean().notNull().default(true),
lastSummary: text(),
lastDisplayName: text(),
cumulativeStats: jsonb().$type<{ messagesIn: number; messagesOut: number; toolCalls: number; errors: number }>().default({ messagesIn: 0, messagesOut: 0, toolCalls: 0, errors: 0 }),
lastSeenAt: timestamp(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [
uniqueIndex("peer_state_mesh_member_idx").on(table.meshId, table.memberId),
],
);
export const peerStateRelations = relations(peerState, ({ one }) => ({
mesh: one(mesh, {
fields: [peerState.meshId],
references: [mesh.id],
}),
member: one(meshMember, {
fields: [peerState.memberId],
references: [meshMember.id],
}),
}));
export const selectPeerStateSchema = createSelectSchema(peerState);
export const insertPeerStateSchema = createInsertSchema(peerState);
export type SelectPeerState = typeof peerState.$inferSelect;
export type InsertPeerState = typeof peerState.$inferInsert;
export const meshSkillRelations = relations(meshSkill, ({ one }) => ({
mesh: one(mesh, {
fields: [meshSkill.meshId],