Merge origin/main into feat/graph-data-viz

Resolve conflicts:
- backend/src/server.ts: keep main's getPresence + closeRoom (room auto-cleanup)
  and add the graph imports/routes (additive).
- frontend/src/App.tsx: take main's shadcn command-center UI and re-integrate
  GraphView — import, graphPodId state, an early-return render branch, and a
  shadcn "Team memory" button next to Refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb-iam
2026-06-27 18:41:56 -07:00
113 changed files with 13878 additions and 493 deletions
+2 -1
View File
@@ -43,7 +43,8 @@ async function main() {
room.on(
RoomEvent.TrackSubscribed,
(track: RemoteTrack, pub: RemoteTrackPublication, participant: RemoteParticipant) => {
if (track.kind !== TrackKind.KIND_VIDEO || pub.source !== TrackSource.SOURCE_SCREENSHARE) return;
if (track.kind !== TrackKind.KIND_VIDEO || pub.source !== TrackSource.SOURCE_SCREENSHARE)
return;
const id = participant.identity;
const stream = new VideoStream(track);
void (async () => {
+11 -7
View File
@@ -26,9 +26,12 @@ export class PodMan {
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'GIT_REPORT') {
const c = this.contexts.get(msg.report.engineerId);
if (c) c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
if (c)
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
}
} catch { /* ignore malformed */ }
} catch {
/* ignore malformed */
}
});
}
@@ -58,7 +61,8 @@ export class PodMan {
await recordCollision(collision);
const action = preferredAction(collision, prior);
const names = collision.engineers.join(' and ');
const message = `${names} are both editing ${collision.file}` +
const message =
`${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior ? ` I've seen this conflict pattern before.` : '');
@@ -75,10 +79,10 @@ export class PodMan {
await recordIntervention(intervention);
const data: DataMessage = { type: 'COLLISION', collision, intervention };
await this.room.localParticipant?.publishData(
this.encoder.encode(JSON.stringify(data)),
{ reliable: true, topic: DATA_TOPIC },
);
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
await speak(this.room, message); // gemini-3.1-flash-live voice into the room
}
}
+1 -2
View File
@@ -21,8 +21,7 @@ export function detectCollisions(
const engineers = [...new Set(group.map((g) => g.engineerId))];
if (engineers.length < 2) continue;
const anyUnpushed =
group.some((g) => g.hasUnpushedChanges) || github.unpushed === true;
const anyUnpushed = group.some((g) => g.hasUnpushedChanges) || github.unpushed === true;
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
out.push({
+6 -1
View File
@@ -33,7 +33,12 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
const { owner, repo } = repoParts();
const { data: mainRef } = await gh.rest.git.getRef({ owner, repo, ref: 'heads/main' });
const branch = `podman-sync-${Date.now()}`;
await gh.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: mainRef.object.sha });
await gh.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha: mainRef.object.sha,
});
const { data: pr } = await gh.rest.pulls.create({
owner,
repo,
+13
View File
@@ -14,6 +14,19 @@ function svc(): RoomServiceClient {
return client;
}
/**
* Close a pod's LiveKit room, disconnecting everyone. Best-effort: a missing/
* already-empty room is fine. Called when a pod is deleted.
*/
export async function closeRoom(podId: string): Promise<void> {
if (!isConfigured()) return;
try {
await svc().deleteRoom(podId);
} catch (e) {
console.warn(`[livekit] closeRoom ${podId} failed: ${(e as Error).message}`);
}
}
/**
* Who is currently connected in each pod's LiveKit room, as display names,
* keyed by pod id (= room name). Empty rooms are omitted. Returns {} if
+2 -8
View File
@@ -4,10 +4,7 @@ import type { Collision, SuggestedActionKind } from '@podman/shared';
* Policy gate: decides whether PodMan should intervene.
* Stub: always intervene on warn/critical.
*/
export function shouldIntervene(
collision: Collision,
_prior: unknown,
): boolean {
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
return collision.severity !== 'info';
}
@@ -15,9 +12,6 @@ export function shouldIntervene(
* Preferred action selection based on collision + prior history.
* Stub: open sync PR for critical, ping teammate otherwise.
*/
export function preferredAction(
collision: Collision,
_prior: unknown,
): SuggestedActionKind {
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
}
+6 -2
View File
@@ -15,11 +15,15 @@ async function persist(name: string, fn: () => Promise<unknown>): Promise<void>
}
export async function recordObservation(ctx: EngineerContext): Promise<void> {
await persist('observation', async () => (await collections()).observations.insertOne({ ...ctx }));
await persist('observation', async () =>
(await collections()).observations.insertOne({ ...ctx }),
);
}
export async function recordCollision(collision: Collision): Promise<void> {
await persist('collision', async () => (await collections()).collisions.insertOne({ ...collision }));
await persist('collision', async () =>
(await collections()).collisions.insertOne({ ...collision }),
);
}
export async function recordIntervention(intervention: Intervention): Promise<void> {
+2 -1
View File
@@ -39,7 +39,8 @@ function cleanMembers(members: unknown): string[] {
for (const raw of members) {
if (typeof raw !== 'string') throw new Error('member names must be strings');
const name = raw.trim();
if (name.length > MAX_MEMBER_LEN) throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`);
if (name.length > MAX_MEMBER_LEN)
throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`);
const key = name.toLowerCase();
if (name && !seen.has(key)) {
seen.add(key);
+6 -2
View File
@@ -2,7 +2,7 @@ import express from 'express';
import cors from 'cors';
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
import { AccessToken } from 'livekit-server-sdk';
import { AccessToken, RoomConfiguration } from 'livekit-server-sdk';
import { env } from './env.js';
import { createSyncPr } from './github/client.js';
import { recordOutcome, memoryStats } from './memory/store.js';
@@ -17,7 +17,7 @@ import {
removeMember,
seedDefaultPods,
} from './pods/store.js';
import { getPresence } from './livekit/rooms.js';
import { getPresence, closeRoom } from './livekit/rooms.js';
import { loadPodGraph, reachFrom } from './graph/store.js';
import type { InterventionOutcome } from '@podman/shared';
@@ -37,6 +37,9 @@ app.post('/api/token', async (req, res) => {
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
});
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
// Auto-clean the room: close 60s after it empties, drop a participant 20s
// after they disconnect. Applied when LiveKit auto-creates the room.
at.roomConfig = new RoomConfiguration({ name: room, emptyTimeout: 60, departureTimeout: 20 });
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
});
@@ -119,6 +122,7 @@ app.patch('/api/pods/:id', async (req, res) => {
app.delete('/api/pods/:id', async (req, res) => {
const ok = await deletePod(req.params.id);
if (!ok) return res.status(404).json({ error: 'pod not found' });
await closeRoom(req.params.id); // end the live LiveKit room too (kicks anyone connected)
res.json({ ok: true });
});
+22 -5
View File
@@ -7,13 +7,28 @@ const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
const SCHEMA = {
type: Type.OBJECT,
properties: {
currentFile: { type: Type.STRING, description: 'open file path if visible, e.g. src/auth/session.ts' },
currentFile: {
type: Type.STRING,
description: 'open file path if visible, e.g. src/auth/session.ts',
},
currentSymbol: { type: Type.STRING, description: 'function/class under the cursor' },
activity: { type: Type.STRING, description: 'editing | reading | debugging | terminal | PR review' },
hasUnpushedChanges: { type: Type.BOOLEAN, description: 'dirty git gutter / modified markers visible' },
activity: {
type: Type.STRING,
description: 'editing | reading | debugging | terminal | PR review',
},
hasUnpushedChanges: {
type: Type.BOOLEAN,
description: 'dirty git gutter / modified markers visible',
},
confidence: { type: Type.NUMBER, description: '0..1 confidence in this read' },
},
propertyOrdering: ['currentFile', 'currentSymbol', 'activity', 'hasUnpushedChanges', 'confidence'],
propertyOrdering: [
'currentFile',
'currentSymbol',
'activity',
'hasUnpushedChanges',
'confidence',
],
} as const;
export async function analyzeFrame(
@@ -27,7 +42,9 @@ export async function analyzeFrame(
{
role: 'user',
parts: [
{ text: "You are PodMan watching an engineer's screen. Identify what file/symbol they are working on and whether there are uncommitted edits. JSON only." },
{
text: "You are PodMan watching an engineer's screen. Identify what file/symbol they are working on and whether there are uncommitted edits. JSON only.",
},
{ inlineData: { mimeType: 'image/jpeg', data: jpeg.toString('base64') } },
],
},