Merge origin/main into feat/graph-data-viz
Resolve App.tsx header conflict: keep main's "Privacy-limited" badge AND the "Team memory" graph button (both in the header action group). server.ts and backend/package.json auto-merged cleanly — main's closeMemory/shutdown and the graph:seed script are both preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@ build
|
||||
pnpm-lock.yaml
|
||||
.omc
|
||||
*.log
|
||||
.agents
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 })],
|
||||
];
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Collision | null> {
|
||||
// TODO(memory): embed collision.file via Voyage, query Atlas vector index
|
||||
return null;
|
||||
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
|
||||
return (await recallByVector(collision)) ?? recallBySignature(collision);
|
||||
}
|
||||
|
||||
+16
-1
@@ -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<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'));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+55
-20
@@ -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 (
|
||||
<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={() => setGraphPodId(pods[0]?.id ?? 'demo-pod')}>
|
||||
<BrainCircuitIcon data-icon="inline-start" />
|
||||
Team memory
|
||||
@@ -272,7 +279,7 @@ export default function App() {
|
||||
</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)} />
|
||||
@@ -304,14 +311,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 ? (
|
||||
@@ -335,24 +344,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>
|
||||
@@ -370,18 +396,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+14
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/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;
|
||||
const verifyMember = `Verify ${process.pid}`;
|
||||
|
||||
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') return;
|
||||
const text = msg.text();
|
||||
if (text.includes('net::ERR_NETWORK_CHANGED')) return;
|
||||
consoleErrors.push(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.getByPlaceholder('Your name').first().fill(verifyMember);
|
||||
await page.getByRole('button', { name: 'Add and 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,
|
||||
member: verifyMember,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
const apiBase = process.env.FRONTEND_URL
|
||||
? new URL(process.env.FRONTEND_URL).origin
|
||||
: 'http://localhost:8787';
|
||||
await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
await browser.close();
|
||||
await stopChild(preview);
|
||||
}
|
||||
Reference in New Issue
Block a user