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:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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') } },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user