454fdab13b
Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
8.7 KiB
TypeScript
258 lines
8.7 KiB
TypeScript
import type { Pod, PodInput } from '@podman/shared';
|
|
import { collections, getDb, type UserPodContextDoc } from '../memory/db.js';
|
|
|
|
const NO_ID = { projection: { _id: 0 } } as const;
|
|
|
|
const MAX_NAME = 120;
|
|
const MAX_REPO = 140;
|
|
const MAX_DESCRIPTION = 2000;
|
|
const MAX_MEMBERS = 50;
|
|
const MAX_MEMBER_LEN = 80;
|
|
|
|
function slugify(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
/** Validate + normalize a string field. Throws (-> 400) on type/length errors. */
|
|
function str(value: unknown, field: string, max: number, required = false): string | undefined {
|
|
if (value === undefined || value === null) {
|
|
if (required) throw new Error(`${field} is required`);
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string') throw new Error(`${field} must be a string`);
|
|
const trimmed = value.trim();
|
|
if (required && !trimmed) throw new Error(`${field} is required`);
|
|
if (trimmed.length > max) throw new Error(`${field} too long (max ${max})`);
|
|
return trimmed;
|
|
}
|
|
|
|
/** Validate, trim, length-cap, de-dupe (case-insensitive), and limit count. */
|
|
function cleanMembers(members: unknown): string[] {
|
|
if (members === undefined || members === null) return [];
|
|
if (!Array.isArray(members)) throw new Error('members must be an array');
|
|
const seen = new Set<string>();
|
|
const out: 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})`);
|
|
const key = name.toLowerCase();
|
|
if (name && !seen.has(key)) {
|
|
seen.add(key);
|
|
out.push(name);
|
|
}
|
|
}
|
|
if (out.length > MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`);
|
|
return out;
|
|
}
|
|
|
|
function isDuplicateKey(err: unknown): boolean {
|
|
return (err as { code?: number })?.code === 11000;
|
|
}
|
|
|
|
function now(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function regexExact(value: string): RegExp {
|
|
return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i');
|
|
}
|
|
|
|
function profileString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function matchesMember(doc: UserPodContextDoc, member: string): boolean {
|
|
const key = member.trim().toLowerCase();
|
|
return [doc.memberName, doc.metadata?.identity, doc.metadata?.email]
|
|
.filter(Boolean)
|
|
.some((value) => String(value).trim().toLowerCase() === key);
|
|
}
|
|
|
|
async function hydrateMemberProfiles(pod: Pod): Promise<Pod> {
|
|
if (!pod.members.length) return pod;
|
|
const db = await getDb();
|
|
const matchers = pod.members.map(regexExact);
|
|
const docs = await db
|
|
.collection<UserPodContextDoc>('user_pod_context')
|
|
.find(
|
|
{
|
|
$or: [
|
|
{ memberName: { $in: matchers } },
|
|
{ 'metadata.identity': { $in: matchers } },
|
|
{ 'metadata.email': { $in: matchers } },
|
|
],
|
|
},
|
|
{ projection: { _id: 0 } },
|
|
)
|
|
.sort({ observedAt: -1 })
|
|
.limit(500)
|
|
.toArray();
|
|
const memberProfiles: NonNullable<Pod['memberProfiles']> = {};
|
|
for (const member of pod.members) {
|
|
const doc = docs.find((row) => matchesMember(row, member));
|
|
const imageUrl = profileString(doc?.metadata?.imageUrl);
|
|
if (!doc || !imageUrl) continue;
|
|
memberProfiles[member] = {
|
|
displayName: doc.memberName ?? member,
|
|
email: profileString(doc.metadata?.email),
|
|
imageUrl,
|
|
};
|
|
}
|
|
return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod;
|
|
}
|
|
|
|
async function hydratePods(pods: Pod[]): Promise<Pod[]> {
|
|
return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod)));
|
|
}
|
|
|
|
export async function listPods(): Promise<Pod[]> {
|
|
const c = await collections();
|
|
return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray());
|
|
}
|
|
|
|
export async function getPod(id: string): Promise<Pod | null> {
|
|
const c = await collections();
|
|
const pod = await c.pods.findOne({ id }, NO_ID);
|
|
return pod ? hydrateMemberProfiles(pod) : null;
|
|
}
|
|
|
|
export async function createPod(input: PodInput): Promise<Pod> {
|
|
const name = str(input.name, 'name', MAX_NAME, true)!;
|
|
const repo = str(input.repo, 'repo', MAX_REPO) ?? '';
|
|
const description = str(input.description, 'description', MAX_DESCRIPTION);
|
|
const members = cleanMembers(input.members);
|
|
const c = await collections();
|
|
const ts = now();
|
|
const base = slugify(name) || 'pod';
|
|
|
|
// Atomic create: let the unique index arbitrate slug collisions. On a
|
|
// duplicate-key error, bump the suffix and retry — no check-then-insert race.
|
|
for (let suffix = 1; suffix <= MAX_MEMBERS + 50; suffix++) {
|
|
const pod: Pod = {
|
|
id: suffix === 1 ? base : `${base}-${suffix}`,
|
|
name,
|
|
repo,
|
|
description: description || undefined,
|
|
members,
|
|
createdAt: ts,
|
|
updatedAt: ts,
|
|
};
|
|
try {
|
|
await c.pods.insertOne({ ...pod });
|
|
return hydrateMemberProfiles(pod);
|
|
} catch (err) {
|
|
if (isDuplicateKey(err)) continue;
|
|
throw err;
|
|
}
|
|
}
|
|
throw new Error('could not allocate a unique pod id');
|
|
}
|
|
|
|
export async function updatePod(id: string, patch: PodInput): Promise<Pod | null> {
|
|
const set: Partial<Pod> = { updatedAt: now() };
|
|
if (patch.name !== undefined) {
|
|
const name = str(patch.name, 'name', MAX_NAME);
|
|
if (!name) throw new Error('name cannot be empty');
|
|
set.name = name;
|
|
}
|
|
if (patch.repo !== undefined) set.repo = str(patch.repo, 'repo', MAX_REPO) ?? '';
|
|
if (patch.description !== undefined) {
|
|
set.description = str(patch.description, 'description', MAX_DESCRIPTION) || 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 ? hydrateMemberProfiles(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 ? hydrateMemberProfiles(updated) : null;
|
|
}
|
|
|
|
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
|
if (typeof rawName !== 'string') throw new Error('member name must be a string');
|
|
const name = rawName.trim();
|
|
if (!name) throw new Error('member name is required');
|
|
if (name.length > MAX_MEMBER_LEN) throw new Error(`member name too long (max ${MAX_MEMBER_LEN})`);
|
|
const pod = await getPod(id);
|
|
if (!pod) return null;
|
|
if (pod.members.some((m) => m.toLowerCase() === name.toLowerCase())) return pod;
|
|
if (pod.members.length >= MAX_MEMBERS) throw new Error(`too many members (max ${MAX_MEMBERS})`);
|
|
return setMembers(id, [...pod.members, name]);
|
|
}
|
|
|
|
export async function removeMember(id: string, rawName: string): Promise<Pod | null> {
|
|
const name = String(rawName).trim().toLowerCase();
|
|
const pod = await getPod(id);
|
|
if (!pod) return null;
|
|
const filtered = pod.members.filter((m) => m.toLowerCase() !== name);
|
|
if (filtered.length === pod.members.length) return pod; // no-op: don't write / bump updatedAt
|
|
return setMembers(id, filtered);
|
|
}
|
|
|
|
/**
|
|
* Seed the default pods into a fresh DB. Idempotent and race-safe: gated on an
|
|
* empty collection, and uses per-doc upserts so concurrent startups can't
|
|
* create duplicates. Won't resurrect a default a user later deletes.
|
|
*/
|
|
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 Promise.all(
|
|
defaults.map((p) => c.pods.updateOne({ id: p.id }, { $setOnInsert: p }, { upsert: true })),
|
|
);
|
|
console.log('[pods] seeded default pods');
|
|
}
|