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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<MongoClient> | null = null;
|
||||
@@ -19,6 +25,7 @@ export async function getDb(): Promise<Db> {
|
||||
}
|
||||
|
||||
export interface PodCollections {
|
||||
pods: Collection<Pod>;
|
||||
observations: Collection<EngineerContext>;
|
||||
collisions: Collection<Collision>;
|
||||
interventions: Collection<Intervention>;
|
||||
@@ -28,6 +35,7 @@ export interface PodCollections {
|
||||
export async function collections(): Promise<PodCollections> {
|
||||
const db = await getDb();
|
||||
return {
|
||||
pods: db.collection<Pod>('pods'),
|
||||
observations: db.collection<EngineerContext>('observations'),
|
||||
collisions: db.collection<Collision>('collisions'),
|
||||
interventions: db.collection<Intervention>('interventions'),
|
||||
@@ -41,6 +49,7 @@ export async function initMemory(): Promise<void> {
|
||||
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 }),
|
||||
|
||||
@@ -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<string>();
|
||||
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<Pod[]> {
|
||||
const c = await collections();
|
||||
return c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray();
|
||||
}
|
||||
|
||||
export async function getPod(id: string): Promise<Pod | null> {
|
||||
const c = await collections();
|
||||
return c.pods.findOne({ id }, NO_ID);
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string): Promise<string> {
|
||||
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<Pod> {
|
||||
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<Pod | null> {
|
||||
const set: Partial<Pod> = { 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<boolean> {
|
||||
const c = await collections();
|
||||
const r = await c.pods.deleteOne({ id });
|
||||
return r.deletedCount > 0;
|
||||
}
|
||||
|
||||
async function setMembers(id: string, members: string[]): Promise<Pod | null> {
|
||||
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<Pod | null> {
|
||||
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<Pod | null> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
+68
-1
@@ -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}`));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user