feat: harden podman deployment orchestration
This commit is contained in:
@@ -10,11 +10,18 @@ GEMINI_API_KEY=
|
|||||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||||
GEMINI_LIVE_MODEL=gemini-live-2.5-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 Atlas (engineer state, ownership map, events, nudges) ---
|
||||||
MONGODB_URI=mongodb+srv://<user>:<pass>@cluster.mongodb.net/podman
|
MONGODB_URI=mongodb+srv://<user>:<pass>@cluster.mongodb.net/podman
|
||||||
|
VOYAGE_API_KEY=
|
||||||
|
VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
||||||
|
|
||||||
# --- Backend server ---
|
# --- Backend server ---
|
||||||
PORT=8787
|
PORT=8787
|
||||||
|
POD_ROOM=demo-pod
|
||||||
|
|
||||||
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
||||||
NUDGE_COOLDOWN_MS=180000
|
NUDGE_COOLDOWN_MS=180000
|
||||||
@@ -22,3 +29,7 @@ NUDGE_COOLDOWN_MS=180000
|
|||||||
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
||||||
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||||
VITE_BACKEND_URL=http://localhost:8787
|
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
|
pnpm-lock.yaml
|
||||||
.omc
|
.omc
|
||||||
*.log
|
*.log
|
||||||
|
.agents
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "pnpm run dev:server",
|
||||||
"dev:server": "tsx watch src/server.ts",
|
"dev:server": "tsx watch src/server.ts",
|
||||||
"dev:agent": "tsx watch src/agent.ts",
|
"dev:agent": "tsx watch src/agent.ts",
|
||||||
"start": "node dist/server.js",
|
"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",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import { env } from './env.js';
|
|||||||
import { PodMan } from './agent/podman.js';
|
import { PodMan } from './agent/podman.js';
|
||||||
|
|
||||||
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
|
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
|
const SAMPLE_INTERVAL_MS = 1000; // ~1 fps to the vision model
|
||||||
|
|
||||||
async function agentToken(room: string): Promise<string> {
|
async function agentToken(room: string): Promise<string> {
|
||||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||||
identity: 'podman-agent',
|
identity: HERMES_IDENTITY,
|
||||||
name: 'PodMan',
|
name: 'PodMan',
|
||||||
ttl: '4h',
|
ttl: '4h',
|
||||||
});
|
});
|
||||||
@@ -36,7 +37,7 @@ async function main() {
|
|||||||
dynacast: true,
|
dynacast: true,
|
||||||
});
|
});
|
||||||
await podman.start();
|
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>();
|
const lastSent = new Map<string, number>();
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -16,14 +16,15 @@ export const env = {
|
|||||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||||
// Gemini
|
// Gemini
|
||||||
GEMINI_API_KEY: req('GEMINI_API_KEY'),
|
GEMINI_API_KEY: req('GEMINI_API_KEY'),
|
||||||
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-3.5-flash'),
|
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||||
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-live-preview'),
|
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'),
|
||||||
// GitHub
|
// GitHub
|
||||||
GITHUB_TOKEN: req('GITHUB_TOKEN'),
|
GITHUB_TOKEN: req('GITHUB_TOKEN'),
|
||||||
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
|
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
|
||||||
// Mongo + Voyage
|
// Mongo + Voyage
|
||||||
MONGODB_URI: req('MONGODB_URI'),
|
MONGODB_URI: req('MONGODB_URI'),
|
||||||
VOYAGE_API_KEY: opt('VOYAGE_API_KEY'),
|
VOYAGE_API_KEY: opt('VOYAGE_API_KEY'),
|
||||||
|
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||||
// Server
|
// Server
|
||||||
PORT: Number(opt('PORT', '8787')),
|
PORT: Number(opt('PORT', '8787')),
|
||||||
} as const;
|
} 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();
|
return client.db();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeMemory(): Promise<void> {
|
||||||
|
if (!clientPromise) return;
|
||||||
|
const client = await clientPromise;
|
||||||
|
clientPromise = null;
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
export interface PodCollections {
|
export interface PodCollections {
|
||||||
pods: Collection<Pod>;
|
pods: Collection<Pod>;
|
||||||
observations: Collection<EngineerContext>;
|
observations: Collection<EngineerContext>;
|
||||||
@@ -88,6 +95,10 @@ export async function initMemory(): Promise<void> {
|
|||||||
['observations.podId', () => c.observations.createIndex({ podId: 1, observedAt: -1 })],
|
['observations.podId', () => c.observations.createIndex({ podId: 1, observedAt: -1 })],
|
||||||
['observations.engineerId', () => c.observations.createIndex({ engineerId: 1 })],
|
['observations.engineerId', () => c.observations.createIndex({ engineerId: 1 })],
|
||||||
['collisions.podId', () => c.collisions.createIndex({ podId: 1, detectedAt: -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 })],
|
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||||
import { collections } from './db.js';
|
import { collections } from './db.js';
|
||||||
|
import { enrichCollisionMemory } from './vectors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continual-learning memory: persist observations, collisions, interventions,
|
* 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> {
|
export async function recordCollision(collision: Collision): Promise<void> {
|
||||||
await persist('collision', async () =>
|
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 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).
|
* Recall prior collision patterns. Exact Mongo recall is always available;
|
||||||
* Stub: returns null until Voyage + Atlas Vector Search are wired.
|
* Voyage + Atlas Vector Search is used first when configured.
|
||||||
*/
|
*/
|
||||||
export async function recallSimilar(_collision: Collision): Promise<Collision | null> {
|
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
|
||||||
// TODO(memory): embed collision.file via Voyage, query Atlas vector index
|
return (await recallByVector(collision)) ?? recallBySignature(collision);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -6,7 +6,7 @@ import { AccessToken, RoomConfiguration } from 'livekit-server-sdk';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { createSyncPr } from './github/client.js';
|
import { createSyncPr } from './github/client.js';
|
||||||
import { recordOutcome, memoryStats } from './memory/store.js';
|
import { recordOutcome, memoryStats } from './memory/store.js';
|
||||||
import { initMemory } from './memory/db.js';
|
import { closeMemory, initMemory } from './memory/db.js';
|
||||||
import {
|
import {
|
||||||
listPods,
|
listPods,
|
||||||
getPod,
|
getPod,
|
||||||
@@ -161,3 +161,18 @@ http.listen(env.PORT, '0.0.0.0', () => {
|
|||||||
.then(() => seedDefaultPods())
|
.then(() => seedDefaultPods())
|
||||||
.catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`));
|
.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.
|
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
|
||||||
* Stub: logs until Gemini Live audio track wiring is complete.
|
* 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> {
|
export async function speak(room: Room, message: string): Promise<void> {
|
||||||
// TODO(voice): use Gemini Live streaming TTS -> publish audio track into room
|
await publishVoiceCue(room, message);
|
||||||
console.log(`[voice] ${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
|
# 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
|
Use [`infra/app.yaml`](../infra/app.yaml):
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
doctl apps create --spec infra/app.yaml
|
||||||
```
|
```
|
||||||
# LiveKit
|
|
||||||
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
|
||||||
LIVEKIT_API_KEY=
|
|
||||||
LIVEKIT_API_SECRET=
|
|
||||||
|
|
||||||
# Gemini
|
The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows.
|
||||||
GEMINI_API_KEY=
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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_VISION_MODEL=gemini-2.0-flash
|
||||||
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
||||||
|
|
||||||
# MongoDB Atlas
|
GITHUB_TOKEN=...
|
||||||
|
GITHUB_REPO=karti-ai/podman
|
||||||
|
|
||||||
MONGODB_URI=mongodb+srv://...
|
MONGODB_URI=mongodb+srv://...
|
||||||
|
VOYAGE_API_KEY=...
|
||||||
|
VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=8787
|
PORT=8787
|
||||||
|
POD_ROOM=demo-pod
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
`VOYAGE_API_KEY` is optional for local/demo fallback. Without it, Mongo exact
|
||||||
|
signature recall still works; Atlas Vector Search recall is skipped.
|
||||||
## 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"]`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## App Platform spec (`infra/app.yaml`)
|
## Container Checks
|
||||||
|
|
||||||
Already scaffolded. Key fields to confirm before deploy:
|
Build once:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```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 }`
|
1. PWA calls `POST /pods/:podId/token` → receives `{ token, url }`
|
||||||
2. LiveKit client connects to the room with the token
|
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
|
4. PWA sets mic enabled for ambient presence
|
||||||
|
|
||||||
**Receiving:**
|
**Receiving:**
|
||||||
@@ -48,7 +48,7 @@ room.on(RoomEvent.DataReceived, (payload, participant) => {
|
|||||||
**Startup:**
|
**Startup:**
|
||||||
|
|
||||||
1. Hermes mints its own token via the same `createPodToken` function with `identity: 'podman-hermes'`
|
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
|
3. Registers as a LiveKit Agent with Gemini Live 2.5 as voice provider
|
||||||
|
|
||||||
**Voice delivery:**
|
**Voice delivery:**
|
||||||
@@ -78,7 +78,7 @@ room.localParticipant.publishData(
|
|||||||
|
|
||||||
## Token endpoint
|
## Token endpoint
|
||||||
|
|
||||||
Already implemented at `POST /pods/:podId/token`.
|
Already implemented at `POST /api/token`.
|
||||||
|
|
||||||
Hermes uses the same endpoint. Grants:
|
Hermes uses the same endpoint. Grants:
|
||||||
|
|
||||||
@@ -99,7 +99,6 @@ Hermes uses the same endpoint. Grants:
|
|||||||
|
|
||||||
## What LiveKit does NOT do in PodMan
|
## 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 video tracks from Hermes
|
||||||
- No mic transcription (not needed for v1)
|
- No mic transcription (not needed for v1)
|
||||||
- No SFU mixing — standard room behavior is sufficient
|
- No SFU mixing — standard room behavior is sufficient
|
||||||
|
|||||||
+95
-58
@@ -9,6 +9,7 @@ import {
|
|||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Pod, PodInput } from '@podman/shared';
|
import type { Pod, PodInput } from '@podman/shared';
|
||||||
import { joinPod } from './lib/pod.js';
|
import { joinPod } from './lib/pod.js';
|
||||||
@@ -233,34 +234,42 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<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="flex flex-col gap-5 border-b pb-5">
|
<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 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="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
|
PM
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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'}>
|
<Badge variant={podManOnline ? 'default' : 'secondary'}>
|
||||||
<CircleDotIcon data-icon="inline-start" />
|
<CircleDotIcon data-icon="inline-start" />
|
||||||
{podManOnline ? 'online' : 'standby'}
|
{podManOnline ? 'online' : 'standby'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Quiet coordination for live engineering rooms.
|
Live engineering rooms, team memory, and intervention routing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<Badge variant="outline" className="h-8 rounded-lg px-3">
|
||||||
Refresh
|
<ShieldCheckIcon data-icon="inline-start" />
|
||||||
</Button>
|
Privacy-limited
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||||
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</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={WifiIcon} label="Live" value={fmt.format(liveTotal)} />
|
||||||
<StatPill icon={RadioTowerIcon} label="Rooms" value={fmt.format(activeRooms)} />
|
<StatPill icon={RadioTowerIcon} label="Rooms" value={fmt.format(activeRooms)} />
|
||||||
<StatPill icon={UsersIcon} label="Roster" value={fmt.format(totalMembers)} />
|
<StatPill icon={UsersIcon} label="Roster" value={fmt.format(totalMembers)} />
|
||||||
@@ -292,55 +301,74 @@ export default function App() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<main className="flex min-w-0 flex-1 flex-col gap-4">
|
<main className="grid min-w-0 flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<section className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<h2 className="text-lg font-medium">Workspaces</h2>
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs font-medium uppercase text-muted-foreground">Workspaces</p>
|
||||||
Join a room, publish your screen, and let PodMan watch for overlap.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
<PodSkeleton />
|
<PodSkeleton />
|
||||||
<PodSkeleton />
|
<PodSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : pods.length ? (
|
) : pods.length ? (
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
{pods.map((pod) => (
|
{pods.map((pod) => (
|
||||||
<PodCard
|
<PodCard
|
||||||
key={pod.id}
|
key={pod.id}
|
||||||
pod={pod}
|
pod={pod}
|
||||||
busy={pending.has(pod.id)}
|
busy={pending.has(pod.id)}
|
||||||
presence={presence[pod.id] ?? []}
|
presence={presence[pod.id] ?? []}
|
||||||
onJoin={handleJoin}
|
onJoin={handleJoin}
|
||||||
onAddAndJoin={handleAddAndJoin}
|
onAddAndJoin={handleAddAndJoin}
|
||||||
onAddMember={handleAddMember}
|
onAddMember={handleAddMember}
|
||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 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>
|
||||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
|
</Card>
|
||||||
</div>
|
</aside>
|
||||||
) : (
|
|
||||||
<Empty className="min-h-[360px] border">
|
|
||||||
<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>
|
|
||||||
</EmptyHeader>
|
|
||||||
<EmptyContent>
|
|
||||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact />
|
|
||||||
</EmptyContent>
|
|
||||||
</Empty>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -358,18 +386,27 @@ function StatPill({
|
|||||||
value: string;
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-xl border bg-card px-3 py-2">
|
<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-lg bg-muted">
|
<div className="grid size-8 place-items-center rounded-md bg-muted">
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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>
|
<p className="text-base font-medium">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
function PodSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ export function CreatePodForm({
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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)}
|
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" />
|
<PlusIcon className="size-4 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium">New pod</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')}>
|
<Card className={cn(compact && 'w-full border-0 shadow-none ring-0')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>New pod</CardTitle>
|
<CardTitle>New pod</CardTitle>
|
||||||
<CardDescription>Keep the room name short and specific.</CardDescription>
|
<CardDescription>Create a focused room.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
@@ -117,7 +117,7 @@ export function CreatePodForm({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={submit} disabled={busy || !name.trim()}>
|
<Button className="min-w-20" onClick={submit} disabled={busy || !name.trim()}>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ export function PodCard({
|
|||||||
|
|
||||||
return (
|
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>
|
<CardHeader>
|
||||||
<CardTitle className="truncate">{pod.name}</CardTitle>
|
<CardTitle className="truncate text-[1.05rem]">{pod.name}</CardTitle>
|
||||||
<CardDescription className="truncate">{pod.repo || 'No repository set'}</CardDescription>
|
<CardDescription className="truncate font-mono text-xs">
|
||||||
|
{pod.repo || 'No repository set'}
|
||||||
|
</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -121,7 +123,7 @@ export function PodCard({
|
|||||||
{pod.description || 'Focused workspace for live engineering coordination.'}
|
{pod.description || 'Focused workspace for live engineering coordination.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={active ? 'default' : 'secondary'}>
|
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
|
||||||
{active ? `${presence.length} live` : 'quiet'}
|
{active ? `${presence.length} live` : 'quiet'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +177,11 @@ export function PodCard({
|
|||||||
<VideoIcon data-icon="inline-start" />
|
<VideoIcon data-icon="inline-start" />
|
||||||
Join
|
Join
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => submitMember(true)} disabled={busy || !newMember.trim()}>
|
<Button
|
||||||
|
className="min-w-28"
|
||||||
|
onClick={() => submitMember(true)}
|
||||||
|
disabled={busy || !newMember.trim()}
|
||||||
|
>
|
||||||
Add and join
|
Add and join
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ export function PodView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<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="flex items-center justify-between gap-4 border-b pb-4">
|
<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">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -200,13 +200,15 @@ export function PodView({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
|
<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>
|
</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>
|
</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}>
|
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
|
||||||
<Volume2Icon data-icon="inline-start" />
|
<Volume2Icon data-icon="inline-start" />
|
||||||
{playingBeat ? 'Stop audio' : 'Test audio'}
|
{playingBeat ? 'Stop audio' : 'Test audio'}
|
||||||
@@ -244,8 +246,8 @@ export function PodView({
|
|||||||
|
|
||||||
<Card className="flex-1">
|
<Card className="flex-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Room</CardTitle>
|
<CardTitle>Room state</CardTitle>
|
||||||
<CardDescription>Only the signals that matter right now.</CardDescription>
|
<CardDescription>People and media currently visible to PodMan.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
@@ -278,9 +280,9 @@ export function PodView({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Intervention</CardTitle>
|
<CardTitle>Intervention</CardTitle>
|
||||||
<CardDescription>PodMan stays quiet until the signal is useful.</CardDescription>
|
<CardDescription>Card first, voice only for urgent escalation.</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<Badge variant={active ? 'default' : 'secondary'}>
|
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
|
||||||
{active ? 'active' : 'clear'}
|
{active ? 'active' : 'clear'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
@@ -288,7 +290,7 @@ export function PodView({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{active ? (
|
{active ? (
|
||||||
<div className="flex flex-col gap-4">
|
<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>
|
<p className="text-sm leading-6">{active.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
@@ -306,8 +308,8 @@ export function PodView({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>No collision detected</EmptyTitle>
|
<EmptyTitle>No collision detected</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
Share your screen when ready. The agent will surface only meaningful
|
Share your screen when ready. PodMan will stay quiet until there is a useful
|
||||||
overlap.
|
signal.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
@@ -372,7 +374,7 @@ function Participant({ participant }: { participant: PInfo }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
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>
|
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={participant.speaking ? 'default' : 'secondary'}>
|
<Badge variant={participant.speaking ? 'default' : 'secondary'} className="rounded-md">
|
||||||
{participant.speaking ? 'speaking' : 'connected'}
|
{participant.speaking ? 'speaking' : 'connected'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from 'radix-ui';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -23,14 +23,13 @@ const buttonVariants = cva(
|
|||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
'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",
|
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-[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",
|
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',
|
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: 'size-8',
|
||||||
'icon-xs':
|
'icon-xs':
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-md in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
'icon-sm':
|
'icon-sm': 'size-7 rounded-md in-data-[slot=button-group]:rounded-md',
|
||||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
|
||||||
'icon-lg': 'size-9',
|
'icon-lg': 'size-9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -77,7 +77,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+25
-23
@@ -7,33 +7,33 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--background: oklch(0.985 0 0);
|
--background: oklch(0.986 0.002 247.84);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.17 0.004 255.9);
|
||||||
--card: oklch(1 0 0);
|
--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: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.17 0.004 255.9);
|
||||||
--primary: oklch(0.18 0 0);
|
--primary: oklch(0.19 0.004 255.9);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.96 0 0);
|
--secondary: oklch(0.956 0.003 247.86);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.26 0.004 255.9);
|
||||||
--muted: oklch(0.955 0 0);
|
--muted: oklch(0.962 0.003 247.86);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.52 0.006 255.9);
|
||||||
--accent: oklch(0.955 0 0);
|
--accent: oklch(0.955 0.004 247.86);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.24 0.004 255.9);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.905 0.004 247.86);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.905 0.004 247.86);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.62 0.012 255.9);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.67 0.12 250);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.68 0.11 155);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.72 0.14 80);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.63 0.16 20);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.55 0.11 290);
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.986 0.002 247.84);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.17 0.004 255.9);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.24 0.004 255.9);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
@@ -47,6 +47,8 @@ body {
|
|||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 248, 250, 0.98)), var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { InterventionOutcome, Pod, PodInput } from '@podman/shared';
|
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 {
|
export interface MemoryStats {
|
||||||
observations: number;
|
observations: number;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Room, RoomEvent } from 'livekit-client';
|
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. */
|
/** Token + LiveKit URL minted by the backend. */
|
||||||
export async function fetchPodToken(
|
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
|
name: podman
|
||||||
region: nyc
|
region: nyc
|
||||||
|
|
||||||
static_sites:
|
static_sites:
|
||||||
- name: web
|
- name: web
|
||||||
github:
|
github:
|
||||||
repo: <org>/Podman
|
repo: karti-ai/podman
|
||||||
branch: main
|
branch: main
|
||||||
deploy_on_push: true
|
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
|
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:
|
routes:
|
||||||
- path: /
|
- path: /
|
||||||
|
envs:
|
||||||
|
- key: VITE_BACKEND_URL
|
||||||
|
scope: BUILD_TIME
|
||||||
|
value: ${APP_URL}
|
||||||
|
- key: VITE_LIVEKIT_URL
|
||||||
|
scope: BUILD_TIME
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- name: api
|
- name: api
|
||||||
github:
|
github:
|
||||||
repo: <org>/Podman
|
repo: karti-ai/podman
|
||||||
branch: main
|
branch: main
|
||||||
deploy_on_push: true
|
deploy_on_push: true
|
||||||
source_dir: backend
|
source_dir: /
|
||||||
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
|
dockerfile_path: infra/Dockerfile
|
||||||
run_command: node dist/server.js
|
|
||||||
http_port: 8787
|
http_port: 8787
|
||||||
instance_size_slug: apps-s-1vcpu-1gb
|
instance_size_slug: apps-s-1vcpu-1gb
|
||||||
instance_count: 1
|
instance_count: 1
|
||||||
|
health_check:
|
||||||
|
http_path: /health
|
||||||
routes:
|
routes:
|
||||||
- path: /api
|
- path: /api
|
||||||
|
preserve_path_prefix: true
|
||||||
|
- path: /health
|
||||||
envs:
|
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_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: LIVEKIT_API_SECRET, 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_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: 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:
|
workers:
|
||||||
- name: podman-agent
|
- name: podman-agent
|
||||||
github:
|
github:
|
||||||
repo: <org>/Podman
|
repo: karti-ai/podman
|
||||||
branch: main
|
branch: main
|
||||||
deploy_on_push: true
|
deploy_on_push: true
|
||||||
source_dir: backend
|
source_dir: /
|
||||||
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
|
dockerfile_path: infra/Dockerfile
|
||||||
run_command: node dist/agent.js
|
|
||||||
instance_size_slug: apps-s-1vcpu-1gb
|
instance_size_slug: apps-s-1vcpu-1gb
|
||||||
instance_count: 1
|
instance_count: 1
|
||||||
envs:
|
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_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: LIVEKIT_API_SECRET, 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_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-3.5-flash }
|
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-live-preview }
|
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
- { 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: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: VOYAGE_API_KEY, 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 .
|
# 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
|
FROM node:24-slim AS base
|
||||||
ENV PNPM_HOME=/pnpm
|
ENV PNPM_HOME=/pnpm
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
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
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ RUN pnpm --filter @podman/shared build && pnpm --filter @podman/backend build
|
|||||||
# Runtime
|
# Runtime
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PODMAN_PROCESS=server
|
||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
EXPOSE 8787
|
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.
|
Deploy targets for PodMan on DigitalOcean.
|
||||||
|
|
||||||
- `Dockerfile` — builds the Hermes backend from the monorepo root
|
- `Dockerfile` — builds the backend runtime image from the monorepo root
|
||||||
- `app.yaml` — DigitalOcean App Platform spec (Hermes web service + frontend static site)
|
- `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).
|
Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md).
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter backend dev # Hermes on :8787
|
pnpm --filter @podman/backend dev:server
|
||||||
pnpm --filter frontend dev # PWA on :5173
|
pnpm --filter @podman/backend dev:agent
|
||||||
|
pnpm --filter @podman/frontend dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local container
|
## Local container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f infra/Dockerfile -t podman-hermes .
|
docker build -f infra/Dockerfile -t podman-backend .
|
||||||
docker run --env-file .env -p 8787:8787 podman-hermes
|
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
|
## DigitalOcean deploy
|
||||||
@@ -27,8 +55,30 @@ docker run --env-file .env -p 8787:8787 podman-hermes
|
|||||||
doctl apps create --spec infra/app.yaml
|
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)
|
## 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.
|
||||||
|
|||||||
+63
-8
@@ -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
|
# Deploy: doctl apps create --spec infra/app.yaml
|
||||||
name: podman
|
name: podman
|
||||||
region: nyc
|
region: nyc
|
||||||
services:
|
|
||||||
- name: backend
|
static_sites:
|
||||||
dockerfile_path: infra/Dockerfile
|
- name: web
|
||||||
source_dir: /
|
|
||||||
github:
|
github:
|
||||||
repo: karti-ai/podman
|
repo: karti-ai/podman
|
||||||
branch: main
|
branch: main
|
||||||
deploy_on_push: true
|
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
|
http_port: 8787
|
||||||
instance_size_slug: basic-xxs
|
instance_size_slug: apps-s-1vcpu-1gb
|
||||||
instance_count: 1
|
instance_count: 1
|
||||||
health_check:
|
health_check:
|
||||||
http_path: /health
|
http_path: /health
|
||||||
|
routes:
|
||||||
|
- path: /api
|
||||||
|
preserve_path_prefix: true
|
||||||
|
- path: /health
|
||||||
envs:
|
envs:
|
||||||
|
- { key: PODMAN_PROCESS, scope: RUN_TIME, value: server }
|
||||||
|
- { key: PORT, scope: RUN_TIME, value: '8787' }
|
||||||
- { key: LIVEKIT_URL, scope: RUN_TIME }
|
- { key: LIVEKIT_URL, scope: RUN_TIME }
|
||||||
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
|
- { key: LIVEKIT_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: LIVEKIT_API_SECRET, 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_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_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: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: VOYAGE_API_KEY, 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:frontend": "pnpm --filter @podman/frontend dev",
|
||||||
"dev:backend": "pnpm --filter @podman/backend dev",
|
"dev:backend": "pnpm --filter @podman/backend dev",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
|
"build:container": "docker build -f infra/Dockerfile -t podman-backend .",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"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 .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
@@ -22,6 +33,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"prettier": "^3.9.0",
|
"prettier": "^3.9.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.62.0"
|
"typescript-eslint": "^8.62.0"
|
||||||
@@ -32,5 +44,10 @@
|
|||||||
"@google/genai",
|
"@google/genai",
|
||||||
"protobufjs"
|
"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:
|
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:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
@@ -17,6 +27,9 @@ importers:
|
|||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.6.0
|
specifier: ^10.6.0
|
||||||
version: 10.6.0(jiti@2.7.0)
|
version: 10.6.0(jiti@2.7.0)
|
||||||
|
playwright:
|
||||||
|
specifier: ^1.61.1
|
||||||
|
version: 1.61.1
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.9.0
|
specifier: ^3.9.0
|
||||||
version: 3.9.0
|
version: 3.9.0
|
||||||
@@ -31,7 +44,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@google/genai':
|
'@google/genai':
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.10.0
|
version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))
|
||||||
'@livekit/rtc-node':
|
'@livekit/rtc-node':
|
||||||
specifier: ^0.13.29
|
specifier: ^0.13.29
|
||||||
version: 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) {
|
if (!process.env.MONGODB_URI) {
|
||||||
try {
|
try {
|
||||||
const dotenv = await import('dotenv');
|
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 });
|
dotenv.config({ path: new URL('../backend/.env', import.meta.url).pathname });
|
||||||
} catch {
|
} catch {
|
||||||
// dotenv not available — rely on process.env
|
// dotenv not available — rely on process.env
|
||||||
@@ -37,7 +38,7 @@ async function loadEnv() {
|
|||||||
}
|
}
|
||||||
const uri = process.env.MONGODB_URI;
|
const uri = process.env.MONGODB_URI;
|
||||||
if (!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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
return uri;
|
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,116 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
|
||||||
|
const shouldStartPreview = !process.env.FRONTEND_URL;
|
||||||
|
const doFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
async function stopChild(child) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve) => child.once('exit', resolve)),
|
||||||
|
delay(3000).then(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPreview() {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try {
|
||||||
|
const res = await doFetch(frontendUrl);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch {
|
||||||
|
// Preview is still starting.
|
||||||
|
}
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
throw new Error(`frontend preview did not become ready at ${frontendUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview = null;
|
||||||
|
if (shouldStartPreview) {
|
||||||
|
preview = spawn(
|
||||||
|
'pnpm',
|
||||||
|
['--filter', '@podman/frontend', 'preview', '--host', '127.0.0.1', '--port', '4173'],
|
||||||
|
{
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitForPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
|
||||||
|
const consoleErrors = [];
|
||||||
|
const pageErrors = [];
|
||||||
|
const failedRequests = [];
|
||||||
|
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||||
|
page.on('requestfailed', (req) => {
|
||||||
|
failedRequests.push(`${req.url()} ${req.failure()?.errorText ?? ''}`.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(frontendUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const bodyText = await page.locator('body').innerText();
|
||||||
|
const hasPodCards =
|
||||||
|
(await page.locator('text=/Frontend Pod|Backend Pod|graph pod/i').count()) > 0;
|
||||||
|
const hasOverlay = (await page.locator('vite-error-overlay, .vite-error-overlay').count()) > 0;
|
||||||
|
|
||||||
|
if (bodyText.length < 100) throw new Error('frontend rendered too little text');
|
||||||
|
if (!hasPodCards) throw new Error('pod cards did not render');
|
||||||
|
if (hasOverlay) throw new Error('Vite error overlay is visible');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Join' }).first().click();
|
||||||
|
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const joinedText = await page.locator('body').innerText();
|
||||||
|
const hasPodView =
|
||||||
|
joinedText.includes('Leave pod') &&
|
||||||
|
joinedText.includes('Share screen') &&
|
||||||
|
(joinedText.includes('Test audio') || joinedText.includes('Play beat'));
|
||||||
|
|
||||||
|
if (!hasPodView) {
|
||||||
|
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`);
|
||||||
|
if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`);
|
||||||
|
if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
frontendUrl,
|
||||||
|
bodyLength: bodyText.length,
|
||||||
|
joined: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await stopChild(preview);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user