feat: harden podman deployment orchestration

This commit is contained in:
Yahya Alhinai
2026-06-28 01:44:26 +00:00
parent 5185845090
commit c818d5081e
37 changed files with 1796 additions and 273 deletions
+11
View File
@@ -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://<user>:<pass>@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
+1
View File
@@ -4,3 +4,4 @@ build
pnpm-lock.yaml
.omc
*.log
.agents
+5 -2
View File
@@ -3,12 +3,15 @@
"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",
"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"
},
+3 -2
View File
@@ -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<string> {
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<string, number>();
+3 -2
View File
@@ -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;
+50
View File
@@ -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');
});
+11
View File
@@ -24,6 +24,13 @@ export async function getDb(): Promise<Db> {
return client.db();
}
export async function closeMemory(): Promise<void> {
if (!clientPromise) return;
const client = await clientPromise;
clientPromise = null;
await client.close();
}
export interface PodCollections {
pods: Collection<Pod>;
observations: Collection<EngineerContext>;
@@ -88,6 +95,10 @@ export async function initMemory(): Promise<void> {
['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 })],
];
+2 -1
View File
@@ -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<void> {
export async function recordCollision(collision: Collision): Promise<void> {
await persist('collision', async () =>
(await collections()).collisions.insertOne({ ...collision }),
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
);
}
+119 -6
View File
@@ -1,10 +1,123 @@
import type { Collision } from '@podman/shared';
import { env } from '../env.js';
import { getDb } from './db.js';
/**
* Vector-based recall of prior collision patterns (Loop A).
* Stub: returns null until Voyage + Atlas Vector Search are wired.
*/
export async function recallSimilar(_collision: Collision): Promise<Collision | null> {
// TODO(memory): embed collision.file via Voyage, query Atlas vector index
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<number[] | null> {
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<StoredCollision> {
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<Collision | null> {
const queryVector = await embed(memoryText(collision), 'query');
if (!queryVector) return null;
try {
const db = await getDb();
const [match] = await db
.collection<StoredCollision>('collisions')
.aggregate<StoredCollision>([
{
$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<Collision | null> {
const db = await getDb();
const sig = signature(collision);
const match = await db.collection<StoredCollision>('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;
}
/**
* 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<Collision | null> {
return (await recallByVector(collision)) ?? recallBySignature(collision);
}
+16 -1
View File
@@ -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,
@@ -161,3 +161,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<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[server] ${signal} received; shutting down`);
for (const client of clients) client.close();
wss.close();
await new Promise<void>((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'));
+97 -6
View File
@@ -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<void> {
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<void> {
// 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<void> {
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<void>((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(() => {});
}
}
+158 -97
View File
@@ -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
```
+3 -4
View File
@@ -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
+57 -20
View File
@@ -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';
@@ -233,34 +234,42 @@ export default function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
<header className="flex flex-col gap-5 border-b pb-5">
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-5 border-b bg-background/86 px-4 pb-5 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary text-sm font-medium text-primary-foreground">
<div className="grid size-10 place-items-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm">
PM
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-3xl font-semibold tracking-tight">PodMan</h1>
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">
PodMan
</h1>
<Badge variant={podManOnline ? 'default' : 'secondary'}>
<CircleDotIcon data-icon="inline-start" />
{podManOnline ? 'online' : 'standby'}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Quiet coordination for live engineering rooms.
Live engineering rooms, team memory, and intervention routing.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-8 rounded-lg px-3">
<ShieldCheckIcon data-icon="inline-start" />
Privacy-limited
</Badge>
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
<RefreshCwIcon data-icon="inline-start" />
Refresh
</Button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<StatPill icon={WifiIcon} label="Live" value={fmt.format(liveTotal)} />
<StatPill icon={RadioTowerIcon} label="Rooms" value={fmt.format(activeRooms)} />
<StatPill icon={UsersIcon} label="Roster" value={fmt.format(totalMembers)} />
@@ -292,14 +301,16 @@ export default function App() {
</CardContent>
</Card>
) : (
<main className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<main className="grid min-w-0 flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h2 className="text-lg font-medium">Workspaces</h2>
<p className="text-sm text-muted-foreground">
Join a room, publish your screen, and let PodMan watch for overlap.
</p>
<p className="text-xs font-medium uppercase text-muted-foreground">Workspaces</p>
<h2 className="text-xl font-semibold tracking-tight">Active pods</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
Join the room that matches your current workstream.
</p>
</div>
{loading ? (
@@ -323,24 +334,41 @@ export default function App() {
onDelete={handleDelete}
/>
))}
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
</div>
) : (
<Empty className="min-h-[360px] border">
<Empty className="min-h-[360px] rounded-lg border bg-card">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No pods yet</EmptyTitle>
<EmptyDescription>
Create the first pod to start a LiveKit room and coordination loop.
</EmptyDescription>
<EmptyDescription>Create the first room for this team.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact />
</EmptyContent>
</Empty>
)}
</section>
<aside className="flex flex-col gap-4">
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
<Card>
<CardHeader>
<CardTitle>Operating brief</CardTitle>
<CardDescription>Current coordination signals.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<BriefLine label="Notification default" value="Card" />
<BriefLine label="Escalation" value="Hermes voice only when urgent" />
<BriefLine label="Memory events" value={fmt.format(latestActivity)} />
<BriefLine
label="Live people"
value={liveNames.length ? liveNames.join(', ') : 'None'}
/>
</CardContent>
</Card>
</aside>
</main>
)}
</div>
@@ -358,18 +386,27 @@ function StatPill({
value: string;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-3 py-2">
<div className="grid size-8 place-items-center rounded-lg bg-muted">
<div className="flex min-h-16 items-center gap-3 rounded-lg border bg-card/90 px-3 py-2 shadow-sm">
<div className="grid size-8 place-items-center rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xs font-medium uppercase text-muted-foreground">{label}</p>
<p className="text-base font-medium">{value}</p>
</div>
</div>
);
}
function BriefLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 rounded-md bg-muted/45 px-3 py-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="max-w-44 text-right text-sm font-medium">{value}</span>
</div>
);
}
function PodSkeleton() {
return (
<Card>
+4 -4
View File
@@ -50,10 +50,10 @@ export function CreatePodForm({
if (!open) {
return (
<button
className="group flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed bg-card p-6 text-center transition hover:bg-muted/30"
className="group flex min-h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed bg-card p-6 text-center shadow-sm transition hover:bg-muted/30 hover:ring-1 hover:ring-black/10"
onClick={() => setOpen(true)}
>
<span className="grid size-9 place-items-center rounded-lg bg-muted transition group-hover:bg-background">
<span className="grid size-9 place-items-center rounded-md bg-muted transition group-hover:bg-background">
<PlusIcon className="size-4 text-muted-foreground" />
</span>
<span className="text-sm font-medium">New pod</span>
@@ -68,7 +68,7 @@ export function CreatePodForm({
<Card className={cn(compact && 'w-full border-0 shadow-none ring-0')}>
<CardHeader>
<CardTitle>New pod</CardTitle>
<CardDescription>Keep the room name short and specific.</CardDescription>
<CardDescription>Create a focused room.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
@@ -117,7 +117,7 @@ export function CreatePodForm({
Cancel
</Button>
)}
<Button onClick={submit} disabled={busy || !name.trim()}>
<Button className="min-w-20" onClick={submit} disabled={busy || !name.trim()}>
Create
</Button>
</CardFooter>
+11 -5
View File
@@ -84,10 +84,12 @@ export function PodCard({
return (
<>
<Card className="min-h-64 transition duration-200 hover:-translate-y-0.5 hover:ring-foreground/15">
<Card className="min-h-64 transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_2px_6px_rgba(0,0,0,0.06),0_28px_70px_rgba(0,0,0,0.06)] hover:ring-black/15">
<CardHeader>
<CardTitle className="truncate">{pod.name}</CardTitle>
<CardDescription className="truncate">{pod.repo || 'No repository set'}</CardDescription>
<CardTitle className="truncate text-[1.05rem]">{pod.name}</CardTitle>
<CardDescription className="truncate font-mono text-xs">
{pod.repo || 'No repository set'}
</CardDescription>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -121,7 +123,7 @@ export function PodCard({
{pod.description || 'Focused workspace for live engineering coordination.'}
</p>
</div>
<Badge variant={active ? 'default' : 'secondary'}>
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
{active ? `${presence.length} live` : 'quiet'}
</Badge>
</div>
@@ -175,7 +177,11 @@ export function PodCard({
<VideoIcon data-icon="inline-start" />
Join
</Button>
<Button onClick={() => submitMember(true)} disabled={busy || !newMember.trim()}>
<Button
className="min-w-28"
onClick={() => submitMember(true)}
disabled={busy || !newMember.trim()}
>
Add and join
</Button>
</CardFooter>
+16 -14
View File
@@ -185,8 +185,8 @@ export function PodView({
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
<header className="flex items-center justify-between gap-4 border-b pb-4">
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-3 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 md:flex-row md:items-center md:justify-between lg:-mx-8 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
@@ -200,13 +200,15 @@ export function PodView({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
<Badge variant={room ? 'default' : 'secondary'}>{room ? 'live' : 'local'}</Badge>
<Badge variant={room ? 'default' : 'secondary'} className="rounded-md">
{room ? 'live' : 'local'}
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{team.repo}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
<Volume2Icon data-icon="inline-start" />
{playingBeat ? 'Stop audio' : 'Test audio'}
@@ -244,8 +246,8 @@ export function PodView({
<Card className="flex-1">
<CardHeader>
<CardTitle>Room</CardTitle>
<CardDescription>Only the signals that matter right now.</CardDescription>
<CardTitle>Room state</CardTitle>
<CardDescription>People and media currently visible to PodMan.</CardDescription>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
@@ -278,9 +280,9 @@ export function PodView({
<Card>
<CardHeader>
<CardTitle>Intervention</CardTitle>
<CardDescription>PodMan stays quiet until the signal is useful.</CardDescription>
<CardDescription>Card first, voice only for urgent escalation.</CardDescription>
<CardAction>
<Badge variant={active ? 'default' : 'secondary'}>
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
{active ? 'active' : 'clear'}
</Badge>
</CardAction>
@@ -288,7 +290,7 @@ export function PodView({
<CardContent>
{active ? (
<div className="flex flex-col gap-4">
<div className="rounded-xl border bg-muted/30 p-3">
<div className="rounded-lg border bg-muted/35 p-3">
<p className="text-sm leading-6">{active.message}</p>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
@@ -306,8 +308,8 @@ export function PodView({
</EmptyMedia>
<EmptyTitle>No collision detected</EmptyTitle>
<EmptyDescription>
Share your screen when ready. The agent will surface only meaningful
overlap.
Share your screen when ready. PodMan will stay quiet until there is a useful
signal.
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -372,7 +374,7 @@ function Participant({ participant }: { participant: PInfo }) {
return (
<div
className={cn(
'flex min-h-16 items-center justify-between gap-3 rounded-xl border bg-muted/20 px-3 transition',
'flex min-h-16 items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 transition',
participant.speaking && 'bg-muted',
)}
>
@@ -386,7 +388,7 @@ function Participant({ participant }: { participant: PInfo }) {
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
</div>
</div>
<Badge variant={participant.speaking ? 'default' : 'secondary'}>
<Badge variant={participant.speaking ? 'default' : 'secondary'} className="rounded-md">
{participant.speaking ? 'speaking' : 'connected'}
</Badge>
</div>
+5 -6
View File
@@ -5,7 +5,7 @@ import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -23,14 +23,13 @@ const buttonVariants = cva(
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-6 gap-1 rounded-md px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-md px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
"size-6 rounded-md in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-md in-data-[slot=button-group]:rounded-md',
'icon-lg': 'size-9',
},
},
+3 -3
View File
@@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04),0_20px_60px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.07] [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg',
className,
)}
{...props}
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot="card-header"
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
className,
)}
{...props}
@@ -77,7 +77,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot="card-footer"
className={cn(
'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
'flex items-center rounded-b-lg border-t bg-muted/35 p-(--card-spacing)',
className,
)}
{...props}
+25 -23
View File
@@ -7,33 +7,33 @@
:root {
color-scheme: light;
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.986 0.002 247.84);
--foreground: oklch(0.17 0.004 255.9);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.17 0.004 255.9);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.18 0 0);
--popover-foreground: oklch(0.17 0.004 255.9);
--primary: oklch(0.19 0.004 255.9);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.955 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.955 0 0);
--accent-foreground: oklch(0.205 0 0);
--secondary: oklch(0.956 0.003 247.86);
--secondary-foreground: oklch(0.26 0.004 255.9);
--muted: oklch(0.962 0.003 247.86);
--muted-foreground: oklch(0.52 0.006 255.9);
--accent: oklch(0.955 0.004 247.86);
--accent-foreground: oklch(0.24 0.004 255.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--border: oklch(0.905 0.004 247.86);
--input: oklch(0.905 0.004 247.86);
--ring: oklch(0.62 0.012 255.9);
--chart-1: oklch(0.67 0.12 250);
--chart-2: oklch(0.68 0.11 155);
--chart-3: oklch(0.72 0.14 80);
--chart-4: oklch(0.63 0.16 20);
--chart-5: oklch(0.55 0.11 290);
--radius: 0.5rem;
--sidebar: oklch(0.986 0.002 247.84);
--sidebar-foreground: oklch(0.17 0.004 255.9);
--sidebar-primary: oklch(0.24 0.004 255.9);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
@@ -47,6 +47,8 @@ body {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 248, 250, 0.98)), var(--background);
}
button,
+5 -1
View File
@@ -1,6 +1,10 @@
import type { InterventionOutcome, Pod, PodInput } from '@podman/shared';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL ||
(import.meta.env.DEV || ['localhost', '127.0.0.1'].includes(window.location.hostname)
? 'http://localhost:8787'
: '');
export interface MemoryStats {
observations: number;
+5 -1
View File
@@ -1,6 +1,10 @@
import { Room, RoomEvent } from 'livekit-client';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL ||
(import.meta.env.DEV || ['localhost', '127.0.0.1'].includes(window.location.hostname)
? 'http://localhost:8787'
: '');
/** Token + LiveKit URL minted by the backend. */
export async function fetchPodToken(
+39 -18
View File
@@ -1,60 +1,81 @@
# DigitalOcean App Platform spec for PodMan.
# Deploy: doctl apps create --spec infra/app.yaml
name: podman
region: nyc
static_sites:
- name: web
github:
repo: <org>/Podman
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: frontend
source_dir: /
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/frontend build
output_dir: dist
output_dir: frontend/dist
index_document: index.html
error_document: index.html
routes:
- path: /
envs:
- key: VITE_BACKEND_URL
scope: BUILD_TIME
value: ${APP_URL}
- key: VITE_LIVEKIT_URL
scope: BUILD_TIME
services:
- name: api
github:
repo: <org>/Podman
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: backend
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
run_command: node dist/server.js
source_dir: /
dockerfile_path: infra/Dockerfile
http_port: 8787
instance_size_slug: apps-s-1vcpu-1gb
instance_count: 1
health_check:
http_path: /health
routes:
- path: /api
preserve_path_prefix: true
- path: /health
envs:
- { key: LIVEKIT_URL, scope: RUN_TIME, type: SECRET }
- { key: PODMAN_PROCESS, scope: RUN_TIME, value: server }
- { key: PORT, scope: RUN_TIME, value: '8787' }
- { key: LIVEKIT_URL, scope: RUN_TIME }
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: <org>/<public-repo> }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_EMBEDDING_MODEL, scope: RUN_TIME, value: voyage-4-lite }
workers:
- name: podman-agent
github:
repo: <org>/Podman
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: backend
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
run_command: node dist/agent.js
source_dir: /
dockerfile_path: infra/Dockerfile
instance_size_slug: apps-s-1vcpu-1gb
instance_count: 1
envs:
- { key: LIVEKIT_URL, scope: RUN_TIME, type: SECRET }
- { key: PODMAN_PROCESS, scope: RUN_TIME, value: agent }
- { key: POD_ROOM, scope: RUN_TIME, value: demo-pod }
- { key: LIVEKIT_URL, scope: RUN_TIME }
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-3.5-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-live-preview }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: <org>/<public-repo> }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: POD_ROOM, scope: RUN_TIME, value: demo-pod }
- { key: VOYAGE_EMBEDDING_MODEL, scope: RUN_TIME, value: voyage-4-lite }
+19
View File
@@ -0,0 +1,19 @@
165-22-129-249.sslip.io {
route {
handle /api/* {
reverse_proxy 127.0.0.1:8787
}
handle /health {
reverse_proxy 127.0.0.1:8787
}
root * /var/www/podman
try_files {path} /index.html
file_server
}
}
lk.165-22-129-249.sslip.io {
reverse_proxy localhost:7880
}
+8 -2
View File
@@ -1,8 +1,13 @@
# PodMan backend agent — built from the monorepo root.
# PodMan backend runtime — built from the monorepo root.
# Build: docker build -f infra/Dockerfile -t podman-backend .
# Run API: docker run --env-file backend/.env -e PODMAN_PROCESS=server -p 8787:8787 podman-backend
# Run agent: docker run --env-file backend/.env -e PODMAN_PROCESS=agent podman-backend
FROM node:24-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
@@ -21,6 +26,7 @@ RUN pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
# Runtime
FROM base AS runtime
ENV NODE_ENV=production
ENV PODMAN_PROCESS=server
COPY --from=build /app /app
EXPOSE 8787
CMD ["node", "backend/dist/index.js"]
CMD ["sh", "-c", "if [ \"$PODMAN_PROCESS\" = \"agent\" ]; then exec node backend/dist/agent.js; else exec node backend/dist/server.js; fi"]
+58 -8
View File
@@ -2,23 +2,51 @@
Deploy targets for PodMan on DigitalOcean.
- `Dockerfile` — builds the Hermes backend from the monorepo root
- `app.yaml` — DigitalOcean App Platform spec (Hermes web service + frontend static site)
- `Dockerfile` — builds the backend runtime image from the monorepo root
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
- `systemd/` — local droplet service units for the API and agent worker
Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md).
## Local development
```bash
pnpm --filter backend dev # Hermes on :8787
pnpm --filter frontend dev # PWA on :5173
pnpm --filter @podman/backend dev:server
pnpm --filter @podman/backend dev:agent
pnpm --filter @podman/frontend dev
```
## Local container
```bash
docker build -f infra/Dockerfile -t podman-hermes .
docker run --env-file .env -p 8787:8787 podman-hermes
docker build -f infra/Dockerfile -t podman-backend .
docker run --env-file backend/.env -e PODMAN_PROCESS=server -p 8787:8787 podman-backend
docker run --env-file backend/.env -e PODMAN_PROCESS=agent podman-backend
```
## Local production services
On the demo droplet, serve the API and worker with systemd instead of tmux:
```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
sudo systemctl status podman-platform-api podman-platform-agent
```
The services expect:
- built backend artifacts in `backend/dist`
- runtime env in `backend/.env`
- Caddy proxying `/api/*` to `127.0.0.1:8787`
Useful checks:
```bash
curl http://127.0.0.1:8787/health
journalctl -u podman-platform-api -u podman-platform-agent -f
```
## DigitalOcean deploy
@@ -27,8 +55,30 @@ docker run --env-file .env -p 8787:8787 podman-hermes
doctl apps create --spec infra/app.yaml
```
Set secret env vars (LiveKit, Gemini, MongoDB) in the DO dashboard after app creation.
Set secret env vars (LiveKit, Gemini, GitHub, MongoDB) in the DO dashboard after app creation.
Run `pnpm deploy:doctor:strict` with the same environment loaded before treating the
deployment as production-ready.
## Droplet/systemd fallback
The `infra/systemd/` units run the compiled API and LiveKit/Gemini agent from
`/root/podman` and load `/root/podman/backend/.env`, matching the current
droplet layout. `pnpm deploy:doctor` also falls back to that file when root
`.env` is absent. Set `FRONTEND_URL` when the static frontend is served from a
different public origin than `VITE_BACKEND_URL`.
The matching Caddy config is in `infra/Caddyfile`; it serves `/var/www/podman`,
proxies `/api/*` and `/health` to `localhost:8787`, and proxies the optional
local LiveKit host.
```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
systemctl status podman-platform-api podman-platform-agent
```
## Fallback (demo safety)
If DO deploy is flaky on stage, run Hermes locally. The PWA defaults to `http://localhost:8787` via `VITE_BACKEND_URL` fallback — no code change needed.
If DO deploy is flaky on stage, run the API and agent locally. In dev, the PWA
defaults to `http://localhost:8787`; in production it falls back to same-origin.
+62 -7
View File
@@ -1,26 +1,81 @@
# DigitalOcean App Platform spec for the PodMan backend.
# DigitalOcean App Platform spec for PodMan.
# Deploy: doctl apps create --spec infra/app.yaml
name: podman
region: nyc
services:
- name: backend
dockerfile_path: infra/Dockerfile
source_dir: /
static_sites:
- name: web
github:
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: /
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/frontend build
output_dir: frontend/dist
index_document: index.html
error_document: index.html
routes:
- path: /
envs:
- key: VITE_BACKEND_URL
scope: BUILD_TIME
value: ${APP_URL}
- key: VITE_LIVEKIT_URL
scope: BUILD_TIME
services:
- name: api
github:
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: /
dockerfile_path: infra/Dockerfile
http_port: 8787
instance_size_slug: basic-xxs
instance_size_slug: apps-s-1vcpu-1gb
instance_count: 1
health_check:
http_path: /health
routes:
- path: /api
preserve_path_prefix: true
- path: /health
envs:
- { key: PODMAN_PROCESS, scope: RUN_TIME, value: server }
- { key: PORT, scope: RUN_TIME, value: '8787' }
- { key: LIVEKIT_URL, scope: RUN_TIME }
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_EMBEDDING_MODEL, scope: RUN_TIME, value: voyage-4-lite }
workers:
- name: podman-agent
github:
repo: karti-ai/podman
branch: main
deploy_on_push: true
source_dir: /
dockerfile_path: infra/Dockerfile
instance_size_slug: apps-s-1vcpu-1gb
instance_count: 1
envs:
- { key: PODMAN_PROCESS, scope: RUN_TIME, value: agent }
- { key: POD_ROOM, scope: RUN_TIME, value: demo-pod }
- { key: LIVEKIT_URL, scope: RUN_TIME }
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: VOYAGE_EMBEDDING_MODEL, scope: RUN_TIME, value: voyage-4-lite }
@@ -0,0 +1,19 @@
[Unit]
Description=PodMan LiveKit/Gemini agent worker
After=network-online.target mongod.service podman-platform-api.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root/podman/backend
Environment=NODE_ENV=production
Environment=POD_ROOM=demo-pod
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node dist/agent.js
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=PodMan platform API
After=network-online.target mongod.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root/podman
Environment=NODE_ENV=production
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node backend/dist/server.js
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
+17
View File
@@ -13,7 +13,18 @@
"dev:frontend": "pnpm --filter @podman/frontend dev",
"dev:backend": "pnpm --filter @podman/backend dev",
"build": "pnpm -r build",
"build:container": "docker build -f infra/Dockerfile -t podman-backend .",
"typecheck": "pnpm -r typecheck",
"deploy:doctor": "node scripts/deploy-doctor.mjs",
"deploy:doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"doctor": "node scripts/deploy-doctor.mjs",
"doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"deploy:static:local": "node scripts/deploy-static-local.mjs",
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
"verify:backend": "node scripts/verify-backend.mjs",
"verify:containers": "node scripts/verify-containers.mjs",
"verify:frontend": "node scripts/verify-frontend.mjs",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
@@ -22,6 +33,7 @@
"@eslint/js": "^10.0.1",
"@types/node": "^26.0.1",
"eslint": "^10.6.0",
"playwright": "^1.61.1",
"prettier": "^3.9.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0"
@@ -32,5 +44,10 @@
"@google/genai",
"protobufjs"
]
},
"dependencies": {
"dotenv": "^17.4.2",
"livekit-server-sdk": "^2.15.5",
"mongodb": "^7.4.0"
}
}
+14 -1
View File
@@ -7,6 +7,16 @@ settings:
importers:
.:
dependencies:
dotenv:
specifier: ^17.4.2
version: 17.4.2
livekit-server-sdk:
specifier: ^2.15.5
version: 2.15.5
mongodb:
specifier: ^7.4.0
version: 7.4.0
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@@ -17,6 +27,9 @@ importers:
eslint:
specifier: ^10.6.0
version: 10.6.0(jiti@2.7.0)
playwright:
specifier: ^1.61.1
version: 1.61.1
prettier:
specifier: ^3.9.0
version: 3.9.0
@@ -31,7 +44,7 @@ importers:
dependencies:
'@google/genai':
specifier: ^2.10.0
version: 2.10.0
version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))
'@livekit/rtc-node':
specifier: ^0.13.29
version: 0.13.29
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { MongoClient } from 'mongodb';
import { RoomServiceClient } from 'livekit-server-sdk';
import { config as loadEnv } from 'dotenv';
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
loadEnv({ path: envPath, quiet: true });
const strict = process.argv.includes('--strict');
const results = [];
const requiredEnv = [
'LIVEKIT_URL',
'LIVEKIT_API_KEY',
'LIVEKIT_API_SECRET',
'GEMINI_API_KEY',
'GITHUB_TOKEN',
'GITHUB_REPO',
'MONGODB_URI',
];
const frontendEnv = ['VITE_BACKEND_URL', 'VITE_LIVEKIT_URL'];
const optionalEnv = ['VOYAGE_API_KEY', 'VOYAGE_EMBEDDING_MODEL'];
const doFetch = globalThis.fetch;
function add(name, status, detail = '') {
results.push({ name, status, detail });
}
function isSet(name) {
return !!process.env[name]?.trim();
}
async function check(name, fn) {
try {
const detail = await fn();
add(name, 'ok', detail);
} catch (err) {
add(name, 'fail', summarizeError(err));
}
}
function summarizeProviderBody(text) {
try {
const parsed = JSON.parse(text);
const error = parsed.error;
if (error?.status || error?.message) {
return [error.status, error.message].filter(Boolean).join(': ');
}
} catch {
// Keep short plaintext bodies.
}
return text.slice(0, 240);
}
async function responseError(service, res) {
const body = await res.text();
return `${service} returned ${res.status}${body ? `: ${summarizeProviderBody(body)}` : ''}`;
}
function summarizeError(err) {
return err instanceof Error ? err.message : String(err);
}
async function checkWorkspace() {
const workspace = await readFile('pnpm-workspace.yaml', 'utf8');
for (const pkg of ['frontend', 'backend', 'shared']) {
if (!workspace.includes(`'${pkg}'`) && !workspace.includes(`- ${pkg}`)) {
throw new Error(`pnpm-workspace.yaml missing ${pkg}`);
}
}
return 'frontend, backend, and shared are listed';
}
function backendUrl() {
const explicit = process.env.BACKEND_URL ?? process.env.VITE_BACKEND_URL;
if (explicit) return explicit;
if (requiredEnv.every(isSet)) return 'http://127.0.0.1:8787';
throw new Error('BACKEND_URL or VITE_BACKEND_URL is not set');
}
function frontendUrl() {
return process.env.FRONTEND_URL ?? process.env.VITE_BACKEND_URL ?? backendUrl();
}
async function checkFrontendShell() {
const url = frontendUrl().replace(/\/$/, '');
const res = await doFetch(`${url}/`);
if (!res.ok) throw new Error(`GET / returned ${res.status}`);
const html = await res.text();
if (!html.includes('id="root"')) throw new Error('frontend HTML missing root mount node');
const script = html.match(/<script[^>]+src="([^"]+)"/)?.[1];
if (!script) throw new Error('frontend HTML missing bundled script');
const assetUrl = new URL(script, `${url}/`);
const assetRes = await doFetch(assetUrl);
if (!assetRes.ok) throw new Error(`frontend bundle returned ${assetRes.status}`);
const bundle = await assetRes.text();
if (bundle.length < 10_000) throw new Error('frontend bundle was unexpectedly small');
return `${url}, bundle ${Math.round(bundle.length / 1024)} KiB`;
}
async function checkBackendHealth() {
const url = backendUrl();
const res = await doFetch(`${url.replace(/\/$/, '')}/health`);
if (!res.ok) throw new Error(`GET /health returned ${res.status}`);
const body = await res.json();
if (body.ok !== true) throw new Error(`unexpected /health body: ${JSON.stringify(body)}`);
return url;
}
async function checkBackendPods() {
const url = backendUrl();
const res = await doFetch(`${url.replace(/\/$/, '')}/api/pods`);
if (!res.ok) throw new Error(`GET /api/pods returned ${res.status}`);
const body = await res.json();
if (!Array.isArray(body)) throw new Error(`unexpected /api/pods body: ${JSON.stringify(body)}`);
return `${body.length} pod(s)`;
}
async function checkBackendToken() {
const url = backendUrl();
const res = await doFetch(`${url.replace(/\/$/, '')}/api/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
room: process.env.POD_ROOM ?? 'demo-pod',
identity: 'doctor',
name: 'Doctor',
}),
});
if (!res.ok) throw new Error(`POST /api/token returned ${res.status}`);
const body = await res.json();
if (typeof body.token !== 'string' || body.token.split('.').length !== 3) {
throw new Error('token response did not contain a JWT');
}
if (typeof body.url !== 'string' || !body.url)
throw new Error('token response missing LiveKit URL');
return `minted JWT for ${body.url}`;
}
async function checkLiveKitApi() {
if (!isSet('LIVEKIT_URL')) throw new Error('LIVEKIT_URL is not set');
if (!isSet('LIVEKIT_API_KEY')) throw new Error('LIVEKIT_API_KEY is not set');
if (!isSet('LIVEKIT_API_SECRET')) throw new Error('LIVEKIT_API_SECRET is not set');
if (process.env.LIVEKIT_URL.includes('REPLACE_ME')) {
throw new Error('LIVEKIT_URL is still the local placeholder');
}
const httpUrl = process.env.LIVEKIT_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
const svc = new RoomServiceClient(
httpUrl,
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
);
const rooms = await svc.listRooms();
return `${httpUrl}, ${rooms.length} room(s) visible`;
}
async function checkMongo() {
if (!isSet('MONGODB_URI')) throw new Error('MONGODB_URI is not set');
const client = new MongoClient(process.env.MONGODB_URI, { serverSelectionTimeoutMS: 5000 });
try {
await client.connect();
const db = client.db();
await db.command({ ping: 1 });
return `connected to ${db.databaseName}`;
} finally {
await client.close();
}
}
async function checkVectorIndex() {
if (!isSet('MONGODB_URI')) throw new Error('MONGODB_URI is not set');
const client = new MongoClient(process.env.MONGODB_URI, { serverSelectionTimeoutMS: 5000 });
try {
await client.connect();
const db = client.db();
const indexes = await db
.collection('collisions')
.listSearchIndexes('collision_embedding')
.toArray();
if (indexes.length === 0) throw new Error('Atlas Search index collision_embedding not found');
return 'collision_embedding search index found';
} finally {
await client.close();
}
}
async function checkGitHub() {
if (!isSet('GITHUB_TOKEN')) throw new Error('GITHUB_TOKEN is not set');
if (!isSet('GITHUB_REPO')) throw new Error('GITHUB_REPO is not set');
const res = await doFetch(`https://api.github.com/repos/${process.env.GITHUB_REPO}`, {
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'x-github-api-version': '2022-11-28',
},
});
if (!res.ok) throw new Error(`GitHub repo check returned ${res.status}`);
const body = await res.json();
return body.full_name ?? process.env.GITHUB_REPO;
}
async function checkGeminiVision() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: 'Return only: ok' }] }] }),
},
);
if (!res.ok) throw new Error(await responseError('Gemini vision check', res));
const body = await res.json();
const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text).join('') ?? '';
if (!text.trim()) throw new Error('Gemini vision response had no text');
return model;
}
async function checkGeminiLiveListed() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(
process.env.GEMINI_API_KEY,
)}`,
);
if (!res.ok) throw new Error(await responseError('Gemini model list', res));
const body = await res.json();
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
return model;
}
async function checkVoyage() {
if (!isSet('VOYAGE_API_KEY')) throw new Error('VOYAGE_API_KEY is not set');
const model = process.env.VOYAGE_EMBEDDING_MODEL ?? 'voyage-4-lite';
const res = await doFetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: 'podman deployment doctor', model, input_type: 'query' }),
});
if (!res.ok) throw new Error(await responseError('Voyage embedding check', res));
const body = await res.json();
const dims = body.data?.[0]?.embedding?.length;
if (!dims) throw new Error('Voyage response did not contain an embedding');
return `${model}, ${dims} dimensions`;
}
await check('workspace', checkWorkspace);
for (const name of requiredEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing');
}
for (const name of optionalEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
}
for (const name of frontendEnv) {
add(
`env:${name}`,
isSet(name) ? 'ok' : strict ? 'fail' : 'warn',
isSet(name) ? 'set' : 'required for production frontend builds',
);
}
await check('frontend shell', checkFrontendShell);
await check('backend health', checkBackendHealth);
await check('backend pods', checkBackendPods);
await check('backend token minting', checkBackendToken);
await check('livekit room service', checkLiveKitApi);
await check('mongo ping', checkMongo);
await check('github repo access', checkGitHub);
await check('gemini vision model', checkGeminiVision);
await check('gemini live model listed', checkGeminiLiveListed);
if (isSet('VOYAGE_API_KEY')) {
await check('voyage embeddings', checkVoyage);
await check('atlas vector index', checkVectorIndex);
} else {
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; exact Mongo recall remains active');
add('atlas vector index', 'warn', 'requires VOYAGE_API_KEY and Atlas Search index');
}
const failed = results.filter((r) => r.status === 'fail');
const warnings = results.filter((r) => r.status === 'warn');
for (const result of results) {
const mark = result.status.toUpperCase().padEnd(4);
console.log(`${mark} ${result.name}${result.detail ? ` - ${result.detail}` : ''}`);
}
console.log(
JSON.stringify(
{
ok: failed.length === 0,
strict,
failed: failed.length,
warnings: warnings.length,
},
null,
2,
),
);
if (strict && failed.length > 0) {
process.exitCode = 1;
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { cp, chmod, rm } from 'node:fs/promises';
const source = process.env.PODMAN_STATIC_SOURCE ?? 'frontend/dist';
const target = process.env.PODMAN_STATIC_TARGET ?? '/var/www/podman';
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true });
await chmod(target, 0o755);
const stack = [target];
while (stack.length) {
const dir = stack.pop();
const { readdir } = await import('node:fs/promises');
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
await chmod(path, 0o755);
stack.push(path);
} else {
await chmod(path, 0o644);
}
}
}
console.log(JSON.stringify({ ok: true, source, target }, null, 2));
+2 -1
View File
@@ -30,6 +30,7 @@ async function loadEnv() {
if (!process.env.MONGODB_URI) {
try {
const dotenv = await import('dotenv');
dotenv.config({ path: new URL('../.env', import.meta.url).pathname });
dotenv.config({ path: new URL('../backend/.env', import.meta.url).pathname });
} catch {
// dotenv not available — rely on process.env
@@ -37,7 +38,7 @@ async function loadEnv() {
}
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('Error: MONGODB_URI not set. Export it or add it to backend/.env');
console.error('Error: MONGODB_URI not set. Export it or add it to .env');
process.exit(1);
}
return uri;
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { setTimeout as delay } from 'node:timers/promises';
import { config as loadEnv } from 'dotenv';
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
loadEnv({ path: envPath, quiet: true });
const port = Number(process.env.VERIFY_BACKEND_PORT ?? 18978);
const baseUrl = `http://127.0.0.1:${port}`;
const mongoUri = process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/podman';
const doFetch = globalThis.fetch;
const env = {
...process.env,
PORT: String(port),
LIVEKIT_URL: process.env.LIVEKIT_URL ?? 'REPLACE_ME',
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
MONGODB_URI: mongoUri,
};
function fail(message) {
throw new Error(message);
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
delay(2000).then(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}),
]);
}
async function waitForHealth() {
for (let i = 0; i < 40; i++) {
try {
const res = await doFetch(`${baseUrl}/health`);
const body = await res.json();
if (body.ok === true) return;
} catch {
// server still starting
}
await delay(250);
}
fail('backend /health did not become ready');
}
async function json(res) {
const body = await res.json().catch(() => ({}));
if (!res.ok) fail(`${res.url} returned ${res.status}: ${JSON.stringify(body)}`);
return body;
}
async function verifyApi() {
const token = await json(
await doFetch(`${baseUrl}/api/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ room: 'verify-pod', identity: 'verify-user', name: 'Verify User' }),
}),
);
if (typeof token.token !== 'string' || token.token.split('.').length !== 3) {
fail('token endpoint did not return a JWT');
}
const podName = `Verify Pod ${Date.now()}`;
const created = await json(
await doFetch(`${baseUrl}/api/pods`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: podName,
repo: 'karti-ai/podman',
members: ['Alice', 'Bob'],
description: 'temporary backend verification pod',
}),
}),
);
if (!created.id || created.name !== podName) fail('pod create returned unexpected payload');
const withMember = await json(
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/members`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Hermes' }),
}),
);
if (!withMember.members.includes('Hermes')) fail('member add did not persist');
await json(
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
);
}
async function verifyCollisionAndMessages() {
const { detectCollisions } = await import('../backend/dist/collision/detector.js');
const { DATA_TOPIC } = await import('../shared/dist/messages.js');
if (DATA_TOPIC !== 'podman.intervention') fail('shared DATA_TOPIC changed unexpectedly');
const out = detectCollisions(
[
{
engineerId: 'alice',
podId: 'verify-pod',
currentFile: 'src/auth.ts',
currentSymbol: 'loadSession',
hasUnpushedChanges: true,
confidence: 1,
observedAt: new Date().toISOString(),
},
{
engineerId: 'bob',
podId: 'verify-pod',
currentFile: './auth.ts',
hasUnpushedChanges: false,
confidence: 1,
observedAt: new Date().toISOString(),
},
],
{ branches: { main: 'sha' } },
);
if (out.length !== 1 || out[0].file !== 'src/auth.ts')
fail('collision detector did not find expected overlap');
}
async function verifyMemoryRecall() {
process.env.LIVEKIT_URL = env.LIVEKIT_URL;
process.env.LIVEKIT_API_KEY = env.LIVEKIT_API_KEY;
process.env.LIVEKIT_API_SECRET = env.LIVEKIT_API_SECRET;
process.env.GEMINI_API_KEY = env.GEMINI_API_KEY;
process.env.GITHUB_TOKEN = env.GITHUB_TOKEN;
process.env.GITHUB_REPO = env.GITHUB_REPO;
process.env.MONGODB_URI = env.MONGODB_URI;
const { recordCollision } = await import('../backend/dist/memory/store.js');
const { recallSimilar } = await import('../backend/dist/memory/vectors.js');
const seed = {
id: `verify_memory_${Date.now()}`,
podId: 'verify-pod',
file: 'src/verify-memory.ts',
symbol: 'verifyMemory',
engineers: ['alice', 'bob'],
severity: 'warn',
githubState: { unpushed: true },
detectedAt: new Date().toISOString(),
};
await recordCollision(seed);
const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
if (!recalled) fail('memory recall did not find seeded collision');
}
async function verifyGitWatcher() {
const child = spawn(
process.execPath,
['scripts/podman-agent.mjs', '--name', 'verify-user', '--pod', 'verify-pod'],
{
env,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
for (let i = 0; i < 20; i++) {
if (output.includes('podman-agent started') && output.includes('verify-user@verify-pod')) {
await stopChild(child);
return;
}
await delay(250);
}
await stopChild(child);
fail(`git watcher did not produce expected output: ${output}`);
}
const server = spawn(process.execPath, ['backend/dist/server.js'], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverOutput = '';
server.stdout.on('data', (chunk) => {
serverOutput += chunk.toString();
});
server.stderr.on('data', (chunk) => {
serverOutput += chunk.toString();
});
let exitCode = 0;
try {
await waitForHealth();
await verifyApi();
await verifyCollisionAndMessages();
await verifyMemoryRecall();
await verifyGitWatcher();
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
mongoUri,
checks: ['health', 'token', 'pod-crud', 'collision', 'memory-recall', 'git-watcher'],
},
null,
2,
),
);
} catch (err) {
console.error(serverOutput);
console.error(err);
exitCode = 1;
} finally {
await stopChild(server);
}
process.exit(exitCode);
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { setTimeout as delay } from 'node:timers/promises';
import { config as loadEnv } from 'dotenv';
const image = process.env.VERIFY_CONTAINER_IMAGE ?? 'podman-backend';
const port = Number(process.env.VERIFY_CONTAINER_PORT ?? 8799);
const baseUrl = `http://127.0.0.1:${port}`;
const runId = `${process.pid}-${Date.now()}`;
const apiContainer = `podman-verify-api-${runId}`;
const agentContainer = `podman-verify-agent-${runId}`;
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
const doFetch = globalThis.fetch;
loadEnv({ path: envPath, quiet: true });
const containerEnv = {
PORT: String(port),
LIVEKIT_URL: process.env.LIVEKIT_URL ?? 'REPLACE_ME',
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/podman',
};
function runPodman(args, options = {}) {
return new Promise((resolve) => {
const child = spawn('podman', args, {
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` });
});
child.on('close', (code) => {
resolve({ code: code ?? 1, stdout, stderr });
});
});
}
function envArgs(extra = {}) {
return Object.entries({ ...containerEnv, ...extra }).flatMap(([key, value]) => [
'--env',
`${key}=${value}`,
]);
}
function fail(message) {
throw new Error(message);
}
async function assertPodmanAvailable() {
const result = await runPodman(['--version']);
if (result.code !== 0) fail(`podman is not available: ${result.stderr.trim()}`);
}
async function assertImageExists() {
const result = await runPodman(['image', 'exists', image]);
if (result.code !== 0) {
fail(
`container image "${image}" does not exist locally; build it before running this verifier`,
);
}
}
async function removeContainer(name) {
await runPodman(['rm', '-f', name]);
}
async function startContainer(name, extraEnv) {
await removeContainer(name);
const result = await runPodman([
'run',
'--detach',
'--name',
name,
'--network',
'host',
...envArgs(extraEnv),
image,
]);
if (result.code !== 0) {
fail(`failed to start ${name}: ${result.stderr.trim() || result.stdout.trim()}`);
}
}
async function stopContainer(name) {
await runPodman(['stop', '--time', '3', name]);
await removeContainer(name);
}
async function fetchJson(path) {
const res = await doFetch(`${baseUrl}${path}`);
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : null;
} catch {
fail(`${path} returned non-JSON response: ${text.slice(0, 200)}`);
}
if (!res.ok) fail(`${path} returned ${res.status}: ${JSON.stringify(body)}`);
return body;
}
async function waitForApi() {
let lastError = 'not attempted';
for (let i = 0; i < 60; i++) {
try {
const body = await fetchJson('/health');
if (body?.ok === true) return;
lastError = `unexpected /health body: ${JSON.stringify(body)}`;
} catch (error) {
lastError = error.message;
}
await delay(500);
}
const logs = await runPodman(['logs', apiContainer]);
fail(
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
);
}
async function verifyApiContainer() {
await startContainer(apiContainer, { PODMAN_PROCESS: 'server' });
await waitForApi();
const pods = await fetchJson('/api/pods');
if (!Array.isArray(pods)) fail(`/api/pods returned unexpected payload: ${JSON.stringify(pods)}`);
}
function requireLiveKitEnv() {
const missing = ['LIVEKIT_URL', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET'].filter((key) => {
const value = process.env[key];
return !value || value === 'REPLACE_ME';
});
if (missing.length) {
fail(`agent container verification requires real LiveKit env vars: ${missing.join(', ')}`);
}
}
async function verifyAgentContainer() {
requireLiveKitEnv();
await startContainer(agentContainer, { PODMAN_PROCESS: 'agent', POD_ROOM: 'demo-pod' });
let output = '';
for (let i = 0; i < 60; i++) {
const logs = await runPodman(['logs', agentContainer]);
output = `${logs.stdout}${logs.stderr}`;
if (output.includes('podman-hermes joined room')) return;
const inspect = await runPodman([
'inspect',
'--format',
'{{.State.Running}} {{.State.ExitCode}}',
agentContainer,
]);
if (inspect.code === 0 && inspect.stdout.trim().startsWith('false')) break;
await delay(500);
}
fail(`agent logs did not include "podman-hermes joined room":\n${output}`);
}
let exitCode = 0;
try {
await assertPodmanAvailable();
await assertImageExists();
await verifyApiContainer();
await verifyAgentContainer();
console.log(
JSON.stringify(
{
ok: true,
image,
baseUrl,
containers: [apiContainer, agentContainer],
checks: ['image-exists', 'api-health', 'api-pods', 'agent-joined-room'],
},
null,
2,
),
);
} catch (error) {
console.error(error);
exitCode = 1;
} finally {
await stopContainer(agentContainer);
await stopContainer(apiContainer);
}
process.exit(exitCode);
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import { setTimeout as delay } from 'node:timers/promises';
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
const shouldStartPreview = !process.env.FRONTEND_URL;
const doFetch = globalThis.fetch;
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
delay(3000).then(() => {
if (child.exitCode === null && child.signalCode === null) {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
child.kill('SIGKILL');
}
}
}),
]);
}
async function waitForPreview() {
for (let i = 0; i < 50; i++) {
try {
const res = await doFetch(frontendUrl);
if (res.ok) return;
} catch {
// Preview is still starting.
}
await delay(200);
}
throw new Error(`frontend preview did not become ready at ${frontendUrl}`);
}
let preview = null;
if (shouldStartPreview) {
preview = spawn(
'pnpm',
['--filter', '@podman/frontend', 'preview', '--host', '127.0.0.1', '--port', '4173'],
{
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
await waitForPreview();
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', (err) => pageErrors.push(err.message));
page.on('requestfailed', (req) => {
failedRequests.push(`${req.url()} ${req.failure()?.errorText ?? ''}`.trim());
});
try {
await page.goto(frontendUrl, { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(500);
const bodyText = await page.locator('body').innerText();
const hasPodCards =
(await page.locator('text=/Frontend Pod|Backend Pod|graph pod/i').count()) > 0;
const hasOverlay = (await page.locator('vite-error-overlay, .vite-error-overlay').count()) > 0;
if (bodyText.length < 100) throw new Error('frontend rendered too little text');
if (!hasPodCards) throw new Error('pod cards did not render');
if (hasOverlay) throw new Error('Vite error overlay is visible');
await page.getByRole('button', { name: 'Join' }).first().click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const joinedText = await page.locator('body').innerText();
const hasPodView =
joinedText.includes('Leave pod') &&
joinedText.includes('Share screen') &&
(joinedText.includes('Test audio') || joinedText.includes('Play beat'));
if (!hasPodView) {
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
}
if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`);
if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`);
if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`);
console.log(
JSON.stringify(
{
ok: true,
frontendUrl,
bodyLength: bodyText.length,
joined: true,
},
null,
2,
),
);
} finally {
await browser.close();
await stopChild(preview);
}