From 5a03ad4d6bd04f0b8b3f262d9e27d7fc43a2f6d1 Mon Sep 17 00:00:00 2001 From: Kartikeya <176560021+karti-ai@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:48:55 -0700 Subject: [PATCH] feat: Mongo-backed Pods with full CRUD + member management Pods are persisted in MongoDB and CRUD'd via /api/pods (list/create/get/ update/delete + add/remove member). Shared Pod reshaped to a persisted entity (members: string[], description, timestamps) + PodInput. Backend seeds Demo/ Frontend/Backend Pods on first run (Squad -> Pod rename). Frontend replaces hardcoded teams with live data: PodCard (member chips add/remove, inline edit, delete, join) + CreatePodForm; App fetches+mutates via API. Removes teams.ts/ TeamCard; gitignores test artifacts. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + backend/src/memory/db.ts | 11 +- backend/src/pods/store.ts | 161 ++++++++++++++++++ backend/src/server.ts | 69 +++++++- frontend/src/App.tsx | 171 ++++++++++++------- frontend/src/components/CreatePodForm.tsx | 87 ++++++++++ frontend/src/components/PodCard.tsx | 190 ++++++++++++++++++++++ frontend/src/components/PodView.tsx | 4 +- frontend/src/components/TeamCard.tsx | 37 ----- frontend/src/lib/api.ts | 65 +++++++- frontend/src/lib/teams.ts | 39 ----- shared/src/index.ts | 2 +- shared/src/pod.ts | 19 ++- 13 files changed, 708 insertions(+), 148 deletions(-) create mode 100644 backend/src/pods/store.ts create mode 100644 frontend/src/components/CreatePodForm.tsx create mode 100644 frontend/src/components/PodCard.tsx delete mode 100644 frontend/src/components/TeamCard.tsx delete mode 100644 frontend/src/lib/teams.ts diff --git a/.gitignore b/.gitignore index 4faf4e5..a1d7cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ coverage/ # Ramis .remember/ .claude/ +.playwright-mcp/ diff --git a/backend/src/memory/db.ts b/backend/src/memory/db.ts index 8da2414..4513a18 100644 --- a/backend/src/memory/db.ts +++ b/backend/src/memory/db.ts @@ -1,5 +1,11 @@ import { MongoClient, type Db, type Collection } from 'mongodb'; -import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared'; +import type { + EngineerContext, + Collision, + Intervention, + InterventionOutcome, + Pod, +} from '@podman/shared'; import { env } from '../env.js'; let clientPromise: Promise | null = null; @@ -19,6 +25,7 @@ export async function getDb(): Promise { } export interface PodCollections { + pods: Collection; observations: Collection; collisions: Collection; interventions: Collection; @@ -28,6 +35,7 @@ export interface PodCollections { export async function collections(): Promise { const db = await getDb(); return { + pods: db.collection('pods'), observations: db.collection('observations'), collisions: db.collection('collisions'), interventions: db.collection('interventions'), @@ -41,6 +49,7 @@ export async function initMemory(): Promise { await db.command({ ping: 1 }); const c = await collections(); await Promise.all([ + c.pods.createIndex({ id: 1 }, { unique: true }), c.observations.createIndex({ podId: 1, observedAt: -1 }), c.observations.createIndex({ engineerId: 1 }), c.collisions.createIndex({ podId: 1, detectedAt: -1 }), diff --git a/backend/src/pods/store.ts b/backend/src/pods/store.ts new file mode 100644 index 0000000..4694c48 --- /dev/null +++ b/backend/src/pods/store.ts @@ -0,0 +1,161 @@ +import type { Pod, PodInput } from '@podman/shared'; +import { collections } from '../memory/db.js'; + +const NO_ID = { projection: { _id: 0 } } as const; + +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** Trim, drop empties, de-dupe case-insensitively while preserving order. */ +function cleanMembers(members: unknown): string[] { + if (!Array.isArray(members)) return []; + const seen = new Set(); + const out: string[] = []; + for (const raw of members) { + const name = String(raw).trim(); + const key = name.toLowerCase(); + if (name && !seen.has(key)) { + seen.add(key); + out.push(name); + } + } + return out; +} + +function now(): string { + return new Date().toISOString(); +} + +export async function listPods(): Promise { + const c = await collections(); + return c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray(); +} + +export async function getPod(id: string): Promise { + const c = await collections(); + return c.pods.findOne({ id }, NO_ID); +} + +async function uniqueSlug(base: string): Promise { + const c = await collections(); + const root = base || 'pod'; + let slug = root; + let n = 2; + while (await c.pods.findOne({ id: slug })) slug = `${root}-${n++}`; + return slug; +} + +export async function createPod(input: PodInput): Promise { + const name = (input.name ?? '').trim(); + if (!name) throw new Error('name is required'); + const c = await collections(); + const ts = now(); + const pod: Pod = { + id: await uniqueSlug(slugify(name)), + name, + repo: (input.repo ?? '').trim(), + description: input.description?.trim() || undefined, + members: cleanMembers(input.members), + createdAt: ts, + updatedAt: ts, + }; + await c.pods.insertOne({ ...pod }); + return pod; +} + +export async function updatePod(id: string, patch: PodInput): Promise { + const set: Partial = { updatedAt: now() }; + if (patch.name !== undefined) { + const name = patch.name.trim(); + if (!name) throw new Error('name cannot be empty'); + set.name = name; + } + if (patch.repo !== undefined) set.repo = patch.repo.trim(); + if (patch.description !== undefined) set.description = patch.description.trim() || undefined; + if (patch.members !== undefined) set.members = cleanMembers(patch.members); + const c = await collections(); + const updated = await c.pods.findOneAndUpdate( + { id }, + { $set: set }, + { returnDocument: 'after', projection: { _id: 0 } }, + ); + return updated ?? null; +} + +export async function deletePod(id: string): Promise { + const c = await collections(); + const r = await c.pods.deleteOne({ id }); + return r.deletedCount > 0; +} + +async function setMembers(id: string, members: string[]): Promise { + const c = await collections(); + const updated = await c.pods.findOneAndUpdate( + { id }, + { $set: { members, updatedAt: now() } }, + { returnDocument: 'after', projection: { _id: 0 } }, + ); + return updated ?? null; +} + +export async function addMember(id: string, rawName: string): Promise { + const name = rawName.trim(); + if (!name) throw new Error('member name is required'); + const pod = await getPod(id); + if (!pod) return null; + if (pod.members.some((m) => m.toLowerCase() === name.toLowerCase())) return pod; + return setMembers(id, [...pod.members, name]); +} + +export async function removeMember(id: string, rawName: string): Promise { + const name = rawName.trim().toLowerCase(); + const pod = await getPod(id); + if (!pod) return null; + return setMembers( + id, + pod.members.filter((m) => m.toLowerCase() !== name), + ); +} + +/** Insert the default pods once, if the collection is empty. */ +export async function seedDefaultPods(): Promise { + const c = await collections(); + if ((await c.pods.estimatedDocumentCount()) > 0) return; + const ts = now(); + const defaults: Pod[] = [ + { + id: 'demo-pod', + name: 'Demo Pod', + repo: 'karti-ai/podman', + description: 'The full crew — used for the live demo.', + members: ['Karti', 'Yahya', 'Ramis', 'Zander', 'Shakthi'], + createdAt: ts, + updatedAt: ts, + }, + { + id: 'frontend-pod', + name: 'Frontend Pod', + repo: 'karti-ai/podman', + description: 'PWA, capture, and PodMan card UI.', + members: ['Karti', 'Zander'], + createdAt: ts, + updatedAt: ts, + }, + { + id: 'backend-pod', + name: 'Backend Pod', + repo: 'karti-ai/podman', + description: 'LiveKit agent, vision, collision detector.', + members: ['Yahya', 'Ramis'], + createdAt: ts, + updatedAt: ts, + }, + ]; + await c.pods.insertMany(defaults.map((p) => ({ ...p }))); + console.log('[pods] seeded default pods'); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 11c79f6..ab9c8e8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,16 @@ import { env } from './env.js'; import { createSyncPr } from './github/client.js'; import { recordOutcome, memoryStats } from './memory/store.js'; import { initMemory } from './memory/db.js'; +import { + listPods, + getPod, + createPod, + updatePod, + deletePod, + addMember, + removeMember, + seedDefaultPods, +} from './pods/store.js'; import type { InterventionOutcome } from '@podman/shared'; const app = express(); @@ -54,6 +64,61 @@ app.get('/api/memory/stats', async (_req, res) => { } }); +// --- Pods CRUD (Mongo-backed) --- +app.get('/api/pods', async (_req, res) => { + try { + res.json(await listPods()); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } +}); + +app.post('/api/pods', async (req, res) => { + try { + res.status(201).json(await createPod(req.body ?? {})); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +app.get('/api/pods/:id', async (req, res) => { + const pod = await getPod(req.params.id); + if (!pod) return res.status(404).json({ error: 'pod not found' }); + res.json(pod); +}); + +app.patch('/api/pods/:id', async (req, res) => { + try { + const pod = await updatePod(req.params.id, req.body ?? {}); + if (!pod) return res.status(404).json({ error: 'pod not found' }); + res.json(pod); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +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' }); + res.json({ ok: true }); +}); + +app.post('/api/pods/:id/members', async (req, res) => { + try { + const pod = await addMember(req.params.id, req.body?.name ?? ''); + if (!pod) return res.status(404).json({ error: 'pod not found' }); + res.json(pod); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +app.delete('/api/pods/:id/members/:name', async (req, res) => { + const pod = await removeMember(req.params.id, req.params.name); + if (!pod) return res.status(404).json({ error: 'pod not found' }); + res.json(pod); +}); + const http = createServer(app); // ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it. @@ -70,5 +135,7 @@ wss.on('connection', (ws) => { http.listen(env.PORT, '0.0.0.0', () => { console.log(`[server] :${env.PORT}`); - initMemory().catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`)); + initMemory() + .then(() => seedDefaultPods()) + .catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`)); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e8fecd..8f2cada 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,50 +1,97 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { Room } from 'livekit-client'; +import type { Pod, PodInput } from '@podman/shared'; import { joinPod } from './lib/pod.js'; -import { TEAMS, teamById } from './lib/teams.js'; -import { TeamCard } from './components/TeamCard.js'; +import * as api from './lib/api.js'; +import { PodCard } from './components/PodCard.js'; +import { CreatePodForm } from './components/CreatePodForm.js'; import { PodView } from './components/PodView.js'; export default function App() { - const [teamId, setTeamId] = useState(TEAMS[0]!.id); - const team = useMemo(() => teamById(teamId), [teamId]); - const [member, setMember] = useState(team.members[0]!); - const [room, setRoom] = useState(null); - const [joined, setJoined] = useState(false); - const [devMode, setDevMode] = useState(false); + const [pods, setPods] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [connecting, setConnecting] = useState(false); - function handleTeamChange(id: string) { - setTeamId(id); - const next = teamById(id); - if (!next.members.includes(member)) setMember(next.members[0]!); + // join state + const [joinedPod, setJoinedPod] = useState(null); + const [member, setMember] = useState(''); + const [devMode, setDevMode] = useState(false); + const [room, setRoom] = useState(null); + + async function refresh() { + setLoading(true); + try { + setPods(await api.listPods()); + setError(null); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } } - async function handleJoin() { + useEffect(() => { + void refresh(); + }, []); + + /** Run a mutation, reflect the result in local state, surface errors. */ + async function mutate(fn: () => Promise) { + setBusy(true); setError(null); - setConnecting(true); try { - const identity = `${member}-${Math.random().toString(36).slice(2, 7)}`; - const result = await joinPod(team.id, identity, member); + await fn(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + const upsert = (p: Pod) => setPods((cur) => cur.map((x) => (x.id === p.id ? p : x))); + + const handleCreate = (input: PodInput) => + mutate(async () => { + const created = await api.createPod(input); + setPods((cur) => [...cur, created]); + }); + const handleUpdate = (id: string, patch: PodInput) => + mutate(async () => upsert(await api.updatePod(id, patch))); + const handleDelete = (id: string) => + mutate(async () => { + await api.deletePod(id); + setPods((cur) => cur.filter((x) => x.id !== id)); + }); + const handleAddMember = (id: string, name: string) => + mutate(async () => upsert(await api.addMember(id, name))); + const handleRemoveMember = (id: string, name: string) => + mutate(async () => upsert(await api.removeMember(id, name))); + + async function handleJoin(pod: Pod, who: string) { + setBusy(true); + setError(null); + try { + const identity = `${who}-${Math.random().toString(36).slice(2, 7)}`; + const result = await joinPod(pod.id, identity, who); setRoom(result.room); setDevMode(result.mode === 'dev'); - setJoined(true); - } catch (err) { - setError((err as Error).message); + setMember(who); + setJoinedPod(pod); + } catch (e) { + setError((e as Error).message); } finally { - setConnecting(false); + setBusy(false); } } function handleLeave() { room?.disconnect(); setRoom(null); - setJoined(false); + setJoinedPod(null); } return ( -
+
🛰️
@@ -55,48 +102,48 @@ export default function App() {
- {joined ? ( - + {joinedPod ? ( + ) : ( -
-
-

Pick a team

-
- {TEAMS.map((t) => ( - +
+

+ Pods {loading ? '…' : `(${pods.length})`} +

+ +
+ + {error && ( +

+ {error} +

+ )} + + {loading ? ( +

Loading pods…

+ ) : ( +
+ {pods.map((pod) => ( + ))} +
-
- -
- - -
- {error &&

{error}

} + )}
)}
diff --git a/frontend/src/components/CreatePodForm.tsx b/frontend/src/components/CreatePodForm.tsx new file mode 100644 index 0000000..de142ac --- /dev/null +++ b/frontend/src/components/CreatePodForm.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { PodInput } from '@podman/shared'; + +export function CreatePodForm({ + busy, + onCreate, +}: { + busy: boolean; + onCreate: (input: PodInput) => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [repo, setRepo] = useState('karti-ai/podman'); + const [description, setDescription] = useState(''); + const [firstMember, setFirstMember] = useState(''); + + function submit() { + if (!name.trim()) return; + onCreate({ + name: name.trim(), + repo: repo.trim(), + description: description.trim(), + members: firstMember.trim() ? [firstMember.trim()] : [], + }); + setName(''); + setDescription(''); + setFirstMember(''); + setOpen(false); + } + + if (!open) { + return ( + + ); + } + + return ( +
+

New Pod

+ setName(e.target.value)} + /> + setRepo(e.target.value)} + /> + setDescription(e.target.value)} + /> + setFirstMember(e.target.value)} + /> +
+ + +
+
+ ); +} diff --git a/frontend/src/components/PodCard.tsx b/frontend/src/components/PodCard.tsx new file mode 100644 index 0000000..8aba2bd --- /dev/null +++ b/frontend/src/components/PodCard.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import type { Pod, PodInput } from '@podman/shared'; +import { Avatar } from './Avatar.js'; + +export function PodCard({ + pod, + busy, + onJoin, + onAddMember, + onRemoveMember, + onUpdate, + onDelete, +}: { + pod: Pod; + busy: boolean; + onJoin: (pod: Pod, member: string) => void; + onAddMember: (id: string, name: string) => void; + onRemoveMember: (id: string, name: string) => void; + onUpdate: (id: string, patch: PodInput) => void; + onDelete: (id: string) => void; +}) { + const [newMember, setNewMember] = useState(''); + const [joinAs, setJoinAs] = useState(pod.members[0] ?? ''); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState({ + name: pod.name, + repo: pod.repo, + description: pod.description ?? '', + }); + + // keep joinAs valid as members change + const joinMember = pod.members.includes(joinAs) ? joinAs : (pod.members[0] ?? ''); + + function add() { + const name = newMember.trim(); + if (!name) return; + onAddMember(pod.id, name); + setNewMember(''); + } + + function saveEdit() { + onUpdate(pod.id, { + name: draft.name?.trim(), + repo: draft.repo?.trim(), + description: draft.description?.trim(), + }); + setEditing(false); + } + + return ( +
+ {editing ? ( +
+ setDraft({ ...draft, name: e.target.value })} + /> + setDraft({ ...draft, repo: e.target.value })} + /> + setDraft({ ...draft, description: e.target.value })} + /> +
+ + +
+
+ ) : ( +
+
+

{pod.name}

+

{pod.repo || 'no repo set'}

+ {pod.description &&

{pod.description}

} +
+
+ + +
+
+ )} + + {/* Members */} +
+
+ {pod.members.length === 0 && ( + No members yet. + )} + {pod.members.map((m) => ( + + + {m} + + + ))} +
+
+ setNewMember(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + +
+
+ + {/* Join */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index d885cb9..ef15df3 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -1,4 +1,4 @@ -import type { DemoTeam } from '../lib/teams.js'; +import type { Pod } from '@podman/shared'; import { Avatar } from './Avatar.js'; export function PodView({ @@ -7,7 +7,7 @@ export function PodView({ devMode, onLeave, }: { - team: DemoTeam; + team: Pod; me: string; devMode: boolean; onLeave: () => void; diff --git a/frontend/src/components/TeamCard.tsx b/frontend/src/components/TeamCard.tsx deleted file mode 100644 index e948ca5..0000000 --- a/frontend/src/components/TeamCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { DemoTeam } from '../lib/teams.js'; -import { Avatar } from './Avatar.js'; - -export function TeamCard({ - team, - selected, - onSelect, -}: { - team: DemoTeam; - selected: boolean; - onSelect: (id: string) => void; -}) { - return ( - - ); -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e892365..50f4af0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,15 @@ -import type { InterventionOutcome } from '@podman/shared'; +import type { InterventionOutcome, Pod, PodInput } from '@podman/shared'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787'; +async function json(res: Response): Promise { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `request failed: ${res.status}`); + } + return res.json() as Promise; +} + /** Mint a LiveKit token from the backend. */ export async function fetchToken(params: { room: string; @@ -14,8 +22,7 @@ export async function fetchToken(params: { headers: { 'content-type': 'application/json' }, body: JSON.stringify(params), }); - if (!res.ok) throw new Error(`token request failed: ${res.status}`); - return res.json() as Promise<{ token: string; url: string }>; + return json(res); } /** Record an intervention outcome for the policy learning loop. */ @@ -27,3 +34,55 @@ export async function postOutcome(outcome: InterventionOutcome): Promise { }); if (!res.ok) throw new Error(`outcome post failed: ${res.status}`); } + +// --- Pods CRUD --- + +export async function listPods(): Promise { + return json(await fetch(`${BACKEND_URL}/api/pods`)); +} + +export async function createPod(input: PodInput): Promise { + return json( + await fetch(`${BACKEND_URL}/api/pods`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }), + ); +} + +export async function updatePod(id: string, patch: PodInput): Promise { + return json( + await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }), + ); +} + +export async function deletePod(id: string): Promise { + const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(`delete pod failed: ${res.status}`); +} + +export async function addMember(id: string, name: string): Promise { + return json( + await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name }), + }), + ); +} + +export async function removeMember(id: string, name: string): Promise { + return json( + await fetch( + `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`, + { method: 'DELETE' }, + ), + ); +} diff --git a/frontend/src/lib/teams.ts b/frontend/src/lib/teams.ts deleted file mode 100644 index 02749b3..0000000 --- a/frontend/src/lib/teams.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Hardcoded teams/engineers for R&D so the join screen is select-only (no typing) - * while we develop. Swap for real data (backend/Atlas) once pods are persisted. - */ -export interface DemoTeam { - id: string; - name: string; - repo: string; - description: string; - members: string[]; -} - -export const TEAMS: DemoTeam[] = [ - { - id: 'demo-pod', - name: 'Demo Pod', - repo: 'karti-ai/podman', - description: 'The full crew — used for the live demo.', - members: ['Karti', 'Yahya', 'Ramis', 'Shakthi'], - }, - { - id: 'frontend-squad', - name: 'Frontend Squad', - repo: 'karti-ai/podman', - description: 'PWA, capture, and PodMan card UI.', - members: ['Karti', 'Ramis'], - }, - { - id: 'backend-squad', - name: 'Backend Squad', - repo: 'karti-ai/podman', - description: 'LiveKit agent, vision, collision detector.', - members: ['Yahya', 'Ramis'], - }, -]; - -export function teamById(id: string): DemoTeam { - return TEAMS.find((t) => t.id === id) ?? TEAMS[0]!; -} diff --git a/shared/src/index.ts b/shared/src/index.ts index e2aef1c..9353955 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,4 +1,4 @@ -export type { Pod, Engineer } from './pod.js'; +export type { Pod, PodInput, Engineer } from './pod.js'; export type { EngineerContext } from './engineer.js'; export type { Collision, CollisionSeverity, GithubStateSnapshot } from './collision.js'; export type { diff --git a/shared/src/pod.ts b/shared/src/pod.ts index 6b25b2b..1070b15 100644 --- a/shared/src/pod.ts +++ b/shared/src/pod.ts @@ -1,11 +1,26 @@ -/** A pod is one room of engineers working together (one LiveKit room per pod). */ +/** + * A pod is one room of engineers working together (one LiveKit room per pod). + * Persisted in MongoDB; CRUD'd via /api/pods. + */ export interface Pod { + /** URL-safe slug, e.g. "frontend-pod". Stable id used everywhere. */ id: string; name: string; /** GitHub repo the pod is working in, as "owner/name". */ repo: string; - members: Engineer[]; + description?: string; + /** Engineer display names PodMan uses when it speaks. */ + members: string[]; createdAt: string; + updatedAt: string; +} + +/** Fields accepted when creating or updating a pod. */ +export interface PodInput { + name?: string; + repo?: string; + description?: string; + members?: string[]; } export interface Engineer {