diff --git a/.env.example b/.env.example index 9591551..6ba3b25 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,18 @@ GEMINI_API_KEY= GEMINI_VISION_MODEL=gemini-2.0-flash GEMINI_LIVE_MODEL=gemini-live-2.5-flash +# --- GitHub (repo state + sync PR artifacts) --- +GITHUB_TOKEN= +GITHUB_REPO=owner/name + # --- MongoDB Atlas (engineer state, ownership map, events, nudges) --- MONGODB_URI=mongodb+srv://:@cluster.mongodb.net/podman +VOYAGE_API_KEY= +VOYAGE_EMBEDDING_MODEL=voyage-4-lite # --- Backend server --- PORT=8787 +POD_ROOM=demo-pod # --- Nudge cooldown (ms) — set to 0 during demo if needed --- NUDGE_COOLDOWN_MS=180000 @@ -22,3 +29,7 @@ NUDGE_COOLDOWN_MS=180000 # --- Frontend (Vite — must be VITE_ prefixed to reach the client) --- VITE_LIVEKIT_URL=wss://your-project.livekit.cloud VITE_BACKEND_URL=http://localhost:8787 + +# --- Deployment verification --- +# Optional override when the deployed SPA and API use different origins. +FRONTEND_URL=http://localhost:4173 diff --git a/.prettierignore b/.prettierignore index 22904a3..ad7b68e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ build pnpm-lock.yaml .omc *.log +.agents diff --git a/backend/package.json b/backend/package.json index 48c511d..3cd759e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,13 +3,16 @@ "version": "0.0.0", "private": true, "type": "module", - "main": "./dist/index.js", + "main": "./dist/server.js", "scripts": { - "dev": "tsx watch src/server.ts", + "dev": "pnpm run dev:server", "dev:server": "tsx watch src/server.ts", "dev:agent": "tsx watch src/agent.ts", "graph:seed": "tsx src/graph/seed.ts", "start": "node dist/server.js", + "start:server": "node dist/server.js", + "start:agent": "node dist/agent.js", + "start:all": "node dist/hermes.js", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/backend/src/agent.ts b/backend/src/agent.ts index cd2e6e6..2062744 100644 --- a/backend/src/agent.ts +++ b/backend/src/agent.ts @@ -16,11 +16,12 @@ import { env } from './env.js'; import { PodMan } from './agent/podman.js'; const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod'; +const HERMES_IDENTITY = 'podman-hermes'; const SAMPLE_INTERVAL_MS = 1000; // ~1 fps to the vision model async function agentToken(room: string): Promise { const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, { - identity: 'podman-agent', + identity: HERMES_IDENTITY, name: 'PodMan', ttl: '4h', }); @@ -36,7 +37,7 @@ async function main() { dynacast: true, }); await podman.start(); - console.log(`[agent] PodMan joined room ${POD_ROOM}`); + console.log(`[agent] ${HERMES_IDENTITY} joined room ${POD_ROOM}`); const lastSent = new Map(); diff --git a/backend/src/env.ts b/backend/src/env.ts index 4388857..670d9cb 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -16,14 +16,15 @@ export const env = { LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'), // Gemini GEMINI_API_KEY: req('GEMINI_API_KEY'), - GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-3.5-flash'), - GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-live-preview'), + GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'), + GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'), // GitHub GITHUB_TOKEN: req('GITHUB_TOKEN'), GITHUB_REPO: req('GITHUB_REPO'), // owner/name // Mongo + Voyage MONGODB_URI: req('MONGODB_URI'), VOYAGE_API_KEY: opt('VOYAGE_API_KEY'), + VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'), // Server PORT: Number(opt('PORT', '8787')), } as const; diff --git a/backend/src/hermes.ts b/backend/src/hermes.ts new file mode 100644 index 0000000..32b6aa3 --- /dev/null +++ b/backend/src/hermes.ts @@ -0,0 +1,50 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const processMode = process.env.PODMAN_PROCESS ?? 'all'; +type ProcessMode = 'server' | 'agent'; + +function commandFor(mode: ProcessMode): string[] { + return [join(here, `${mode}.js`)]; +} + +const modes: ProcessMode[] = + processMode === 'server' || processMode === 'agent' ? [processMode] : ['server', 'agent']; + +const children: ChildProcess[] = modes.map((mode) => + spawn(process.execPath, commandFor(mode), { stdio: 'inherit' }), +); + +let shuttingDown = false; + +function stopAll(signal: NodeJS.Signals = 'SIGTERM') { + for (const child of children) { + if (!child.killed) child.kill(signal); + } +} + +for (const child of children) { + child.on('exit', (code, signal) => { + if (shuttingDown) return; + shuttingDown = true; + stopAll(); + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +process.on('SIGINT', () => { + shuttingDown = true; + stopAll('SIGINT'); +}); + +process.on('SIGTERM', () => { + shuttingDown = true; + stopAll('SIGTERM'); +}); diff --git a/backend/src/memory/db.ts b/backend/src/memory/db.ts index 1902fb0..1094dd5 100644 --- a/backend/src/memory/db.ts +++ b/backend/src/memory/db.ts @@ -24,6 +24,13 @@ export async function getDb(): Promise { return client.db(); } +export async function closeMemory(): Promise { + if (!clientPromise) return; + const client = await clientPromise; + clientPromise = null; + await client.close(); +} + export interface PodCollections { pods: Collection; observations: Collection; @@ -88,6 +95,10 @@ export async function initMemory(): Promise { ['observations.podId', () => c.observations.createIndex({ podId: 1, observedAt: -1 })], ['observations.engineerId', () => c.observations.createIndex({ engineerId: 1 })], ['collisions.podId', () => c.collisions.createIndex({ podId: 1, detectedAt: -1 })], + [ + 'collisions.memorySignature', + () => c.collisions.createIndex({ podId: 1, memorySignature: 1 }), + ], ['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })], ['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })], ]; diff --git a/backend/src/memory/store.ts b/backend/src/memory/store.ts index b784bee..d89a309 100644 --- a/backend/src/memory/store.ts +++ b/backend/src/memory/store.ts @@ -1,5 +1,6 @@ import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared'; import { collections } from './db.js'; +import { enrichCollisionMemory } from './vectors.js'; /** * Continual-learning memory: persist observations, collisions, interventions, @@ -22,7 +23,7 @@ export async function recordObservation(ctx: EngineerContext): Promise { export async function recordCollision(collision: Collision): Promise { await persist('collision', async () => - (await collections()).collisions.insertOne({ ...collision }), + (await collections()).collisions.insertOne(await enrichCollisionMemory(collision)), ); } diff --git a/backend/src/memory/vectors.ts b/backend/src/memory/vectors.ts index cbe5aa7..a4f13f2 100644 --- a/backend/src/memory/vectors.ts +++ b/backend/src/memory/vectors.ts @@ -1,10 +1,123 @@ import type { Collision } from '@podman/shared'; +import { env } from '../env.js'; +import { getDb } from './db.js'; + +type StoredCollision = Collision & { + memorySignature?: string; + memoryText?: string; + embedding?: number[]; +}; + +interface VoyageEmbeddingResponse { + data?: Array<{ embedding?: number[] }>; +} + +function normalize(value: string | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function signature(collision: Collision): string { + return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#'); +} + +function memoryText(collision: Collision): string { + return [ + `file: ${collision.file}`, + collision.symbol ? `symbol: ${collision.symbol}` : undefined, + `engineers: ${collision.engineers.join(', ')}`, + `severity: ${collision.severity}`, + collision.githubState?.unpushed ? 'unpushed local changes present' : undefined, + ] + .filter(Boolean) + .join('\n'); +} + +async function embed(text: string, inputType: 'document' | 'query'): Promise { + if (!env.VOYAGE_API_KEY) return null; + try { + const res = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { + Authorization: `Bearer ${env.VOYAGE_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + input: text, + model: env.VOYAGE_EMBEDDING_MODEL, + input_type: inputType, + }), + }); + if (!res.ok) { + console.warn(`[memory] voyage embedding failed: ${res.status} ${await res.text()}`); + return null; + } + const body = (await res.json()) as VoyageEmbeddingResponse; + return body.data?.[0]?.embedding ?? null; + } catch (err) { + console.warn(`[memory] voyage embedding failed: ${(err as Error).message}`); + return null; + } +} + +export async function enrichCollisionMemory(collision: Collision): Promise { + const text = memoryText(collision); + const embedding = await embed(text, 'document'); + return { + ...collision, + memorySignature: signature(collision), + memoryText: text, + ...(embedding ? { embedding } : {}), + }; +} + +async function recallByVector(collision: Collision): Promise { + const queryVector = await embed(memoryText(collision), 'query'); + if (!queryVector) return null; + + try { + const db = await getDb(); + const [match] = await db + .collection('collisions') + .aggregate([ + { + $vectorSearch: { + index: 'collision_embedding', + path: 'embedding', + queryVector, + numCandidates: 50, + limit: 5, + filter: { podId: collision.podId }, + }, + }, + { $match: { id: { $ne: collision.id } } }, + { $project: { _id: 0, embedding: 0 } }, + ]) + .toArray(); + return match ?? null; + } catch (err) { + console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`); + return null; + } +} + +async function recallBySignature(collision: Collision): Promise { + const db = await getDb(); + const sig = signature(collision); + const match = await db.collection('collisions').findOne( + { + podId: collision.podId, + id: { $ne: collision.id }, + $or: [{ memorySignature: sig }, { file: collision.file }], + }, + { sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } }, + ); + return match ?? null; +} /** - * Vector-based recall of prior collision patterns (Loop A). - * Stub: returns null until Voyage + Atlas Vector Search are wired. + * Recall prior collision patterns. Exact Mongo recall is always available; + * Voyage + Atlas Vector Search is used first when configured. */ -export async function recallSimilar(_collision: Collision): Promise { - // TODO(memory): embed collision.file via Voyage, query Atlas vector index - return null; +export async function recallSimilar(collision: Collision): Promise { + return (await recallByVector(collision)) ?? recallBySignature(collision); } diff --git a/backend/src/server.ts b/backend/src/server.ts index 8eb07d4..6509f38 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,7 +6,7 @@ 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'; -import { initMemory } from './memory/db.js'; +import { closeMemory, initMemory } from './memory/db.js'; import { listPods, getPod, @@ -179,3 +179,18 @@ http.listen(env.PORT, '0.0.0.0', () => { .then(() => seedDefaultPods()) .catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`)); }); + +let shuttingDown = false; +async function shutdown(signal: NodeJS.Signals): Promise { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[server] ${signal} received; shutting down`); + for (const client of clients) client.close(); + wss.close(); + await new Promise((resolve) => http.close(() => resolve())); + await closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`)); + process.exit(0); +} + +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); diff --git a/backend/src/voice/live.ts b/backend/src/voice/live.ts index a5a271f..670949f 100644 --- a/backend/src/voice/live.ts +++ b/backend/src/voice/live.ts @@ -1,10 +1,101 @@ -import type { Room } from '@livekit/rtc-node'; +import { + AudioFrame, + AudioSource, + LocalAudioTrack, + TrackPublishOptions, + TrackSource, + type Room, +} from '@livekit/rtc-node'; +import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai'; +import { DATA_TOPIC, type DataMessage } from '@podman/shared'; +import { env } from '../env.js'; + +const SAMPLE_RATE = 24_000; +const CHANNELS = 1; +const encoder = new TextEncoder(); +const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY }); + +async function publishVoiceCue(room: Room, message: string): Promise { + const cue: DataMessage = { type: 'VOICE_CUE', text: message }; + await room.localParticipant?.publishData(encoder.encode(JSON.stringify(cue)), { + reliable: true, + topic: DATA_TOPIC, + }); +} + +function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | null { + if (mimeType && !mimeType.includes('audio')) return null; + const buf = Buffer.from(data, 'base64'); + if (buf.byteLength < 2) return null; + const bytes = buf.byteLength % 2 === 0 ? buf : buf.subarray(0, buf.byteLength - 1); + const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2); + return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS); +} + +function audioFrames(message: LiveServerMessage): AudioFrame[] { + const parts = message.serverContent?.modelTurn?.parts ?? []; + const out: AudioFrame[] = []; + for (const part of parts) { + const data = part.inlineData?.data; + if (!data) continue; + const frame = audioFrameFromBase64(data, part.inlineData?.mimeType); + if (frame) out.push(frame); + } + return out; +} /** - * Speak a message into the LiveKit room using Gemini Live voice. - * Stub: logs until Gemini Live audio track wiring is complete. + * Speak a message into the LiveKit room using Gemini Live audio. A data-channel + * VOICE_CUE is sent first so clients still get the cue if audio generation or + * publishing fails. */ -export async function speak(_room: Room, message: string): Promise { - // TODO(voice): use Gemini Live streaming TTS -> publish audio track into room - console.log(`[voice] ${message}`); +export async function speak(room: Room, message: string): Promise { + await publishVoiceCue(room, message); + if (!room.localParticipant) return; + + const source = new AudioSource(SAMPLE_RATE, CHANNELS); + const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source); + const options = new TrackPublishOptions(); + options.source = TrackSource.SOURCE_MICROPHONE; + + try { + const publication = await room.localParticipant.publishTrack(track, options); + let done: () => void = () => {}; + const donePromise = new Promise((resolve) => { + done = resolve; + }); + let session: Session | null = null; + + session = await ai.live.connect({ + model: env.GEMINI_LIVE_MODEL, + config: { responseModalities: [Modality.AUDIO] }, + callbacks: { + onmessage: (event) => { + void (async () => { + for (const frame of audioFrames(event)) await source.captureFrame(frame); + if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) + done(); + })(); + }, + onerror: (event) => { + console.warn(`[voice] Gemini Live error: ${event.message}`); + done(); + }, + onclose: done, + }, + }); + + session.sendClientContent({ + turns: [{ role: 'user', parts: [{ text: message }] }], + turnComplete: true, + }); + + await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]); + session.close(); + if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true); + await source.close(); + } catch (err) { + console.warn(`[voice] Gemini Live publish failed: ${(err as Error).message}`); + await source.close().catch(() => {}); + } } diff --git a/docs/digitalocean.md b/docs/digitalocean.md index 5ec4c7e..df1fe37 100644 --- a/docs/digitalocean.md +++ b/docs/digitalocean.md @@ -1,122 +1,183 @@ # DigitalOcean Deployment Spec -PodMan backend (Hermes) runs on DigitalOcean. Frontend is served as a static site. Both are deployed from the same monorepo. +PodMan deploys as three App Platform components from the same monorepo: + +- `web`: static Vite/React frontend +- `api`: health-checked HTTP backend on port `8787` +- `podman-agent`: background LiveKit/Gemini worker with no HTTP health check + +This split is intentional. The LiveKit agent subscribes to rooms and samples +screen-share frames, so it must run as a worker rather than as a web service. --- -## Services +## Canonical Spec -### 1. Hermes — Backend API + LiveKit Agent - -**Type:** DigitalOcean App Platform — Web Service (or Droplet if App Platform has issues) - -**Runtime:** Node.js 20 - -**Build command:** `pnpm --filter backend build` - -**Run command:** `node dist/index.js` - -**Port:** `8787` (set via `PORT` env var) - -**Resources:** Basic ($12/mo) — 1 vCPU, 1GB RAM. Sufficient for hackathon load. - ---- - -### 2. Frontend PWA — Static Site - -**Type:** DigitalOcean App Platform — Static Site - -**Build command:** `pnpm --filter frontend build` - -**Output directory:** `frontend/dist` - -**Routes:** SPA — all routes → `index.html` - ---- - -## Environment variables (set in App Platform dashboard) +Use [`infra/app.yaml`](../infra/app.yaml): +```bash +doctl apps create --spec infra/app.yaml ``` -# LiveKit -LIVEKIT_URL=wss://your-livekit-server.livekit.cloud -LIVEKIT_API_KEY= -LIVEKIT_API_SECRET= -# Gemini -GEMINI_API_KEY= +The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows. + +--- + +## Components + +### Static Site: `web` + +- Source: monorepo root +- Build: + `corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/frontend build` +- Output: `frontend/dist` +- Routes: `/` +- Build-time env: + - `VITE_BACKEND_URL` + - `VITE_LIVEKIT_URL` + - In the App Platform spec, `VITE_BACKEND_URL=${APP_URL}` keeps frontend API + calls on the same deployed origin. If it is omitted, the production frontend + also falls back to same-origin. + +### HTTP Service: `api` + +- Source: monorepo root +- Dockerfile: `infra/Dockerfile` +- Runtime selector: `PODMAN_PROCESS=server` +- Port: `8787` +- Health check: `/health` +- Routes: + - `/api` with `preserve_path_prefix: true` + - `/health` + +### Worker: `podman-agent` + +- Source: monorepo root +- Dockerfile: `infra/Dockerfile` +- Runtime selector: `PODMAN_PROCESS=agent` +- No HTTP route and no HTTP health check +- Default room: `POD_ROOM=demo-pod` + +--- + +## Required Runtime Environment + +```bash +LIVEKIT_URL=wss://your-livekit-server.livekit.cloud +LIVEKIT_API_KEY=... +LIVEKIT_API_SECRET=... + +GEMINI_API_KEY=... GEMINI_VISION_MODEL=gemini-2.0-flash GEMINI_LIVE_MODEL=gemini-live-2.5-flash -# MongoDB Atlas +GITHUB_TOKEN=... +GITHUB_REPO=karti-ai/podman + MONGODB_URI=mongodb+srv://... +VOYAGE_API_KEY=... +VOYAGE_EMBEDDING_MODEL=voyage-4-lite -# Server PORT=8787 - -# Frontend (Vite — set in App Platform as static site env vars) -VITE_BACKEND_URL=https://your-hermes-app.ondigitalocean.app -VITE_LIVEKIT_URL=wss://your-livekit-server.livekit.cloud +POD_ROOM=demo-pod ``` ---- - -## Dockerfile (backend) - -Located at `infra/Dockerfile`. Already scaffolded. Ensure it: - -1. Uses `node:20-slim` -2. Installs `pnpm` -3. Copies workspace root + backend package -4. Runs `pnpm install --frozen-lockfile` -5. Runs `pnpm --filter backend build` -6. `CMD ["node", "backend/dist/index.js"]` +`VOYAGE_API_KEY` is optional for local/demo fallback. Without it, Mongo exact +signature recall still works; Atlas Vector Search recall is skipped. --- -## App Platform spec (`infra/app.yaml`) +## Container Checks -Already scaffolded. Key fields to confirm before deploy: - -```yaml -services: - - name: hermes - source_dir: / - dockerfile_path: infra/Dockerfile - http_port: 8787 - instance_size_slug: basic-xxs - envs: - - key: LIVEKIT_URL - scope: RUN_TIME - value: ${LIVEKIT_URL} - # ... other vars - -static_sites: - - name: frontend - source_dir: frontend - build_command: pnpm build - output_dir: dist - index_document: index.html - error_document: index.html -``` - ---- - -## Deploy checklist - -- [ ] MongoDB Atlas IP allowlist: add DigitalOcean outbound IPs (or allow all: `0.0.0.0/0` for hackathon) -- [ ] LiveKit Cloud: confirm `LIVEKIT_URL` points to your LiveKit Cloud project -- [ ] Gemini API key has quota for `gemini-2.0-flash` + `gemini-live-2.5-flash` -- [ ] `VITE_BACKEND_URL` set to the deployed Hermes URL (not localhost) -- [ ] Test `GET /health` returns `{ ok: true }` after deploy - ---- - -## Fallback plan (if App Platform deploy fails on stage) - -Run Hermes locally: +Build once: ```bash -cd backend && pnpm dev +docker build -f infra/Dockerfile -t podman-backend . ``` -Frontend already points to `http://localhost:8787` by default via `VITE_BACKEND_URL` fallback. Demo works fully local — no DigitalOcean dependency for the live demo itself. +The image entrypoint runs `node backend/dist/server.js` when +`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when +`PODMAN_PROCESS=agent`. Do not run the combined Hermes supervisor inside App +Platform; DO already supervises the service and worker separately. + +Run the API: + +```bash +docker run --env-file backend/.env -e PODMAN_PROCESS=server -p 8787:8787 podman-backend +``` + +Run the worker: + +```bash +docker run --env-file backend/.env -e PODMAN_PROCESS=agent podman-backend +``` + +--- + +## Deploy Checklist + +- [ ] `VITE_BACKEND_URL` is `${APP_URL}` or points to the deployed API origin. +- [ ] `FRONTEND_URL` is set for `pnpm deploy:doctor:strict` if the SPA is on a + different origin than the API. +- [ ] `LIVEKIT_URL` points to the LiveKit Cloud project. +- [ ] LiveKit API key/secret are set for both `api` and `podman-agent`. +- [ ] Gemini API key is set for both backend components. +- [ ] MongoDB Atlas allows DigitalOcean outbound access. +- [ ] `GET /` returns the built frontend HTML and JavaScript bundle. +- [ ] `GET /health` returns `{ "ok": true }`. +- [ ] `GET /api/pods` returns pod data. +- [ ] Worker logs show `podman-hermes joined room demo-pod`. +- [ ] `pnpm deploy:doctor:strict` passes with production env loaded. + +Run a non-failing readiness report any time: + +```bash +pnpm deploy:doctor +``` + +Use the strict gate before calling a deployment production-ready: + +```bash +pnpm deploy:doctor:strict +``` + +--- + +## Local Fallback + +```bash +pnpm install --frozen-lockfile +pnpm build +pnpm --filter @podman/backend start:server +pnpm --filter @podman/backend start:agent +pnpm --filter @podman/frontend dev +``` + +For this droplet deployment, Caddy serves `frontend/dist` from +`/var/www/podman` and proxies `/api/*` to `localhost:8787`. + +The systemd fallback units live in `infra/systemd/` and load +`/root/podman/backend/.env` on the current droplet: + +```bash +sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now podman-platform-api podman-platform-agent +``` + +The droplet production fallback uses systemd units from `infra/systemd/`: + +```bash +sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/ +sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now podman-platform-api podman-platform-agent +``` + +Expected runtime proof: + +```bash +systemctl is-active podman-platform-api podman-platform-agent +curl http://127.0.0.1:8787/health +journalctl -u podman-platform-agent -n 20 --no-pager +``` diff --git a/docs/livekit.md b/docs/livekit.md index f32a107..109e066 100644 --- a/docs/livekit.md +++ b/docs/livekit.md @@ -19,7 +19,7 @@ LiveKit is the real-time backbone for PodMan. It handles room presence and voice 1. PWA calls `POST /pods/:podId/token` → receives `{ token, url }` 2. LiveKit client connects to the room with the token -3. PWA publishes screen track via `getDisplayMedia` (used client-side for frame capture — Hermes does NOT subscribe to this track) +3. PWA publishes screen track via `getDisplayMedia` 4. PWA sets mic enabled for ambient presence **Receiving:** @@ -48,7 +48,7 @@ room.on(RoomEvent.DataReceived, (payload, participant) => { **Startup:** 1. Hermes mints its own token via the same `createPodToken` function with `identity: 'podman-hermes'` -2. Connects to the room on pod creation / first engineer joining +2. Connects to the configured room as `podman-hermes` 3. Registers as a LiveKit Agent with Gemini Live 2.5 as voice provider **Voice delivery:** @@ -78,7 +78,7 @@ room.localParticipant.publishData( ## Token endpoint -Already implemented at `POST /pods/:podId/token`. +Already implemented at `POST /api/token`. Hermes uses the same endpoint. Grants: @@ -99,7 +99,6 @@ Hermes uses the same endpoint. Grants: ## What LiveKit does NOT do in PodMan -- Hermes does NOT subscribe to engineer screen tracks (frame capture happens client-side) - No video tracks from Hermes - No mic transcription (not needed for v1) - No SFU mixing — standard room behavior is sufficient diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e38a52e..3ee58fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { SparklesIcon, UsersIcon, WifiIcon, + ShieldCheckIcon, } from 'lucide-react'; import type { Pod, PodInput } from '@podman/shared'; import { joinPod } from './lib/pod.js'; @@ -239,28 +240,34 @@ export default function App() { return (
-
-
+
+
-
+
PM
-

PodMan

+

+ PodMan +

{podManOnline ? 'online' : 'standby'}

- Quiet coordination for live engineering rooms. + Live engineering rooms, team memory, and intervention routing.

+ + + Privacy-limited +
-
+
@@ -304,55 +311,74 @@ export default function App() { ) : ( -
-
-
-

Workspaces

-

- Join a room, publish your screen, and let PodMan watch for overlap. +

+
+
+
+

Workspaces

+

Active pods

+
+

+ Join the room that matches your current workstream.

-
- {loading ? ( -
- - -
- ) : pods.length ? ( -
- {pods.map((pod) => ( - + + +
+ ) : pods.length ? ( +
+ {pods.map((pod) => ( + + ))} +
+ ) : ( + + + + + + No pods yet + Create the first room for this team. + + + + + + )} + + +
- ) : ( - - - - - - No pods yet - - Create the first pod to start a LiveKit room and coordination loop. - - - - - - - )} + + +
)}
@@ -370,18 +396,27 @@ function StatPill({ value: string; }) { return ( -
-
+
+
-

{label}

+

{label}

{value}

); } +function BriefLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + function PodSkeleton() { return ( diff --git a/frontend/src/components/CreatePodForm.tsx b/frontend/src/components/CreatePodForm.tsx index 08ca031..a3746ef 100644 --- a/frontend/src/components/CreatePodForm.tsx +++ b/frontend/src/components/CreatePodForm.tsx @@ -50,10 +50,10 @@ export function CreatePodForm({ if (!open) { return ( )} - diff --git a/frontend/src/components/PodCard.tsx b/frontend/src/components/PodCard.tsx index efcb4df..330b62a 100644 --- a/frontend/src/components/PodCard.tsx +++ b/frontend/src/components/PodCard.tsx @@ -84,10 +84,12 @@ export function PodCard({ return ( <> - + - {pod.name} - {pod.repo || 'No repository set'} + {pod.name} + + {pod.repo || 'No repository set'} + @@ -121,7 +123,7 @@ export function PodCard({ {pod.description || 'Focused workspace for live engineering coordination.'}

- + {active ? `${presence.length} live` : 'quiet'}
@@ -175,7 +177,11 @@ export function PodCard({ Join - diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index 66e6ff8..6004827 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -185,8 +185,8 @@ export function PodView({ return (
-
-
+
+
@@ -200,13 +200,15 @@ export function PodView({

{team.name}

- {room ? 'live' : 'local'} + + {room ? 'live' : 'local'} +
-

{team.repo}

+

{team.repo}

-
+