12 Commits

Author SHA1 Message Date
sb-iam 07509c62bd docs(graph): record the shadcn/ruixen component convention
UI composes from the ruixen registry (npx shadcn add ruixen.com/r/[component])
via @/components/ui/* + design tokens; only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:05 -07:00
sb-iam a49501b3c4 refactor(ui): compose Team Memory from the shadcn/ruixen primitives
Per the unifying template (npx shadcn add ruixen.com/r/[component]): the chrome
now uses Button + Badge + the app's Tailwind utility patterns (matching StatPill/
App.tsx) inside the page shell, instead of a hand-rolled .pm-* stylesheet. Only
the SVG node-link canvas stays bespoke (3 SVG-only CSS rules). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:46:48 -07:00
sb-iam 7c572fbbcb feat(ui): re-theme Team Memory graph to match the shadcn app
The app is light shadcn; GraphView was hardcoded dark-Bauhaus and clashed.
Re-theme to shadcn tokens (var(--card)/--foreground/--muted-foreground/
--border, var(--primary) for active) so the panel is theme-aware (light now,
follows dark mode if toggled) and native to the command-center. Node-kind
encoding kept (shapes + light-readable hues); labels are sentence-case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:39:04 -07:00
Yahya Alhinai 39a06499af feat: add local vector recall fallback 2026-06-28 02:27:45 +00:00
Yahya Alhinai 146d7e4bd2 test: verify frontend screen sharing 2026-06-28 02:20:58 +00:00
Yahya Alhinai 4253921532 Add Hermes operations management layer 2026-06-28 02:17:23 +00:00
Yahya Alhinai 89893110f1 feat: activate gemini voice tts path 2026-06-28 02:11:02 +00:00
Yahya Alhinai 4726e8ce80 test: make frontend verifier target frontend pod 2026-06-28 02:02:06 +00:00
Yahya Alhinai 1088196ba6 test: verify graph surfaces end to end 2026-06-28 01:59:58 +00:00
sb-iam b092a24941 Merge pull request #2 from karti-ai/feat/graph-data-viz
feat(graph): continual-learning graph data model + dark-Bauhaus viz
2026-06-27 18:56:00 -07:00
Yahya Alhinai 3d99dd2449 fix: proxy LiveKit subdomain to cloud service 2026-06-28 01:53:20 +00:00
Yahya Alhinai 205939616b chore: add public healthcheck watchdog 2026-06-28 01:53:03 +00:00
40 changed files with 1733 additions and 347 deletions
+19 -1
View File
@@ -7,8 +7,11 @@ LIVEKIT_API_SECRET=
# --- Gemini (vision + event detection + voice) --- # --- Gemini (vision + event detection + voice) ---
GEMINI_API_KEY= GEMINI_API_KEY=
# GOOGLE_API_KEY= also works as a local alias, but GEMINI_API_KEY is the
# canonical deployment secret name used by DigitalOcean and docs.
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-3.1-flash-tts-preview
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
# --- GitHub (repo state + sync PR artifacts) --- # --- GitHub (repo state + sync PR artifacts) ---
GITHUB_TOKEN= GITHUB_TOKEN=
@@ -33,3 +36,18 @@ VITE_BACKEND_URL=http://localhost:8787
# --- Deployment verification --- # --- Deployment verification ---
# Optional override when the deployed SPA and API use different origins. # Optional override when the deployed SPA and API use different origins.
FRONTEND_URL=http://localhost:4173 FRONTEND_URL=http://localhost:4173
# --- Hermes operations watchdog ---
PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
PODMAN_PUBLIC_API_URL=https://165-22-129-249.sslip.io/api/pods
PODMAN_PUBLIC_HEALTH_URL=https://165-22-129-249.sslip.io/health
PODMAN_HERMES_REMEDIATE=1
PODMAN_HERMES_STRICT=0
PODMAN_HERMES_STATE_DIR=/var/log/podman
# Optional Discord/Slack/generic webhook for failed watchdog runs.
PODMAN_ALERT_WEBHOOK_URL=
# --- Hermes git-sync deploy loop ---
PODMAN_DEPLOY_REMOTE=origin
PODMAN_DEPLOY_BRANCH=main
PODMAN_DEPLOY_RESTART_SERVICES=podman-platform-api.service,podman-platform-agent.service,caddy.service
+23
View File
@@ -0,0 +1,23 @@
name: Hermes verify
on:
push:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.32.1
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm -r typecheck
- run: pnpm -r build
+39
View File
@@ -0,0 +1,39 @@
import type { Room } from '@livekit/rtc-node';
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
const encoder = new TextEncoder();
function teammateText(collision: Collision, intervention: Intervention): string {
return `${collision.engineers.join(', ')}: ${intervention.message}`;
}
export function createHermesMessage(
collision: Collision,
intervention: Intervention,
): HermesMessage {
return {
id: `hermes_${Date.now()}`,
podId: collision.podId,
interventionId: intervention.id,
recipients: collision.engineers,
text: teammateText(collision, intervention),
urgency: collision.severity === 'critical' ? 'urgent' : 'normal',
createdAt: new Date().toISOString(),
};
}
export async function publishHermesMessage(
room: Room,
collision: Collision,
intervention: Intervention,
): Promise<void> {
const data: DataMessage = {
type: 'HERMES_MESSAGE',
message: createHermesMessage(collision, intervention),
};
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
}
+17 -4
View File
@@ -9,6 +9,7 @@ import { getGitStates } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js'; import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js'; import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { speak } from '../voice/live.js'; import { speak } from '../voice/live.js';
import { publishHermesMessage } from '../action/hermes.js';
export class PodMan { export class PodMan {
private contexts = new Map<string, EngineerContext>(); private contexts = new Map<string, EngineerContext>();
@@ -54,7 +55,7 @@ export class PodMan {
} }
private async handle(collision: Collision): Promise<void> { private async handle(collision: Collision): Promise<void> {
const prior = await recallSimilar(collision); // Loop A: vector recall raises confidence const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
if (prior) collision.severity = 'critical'; if (prior) collision.severity = 'critical';
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
@@ -64,7 +65,11 @@ export class PodMan {
const message = const message =
`${names} are both editing ${collision.file}` + `${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') + (collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior ? ` I've seen this conflict pattern before.` : ''); (prior?.priorOutcome?.accepted
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
: prior
? ` I've seen this conflict pattern before.`
: '');
const intervention: Intervention = { const intervention: Intervention = {
id: `int_${Date.now()}`, id: `int_${Date.now()}`,
@@ -72,7 +77,14 @@ export class PodMan {
podId: this.podId, podId: this.podId,
kind: 'card', kind: 'card',
message, message,
suggestedAction: { kind: action }, suggestedAction: {
kind: action,
params: {
file: collision.file,
summary: message,
engineers: collision.engineers,
},
},
status: 'pending', status: 'pending',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -83,6 +95,7 @@ export class PodMan {
reliable: true, reliable: true,
topic: DATA_TOPIC, topic: DATA_TOPIC,
}); });
await speak(this.room, message); // gemini-3.1-flash-live voice into the room await publishHermesMessage(this.room, collision, intervention);
if (collision.severity === 'critical') await speak(this.room, message);
} }
} }
+11 -2
View File
@@ -5,6 +5,13 @@ function req(name: string): string {
if (!v) throw new Error(`Missing required env var: ${name}`); if (!v) throw new Error(`Missing required env var: ${name}`);
return v; return v;
} }
function reqAny(primary: string, aliases: string[] = []): string {
for (const name of [primary, ...aliases]) {
const v = process.env[name];
if (v) return v;
}
throw new Error(`Missing required env var: ${primary}`);
}
function opt(name: string, fallback = ''): string { function opt(name: string, fallback = ''): string {
return process.env[name] ?? fallback; return process.env[name] ?? fallback;
} }
@@ -15,9 +22,10 @@ export const env = {
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'), LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
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: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'), GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'), GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
// 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
@@ -27,6 +35,7 @@ export const env = {
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'), VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
// Server // Server
PORT: Number(opt('PORT', '8787')), PORT: Number(opt('PORT', '8787')),
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
} as const; } as const;
export function repoParts(): { owner: string; repo: string } { export function repoParts(): { owner: string; repo: string } {
+33 -1
View File
@@ -39,13 +39,45 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
ref: `refs/heads/${branch}`, ref: `refs/heads/${branch}`,
sha: mainRef.object.sha, sha: mainRef.object.sha,
}); });
const artifactPath = `podman-sync-artifacts/${branch}.md`;
const body = [
'# PodMan Sync Artifact',
'',
`- File: \`${input.file || 'unknown'}\``,
`- Source branch hint: \`${input.headBranch || 'not provided'}\``,
`- Created: ${new Date().toISOString()}`,
'',
'## Coordination Summary',
'',
input.summary || 'PodMan detected a coordination risk before the relevant work was pushed.',
'',
'## Suggested Next Step',
'',
'Coordinate ownership before pushing or merging overlapping local work.',
'',
].join('\n');
await gh.rest.repos.createOrUpdateFileContents({
owner,
repo,
branch,
path: artifactPath,
message: `PodMan sync artifact for ${input.file || 'active work'}`,
content: Buffer.from(body).toString('base64'),
});
const { data: pr } = await gh.rest.pulls.create({ const { data: pr } = await gh.rest.pulls.create({
owner, owner,
repo, repo,
title: `PodMan: sync ${input.file} before collision`, title: `PodMan: sync ${input.file} before collision`,
head: branch, head: branch,
base: 'main', base: 'main',
body: input.summary, body: [
input.summary,
'',
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
].join('\n'),
}); });
return pr; return pr;
} }
+1
View File
@@ -99,6 +99,7 @@ export async function initMemory(): Promise<void> {
'collisions.memorySignature', 'collisions.memorySignature',
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }), () => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
], ],
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -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 })],
]; ];
+31 -11
View File
@@ -1,17 +1,37 @@
import type { Collision, SuggestedActionKind } from '@podman/shared'; import type { Collision, SuggestedActionKind } from '@podman/shared';
import type { RecalledCollision } from './vectors.js';
/** const lastNudgeByPod = new Map<string, number>();
* Policy gate: decides whether PodMan should intervene.
* Stub: always intervene on warn/critical. function cooldownMs(): number {
*/ return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
return collision.severity !== 'info';
} }
/** /** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
* Preferred action selection based on collision + prior history. export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
* Stub: open sync PR for critical, ping teammate otherwise. if (collision.severity === 'info') return false;
*/
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind { const priorOutcome = prior?.priorOutcome;
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
const cooldown = cooldownMs();
const last = lastNudgeByPod.get(collision.podId) ?? 0;
if (cooldown > 0 && Date.now() - last < cooldown && collision.severity !== 'critical') {
return false;
}
lastNudgeByPod.set(collision.podId, Date.now());
return true;
}
/** Preferred action selection based on collision severity and prior accepted actions. */
export function preferredAction(
collision: Collision,
prior: RecalledCollision | null,
): SuggestedActionKind {
const acceptedKind = prior?.priorOutcome?.accepted
? prior.priorIntervention?.suggestedAction.kind
: undefined;
if (acceptedKind && acceptedKind !== 'none') return acceptedKind;
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate'; return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
} }
+8 -1
View File
@@ -34,7 +34,14 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
} }
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> { export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome })); await persist('outcome', async () => {
const c = await collections();
await c.outcomes.insertOne({ ...outcome });
await c.interventions.updateOne(
{ id: outcome.interventionId },
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } },
);
});
} }
/** Document counts per collection — used by the /api/memory/stats endpoint. */ /** Document counts per collection — used by the /api/memory/stats endpoint. */
+153 -21
View File
@@ -1,4 +1,4 @@
import type { Collision } from '@podman/shared'; import type { Collision, Intervention, InterventionOutcome } from '@podman/shared';
import { env } from '../env.js'; import { env } from '../env.js';
import { getDb } from './db.js'; import { getDb } from './db.js';
@@ -6,18 +6,35 @@ type StoredCollision = Collision & {
memorySignature?: string; memorySignature?: string;
memoryText?: string; memoryText?: string;
embedding?: number[]; embedding?: number[];
embeddingProvider?: string;
};
export type RecalledCollision = Collision & {
priorIntervention?: Intervention;
priorOutcome?: InterventionOutcome;
}; };
interface VoyageEmbeddingResponse { interface VoyageEmbeddingResponse {
data?: Array<{ embedding?: number[] }>; data?: Array<{ embedding?: number[] }>;
} }
interface GeminiEmbeddingResponse {
embedding?: { values?: number[] };
}
function normalize(value: string | undefined): string { function normalize(value: string | undefined): string {
return (value ?? '').trim().toLowerCase(); return (value ?? '').trim().toLowerCase();
} }
function signature(collision: Collision): string { function signature(collision: Collision): string {
return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#'); return [
normalize(collision.file),
normalize(collision.symbol),
[...collision.engineers].sort().map(normalize).join('+'),
'collision',
]
.filter(Boolean)
.join('#');
} }
function memoryText(collision: Collision): string { function memoryText(collision: Collision): string {
@@ -32,7 +49,30 @@ function memoryText(collision: Collision): string {
.join('\n'); .join('\n');
} }
function cosine(a: number[], b: number[]): number {
const n = Math.min(a.length, b.length);
let dot = 0;
let aNorm = 0;
let bNorm = 0;
for (let i = 0; i < n; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
dot += av * bv;
aNorm += av * av;
bNorm += bv * bv;
}
if (!aNorm || !bNorm) return -1;
return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
}
async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> { async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> {
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
}
async function embedWithVoyage(
text: string,
inputType: 'document' | 'query',
): Promise<number[] | null> {
if (!env.VOYAGE_API_KEY) return null; if (!env.VOYAGE_API_KEY) return null;
try { try {
const res = await fetch('https://api.voyageai.com/v1/embeddings', { const res = await fetch('https://api.voyageai.com/v1/embeddings', {
@@ -59,6 +99,38 @@ async function embed(text: string, inputType: 'document' | 'query'): Promise<num
} }
} }
async function embedWithGemini(
text: string,
inputType: 'document' | 'query',
): Promise<number[] | null> {
try {
const taskType = inputType === 'document' ? 'RETRIEVAL_DOCUMENT' : 'RETRIEVAL_QUERY';
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
env.GEMINI_EMBEDDING_MODEL,
)}:embedContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: { parts: [{ text }] },
taskType,
outputDimensionality: 768,
}),
},
);
if (!res.ok) {
console.warn(`[memory] gemini embedding failed: ${res.status} ${await res.text()}`);
return null;
}
const body = (await res.json()) as GeminiEmbeddingResponse;
return body.embedding?.values ?? null;
} catch (err) {
console.warn(`[memory] gemini embedding failed: ${(err as Error).message}`);
return null;
}
}
export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> { export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> {
const text = memoryText(collision); const text = memoryText(collision);
const embedding = await embed(text, 'document'); const embedding = await embed(text, 'document');
@@ -66,16 +138,45 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
...collision, ...collision,
memorySignature: signature(collision), memorySignature: signature(collision),
memoryText: text, memoryText: text,
...(embedding ? { embedding } : {}), ...(embedding
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
: {}),
}; };
} }
async function recallByVector(collision: Collision): Promise<Collision | null> { async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> {
const db = await getDb();
const intervention = await db
.collection<Intervention>('interventions')
.findOne({ collisionId: match.id }, { sort: { createdAt: -1 }, projection: { _id: 0 } });
const outcome = intervention
? await db
.collection<InterventionOutcome>('outcomes')
.findOne(
{ interventionId: intervention.id },
{ sort: { recordedAt: -1 }, projection: { _id: 0 } },
)
: null;
const {
memorySignature: _memorySignature,
memoryText: _memoryText,
embedding: _embedding,
embeddingProvider: _embeddingProvider,
...collision
} = match;
return {
...collision,
...(intervention ? { priorIntervention: intervention } : {}),
...(outcome ? { priorOutcome: outcome } : {}),
};
}
async function recallByVector(collision: Collision): Promise<RecalledCollision | null> {
const queryVector = await embed(memoryText(collision), 'query'); const queryVector = await embed(memoryText(collision), 'query');
if (!queryVector) return null; if (!queryVector) return null;
const db = await getDb();
try { try {
const db = await getDb();
const [match] = await db const [match] = await db
.collection<StoredCollision>('collisions') .collection<StoredCollision>('collisions')
.aggregate<StoredCollision>([ .aggregate<StoredCollision>([
@@ -93,31 +194,62 @@ async function recallByVector(collision: Collision): Promise<Collision | null> {
{ $project: { _id: 0, embedding: 0 } }, { $project: { _id: 0, embedding: 0 } },
]) ])
.toArray(); .toArray();
return match ?? null; return match ? attachOutcome(match) : null;
} catch (err) { } catch (err) {
console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`); console.warn(`[memory] atlas vector recall unavailable: ${(err as Error).message}`);
return null;
} }
const candidates = await db
.collection<StoredCollision>('collisions')
.find(
{
podId: collision.podId,
id: { $ne: collision.id },
embedding: { $exists: true },
},
{ projection: { _id: 0 }, limit: 100 },
)
.toArray();
let best: { match: StoredCollision; score: number } | null = null;
for (const candidate of candidates) {
if (!candidate.embedding?.length) continue;
const score = cosine(queryVector, candidate.embedding);
if (!best || score > best.score) best = { match: candidate, score };
}
return best && best.score > 0.5 ? attachOutcome(best.match) : null;
} }
async function recallBySignature(collision: Collision): Promise<Collision | null> { async function recallBySignature(collision: Collision): Promise<RecalledCollision | null> {
const db = await getDb(); const db = await getDb();
const sig = signature(collision); const sig = signature(collision);
const match = await db.collection<StoredCollision>('collisions').findOne( const matches = await db
{ .collection<StoredCollision>('collisions')
podId: collision.podId, .find(
id: { $ne: collision.id }, {
$or: [{ memorySignature: sig }, { file: collision.file }], podId: collision.podId,
}, id: { $ne: collision.id },
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } }, $or: [{ memorySignature: sig }, { file: collision.file }],
); },
return match ?? null; { sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 },
)
.toArray();
let fallback: RecalledCollision | null = null;
for (const match of matches) {
const recalled = await attachOutcome(match);
if (!fallback) fallback = recalled;
if (recalled.priorOutcome?.accepted && recalled.priorOutcome.wasRealCollision) {
return recalled;
}
}
return fallback;
} }
/** /**
* Recall prior collision patterns. Exact Mongo recall is always available; * Recall prior collision patterns. Atlas Vector Search is preferred when
* Voyage + Atlas Vector Search is used first when configured. * available; standalone MongoDB falls back to app-side cosine search over
* stored embeddings, then exact signature/file matching.
*/ */
export async function recallSimilar(collision: Collision): Promise<Collision | null> { export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
return (await recallByVector(collision)) ?? recallBySignature(collision); return (await recallByVector(collision)) ?? recallBySignature(collision);
} }
+70 -32
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { import {
AudioFrame, AudioFrame,
AudioSource, AudioSource,
@@ -12,6 +13,7 @@ import { env } from '../env.js';
const SAMPLE_RATE = 24_000; const SAMPLE_RATE = 24_000;
const CHANNELS = 1; const CHANNELS = 1;
const FRAME_SAMPLES = SAMPLE_RATE / 10;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY }); const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
@@ -44,6 +46,72 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
return out; return out;
} }
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
const frame = audioFrameFromBase64(data, mimeType);
if (!frame) return [];
const samples = frame.data;
const frames: AudioFrame[] = [];
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
}
return frames;
}
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
const res = await ai.models.generateContent({
model: env.GEMINI_LIVE_MODEL,
contents: [{ parts: [{ text: message }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
},
});
const parts = res.candidates?.[0]?.content?.parts ?? [];
return parts.flatMap((part) =>
framesFromPcmBase64(part.inlineData?.data ?? '', part.inlineData?.mimeType),
);
}
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
for (const frame of await generateTtsFrames(message)) {
await source.captureFrame(frame);
}
}
async function speakWithLive(source: AudioSource, message: string): Promise<void> {
let done: () => void = () => {};
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
const session: 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();
}
/** /**
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel * Speak a message into the LiveKit room using Gemini Live audio. A data-channel
* VOICE_CUE is sent first so clients still get the cue if audio generation or * VOICE_CUE is sent first so clients still get the cue if audio generation or
@@ -60,38 +128,8 @@ export async function speak(room: Room, message: string): Promise<void> {
try { try {
const publication = await room.localParticipant.publishTrack(track, options); const publication = await room.localParticipant.publishTrack(track, options);
let done: () => void = () => {}; if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
const donePromise = new Promise<void>((resolve) => { else await speakWithLive(source, message);
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); if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
await source.close(); await source.close();
} catch (err) { } catch (err) {
+31 -25
View File
@@ -178,36 +178,41 @@ From the remote plan snapshot and health check on `2026-06-27`:
- Treat this as operational evidence, not architecture truth. Reverify before - Treat this as operational evidence, not architecture truth. Reverify before
demo. demo.
### Partial / stubbed ### Partial / completed since the original audit
- `backend/src/voice/live.ts` logs only; it does not publish real voice/audio - `backend/src/voice/live.ts` now publishes a `VOICE_CUE` fallback and attempts
into LiveKit yet. Gemini audio publication into LiveKit. The agent only calls it for critical
- Hermes is a product/action/messaging layer in the plan, but the current repo interventions so voice remains an urgent escalation path.
does not yet implement a complete Hermes notification bridge. - Hermes now has a data-channel teammate message path via `HERMES_MESSAGE` on
- `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search the existing `podman.intervention` topic. This is the MVP notification bridge,
implementation yet. not a Slack/Discord integration.
- Exact-signature recall is the required MVP fallback before vectors. - `backend/src/memory/vectors.ts` implements exact-signature recall first and
- `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds can use Voyage/Gemini embeddings with Atlas Vector Search when configured.
from outcomes yet. - Exact-signature recall now attaches prior interventions/outcomes and prefers
- `POST /api/sync-pr` creates a PR artifact path but does not yet build a accepted real collisions, giving the learning beat deterministic MongoDB
meaningful sync diff. proof before vector search.
- Frontend `PodView` has only a placeholder intervention area unless/until live - `backend/src/memory/policy.ts` now uses severity, per-pod cooldown, and prior
intervention rendering is wired. outcome history. It is still a simple policy, not a trained threshold model.
- `POST /api/sync-pr` now creates a visible Markdown sync artifact commit before
opening the PR.
- Frontend `PodView` renders intervention cards, Hermes messages, voice cues,
and the accepted sync PR artifact link.
- Browser screen publishing exists, but the active join path must be proven to - Browser screen publishing exists, but the active join path must be proven to
tag tracks as screen share so the backend agent can filter them correctly. The tag tracks as screen share so the backend agent can filter them correctly. The
`origin/main` screen-share button appears to address this; local code remains `origin/main` screen-share button appears to address this; local code remains
behind until that commit is merged. behind until that commit is merged.
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs` - `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs`
is the finished per-laptop git sidecar — polls every 15 s, upserts git fields is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data to `engineer_states` collection. The backend agent now fuses those Mongo
channel message into the LiveKit room (agent fusion step still needed). git-state fields into live contexts before collision detection; direct
LiveKit `GIT_REPORT` publication from the sidecar remains optional.
- Background research recommendations are a product requirement and demo goal, - Background research recommendations are a product requirement and demo goal,
not an implemented research agent yet. not an implemented research agent yet.
- Deployment reliability is partial; API health is reachable, but API/static - Deployment reliability is partial; API health is reachable, but API/static
site/worker together must still be reverified before demo. site/worker together must still be reverified before demo.
- Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and - Env docs now align on `gemini-3.5-flash` for vision and
`gemini-3.1-flash-live-preview`, while `.env.example` still lists older `gemini-3.1-flash-tts-preview` for voice. The backend still preserves a Gemini
Gemini model names. Live path for future available Live models.
### Not yet proven ### Not yet proven
@@ -667,15 +672,16 @@ Before saying PodMan is demo-ready:
- [ ] Backend agent subscribes to the screen-share track. - [ ] Backend agent subscribes to the screen-share track.
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen. - [ ] Agent logs at least one parsed Gemini context from a real IDE screen.
- [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed. - [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed.
- [ ] Frontend renders a real intervention card. - [x] Frontend renders a real intervention card.
- [ ] Hermes notification path works for teammate messages. - [x] Hermes notification path works for teammate messages over the LiveKit data
channel.
- [ ] Voice is heard only for urgent escalation or a fallback is declared. - [ ] Voice is heard only for urgent escalation or a fallback is declared.
- [ ] Outcome ACK writes to MongoDB. - [x] Outcome ACK writes to MongoDB and updates intervention status.
- [ ] `/api/memory/stats` shows counts increasing. - [x] `/api/memory/stats` shows counts increasing.
- [ ] Second similar situation uses prior memory in the message. - [x] Second similar situation uses prior exact memory in the message.
- [ ] Research recommendation card is evidence-backed, or fallback collision demo - [ ] Research recommendation card is evidence-backed, or fallback collision demo
is used. is used.
- [ ] Sync PR action creates a visible GitHub artifact if used in demo. - [x] Sync PR action creates a visible GitHub artifact if used in demo.
- [ ] DigitalOcean deployment or local fallback is rehearsed. - [ ] DigitalOcean deployment or local fallback is rehearsed.
- [ ] Backup recording is ready on a separate device. - [ ] Backup recording is ready on a separate device.
+39 -2
View File
@@ -69,7 +69,7 @@ LIVEKIT_API_SECRET=...
GEMINI_API_KEY=... 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-3.1-flash-tts-preview
GITHUB_TOKEN=... GITHUB_TOKEN=...
GITHUB_REPO=karti-ai/podman GITHUB_REPO=karti-ai/podman
@@ -170,14 +170,51 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
```bash ```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/ 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 install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
``` ```
Expected runtime proof: Expected runtime proof:
```bash ```bash
systemctl is-active podman-platform-api podman-platform-agent systemctl is-active podman-platform-api podman-platform-agent
systemctl is-active podman-hermes-watchdog.timer
systemctl is-active podman-hermes-sync-deploy.timer
curl http://127.0.0.1:8787/health curl http://127.0.0.1:8787/health
journalctl -u podman-platform-agent -n 20 --no-pager journalctl -u podman-platform-agent -n 20 --no-pager
journalctl -u podman-hermes-watchdog -n 40 --no-pager
```
## Hermes-managed operations layer
The app processes are still supervised by systemd, but Hermes now owns the
operations loop around them:
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
`/api/pods`, and `pnpm deploy:doctor`.
- `podman-hermes-watchdog.timer` runs that watchdog every five minutes.
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes. If
the tree is clean and the remote moved, it fast-forwards, installs, builds,
publishes `frontend/dist` to `/var/www/podman`, restarts the API/agent/Caddy,
and runs the strict watchdog.
- Failed URL checks trigger restarts of the PodMan API, PodMan agent, and Caddy.
- Failed service checks restart only the unhealthy service.
- Caddy is validated and reloaded after public route failures.
- Reports are written to `/var/log/podman/hermes-watchdog-latest.json`.
- Set `PODMAN_ALERT_WEBHOOK_URL` to send failed reports to Discord, Slack, or a
generic webhook receiver.
- `pnpm hermes:install` installs the timer units and a local pre-push hook that
gates major pushes with typecheck, lint, and a non-remediating watchdog check.
The strict gate for production readiness is:
```bash
pnpm hermes:watchdog:strict
```
Manual deploy-sync run:
```bash
pnpm hermes:sync-deploy
``` ```
+3 -3
View File
@@ -109,11 +109,11 @@ Respond with the message text only.
--- ---
## 4. Voice Output — Gemini Live 2.5 via LiveKit ## 4. Voice Output — Gemini TTS via LiveKit
**Model:** `gemini-live-2.5-flash` (confirm exact ID from LiveKit Agents docs) **Model:** `gemini-3.1-flash-tts-preview`
**Integration:** LiveKit Agents framework — Hermes runs as a LiveKit Agent with Gemini Live 2.5 as the voice provider **Integration:** Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track. The code still preserves a Gemini Live path for future available Live models.
**Flow:** **Flow:**
+11 -1
View File
@@ -79,10 +79,20 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`) - `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections) - `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)` - `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`) - `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
## Demo-first plan ## Demo-first plan
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path). 1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock. 2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it. 3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
## Component convention
UI is built from the shared **shadcn / ruixen** registry — add primitives with
`npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
`@/components/ui/*` (`Button`, `Badge`, `Card`, …) using the design tokens
(`var(--card)` / `--foreground` / `--border` / …). Only the SVG node-link **canvas**
in `GraphView.tsx` is bespoke (3 SVG-only CSS rules); the chrome (header, toggles,
metric cards, detail panel, legend) is composed from the primitives + the app's
Tailwind utility patterns. No hand-rolled component stylesheets.
+4 -4
View File
@@ -89,11 +89,11 @@ Hermes uses the same endpoint. Grants:
--- ---
## Gemini Live 2.5 model ## Gemini voice model
- Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time - Model ID: `gemini-3.1-flash-tts-preview`
- LiveKit Agents has native Gemini Live integration — no manual audio encoding needed - Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track.
- Hermes passes text string → Agents handles streaming audio publication - The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS.
--- ---
@@ -72,7 +72,7 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }` - **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }` - **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text - **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
- **Voice:** `gemini-live-2.5-flash` via LiveKit Agents — text → streaming audio - **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio
### MongoDB Atlas (4 collections) ### MongoDB Atlas (4 collections)
+195 -206
View File
@@ -1,24 +1,36 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js'; import { fetchPodGraph } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
type Mode = 'risk' | 'learn' | 'all'; type Mode = 'risk' | 'learn' | 'all';
// Fixed, light-readable hues for the node/edge encoding (kept stable across
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = { const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: '#3B5BFF', engineer: BLUE,
file: '#ECE7DA', file: SLATE,
feature: '#F6C445', feature: AMBER,
collision: '#E2403A', collision: RED,
intervention: '#8b6cff', intervention: VIOLET,
}; };
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = { const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: '#3B5BFF', w: 2.6 }, owns: { c: BLUE, w: 2.6 },
editing: { c: '#ECE7DA', w: 2 }, editing: { c: SLATE_EDGE, w: 2 },
touches: { c: '#5d5d66', w: 1.6 }, touches: { c: SLATE_FAINT, w: 1.6 },
collides: { c: '#E2403A', w: 3.2 }, collides: { c: RED, w: 3.2 },
warns: { c: '#F6C445', w: 3.2 }, warns: { c: AMBER, w: 3.2 },
learned_from: { c: '#8b6cff', w: 2.4, dash: true }, learned_from: { c: VIOLET, w: 2.4, dash: true },
}; };
function NodeShape({ node }: { node: PodGraphNode }) { function NodeShape({ node }: { node: PodGraphNode }) {
@@ -26,7 +38,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
const { x, y } = node; const { x, y } = node;
switch (node.kind) { switch (node.kind) {
case 'engineer': case 'engineer':
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />; return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
case 'file': case 'file':
return ( return (
<rect <rect
@@ -34,6 +46,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
y={y - 15} y={y - 15}
width={30} width={30}
height={30} height={30}
rx={4}
fill="none" fill="none"
stroke={c} stroke={c}
strokeWidth={2.6} strokeWidth={2.6}
@@ -81,16 +94,19 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
} }
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: '#3B5BFF' } }, { label: 'engineer', swatch: { background: BLUE } },
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } }, { label: 'file', swatch: { border: `2px solid ${SLATE}` } },
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } }, { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
{ { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
label: 'collision', { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
},
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
]; ];
function statusColor(status: string): string {
if (status === 'risk') return RED;
if (status === 'learned') return VIOLET;
return 'var(--foreground)';
}
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null); const [graph, setGraph] = useState<PodGraph | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -128,201 +144,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
setSelected(null); setSelected(null);
} }
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return ( return (
<div className="pm-graph"> <div className="min-h-screen bg-background text-foreground">
<style>{` <div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff; <style>{`
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden} .pm-node{cursor:pointer}
.pm-graph *{box-sizing:border-box} .pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)} .pm-dim{opacity:.18;transition:opacity .25s}
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif} `}</style>
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
.pm-x:hover{border-color:var(--paper)}
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
.pm-btn:hover{border-color:var(--paper)}
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
.pm-col{padding:14px}
.pm-railR{border-left:1px solid var(--line);background:#17171b}
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
.pm-canvas svg{width:100%;height:auto;display:block}
.pm-node{cursor:pointer}
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
.pm-dim{opacity:.12;transition:opacity .25s}
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
.pm-drow b{color:var(--paper);font-weight:500}
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
.pm-lg{display:flex;align-items:center;gap:6px}
.pm-sw{width:13px;height:13px;display:inline-block}
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
`}</style>
<div className="pm-hd"> <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div> <div className="flex items-center justify-between border-b px-5 py-4">
<div className="pm-ttl">Team memory</div> <div>
<div className="pm-sub">What PodMan learned · {podId}</div> <h2 className="text-base font-medium">Team memory</h2>
</div> <p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
<button className="pm-x" onClick={onClose}> </div>
Pods <Button variant="outline" size="sm" onClick={onClose}>
</button> Pods
</div> </Button>
</div>
<div className="pm-bar"> <div className="flex flex-wrap gap-2 border-b px-4 py-3">
<button <Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`} Risk path
onClick={() => pick('risk')} </Button>
> <Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Risk path Learning edges
</button> </Button>
<button <Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`} Whole graph
onClick={() => pick('learn')} </Button>
> </div>
Learning edges
</button>
<button
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
onClick={() => pick('all')}
>
Whole graph
</button>
</div>
{error && ( {error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p> {!graph && !error && (
)} <p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p>
{!graph && !error && ( )}
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph</p>
)}
{graph && ( {graph && (
<> <>
<div className="pm-grid"> <div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="pm-col"> <div className="space-y-3 p-4">
<div className="pm-st">Workflow metrics</div> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{graph.metrics.map((m) => ( Workflow metrics
<div className="pm-kpi" key={m.label}> </p>
<div className="pm-num">{m.value}</div> {graph.metrics.map((m) => (
<div className="pm-klab">{m.label}</div> <div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<div className="pm-kdet">{m.detail}</div> <p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
</div> </div>
))}
</div>
<div className="pm-canvas"> <div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph"> <svg
{graph.edges.map((e) => { viewBox="0 0 720 472"
const a = nodeById.get(e.source); role="img"
const b = nodeById.get(e.target); aria-label="PodMan team-memory graph"
if (!a || !b) return null; className="block h-auto w-full"
const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{graph.nodes.map((n) => (
<g
key={n.id}
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
role="button"
tabIndex={0}
aria-label={`${n.kind}: ${n.label}`}
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
setSelected((cur) => (cur === n.id ? null : n.id));
}
}}
> >
<NodeShape node={n} /> {graph.edges.map((e) => {
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle"> const a = nodeById.get(e.source);
{n.label.toUpperCase()} const b = nodeById.get(e.target);
</text> if (!a || !b) return null;
</g> const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{graph.nodes.map((n) => (
<g
key={n.id}
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
role="button"
tabIndex={0}
aria-label={`${n.kind}: ${n.label}`}
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
setSelected((cur) => (cur === n.id ? null : n.id));
}
}}
>
<NodeShape node={n} />
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
{n.label}
</text>
</g>
))}
</svg>
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Status</span>
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
{sel.status}
</Badge>
</div>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Relationships</span>
<span className="font-medium text-foreground">{relCount}</span>
</div>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
{sel.summary}
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Continual learning
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph
gets sharper every session. Click any node to trace its relationships.
</p>
</>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))} ))}
</svg> <span className="flex items-center gap-1.5">
</div> <span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
<div className="pm-col pm-railR"> </span>
{sel ? ( <span className="flex items-center gap-1.5">
<> <span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
<div className="pm-dkind">{sel.kind}</div> learned_from
<div className="pm-dname">{sel.label}</div> </span>
<div className="pm-drow"> </div>
<span>Status</span> </>
<b )}
style={{ </div>
color: </div>
sel.status === 'risk'
? '#E2403A'
: sel.status === 'learned'
? '#b7a4ff'
: '#ECE7DA',
}}
>
{sel.status}
</b>
</div>
<div className="pm-drow">
<span>Relationships</span>
<b>{relCount}</b>
</div>
<div className="pm-note">{sel.summary}</div>
</>
) : (
<>
<div className="pm-dkind">Continual learning</div>
<div className="pm-dname">It learned</div>
<div className="pm-note">
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
PodMan retained from accepted interventions the graph gets sharper every
session. Click any node to trace its relationships.
</div>
</>
)}
</div>
</div>
<div className="pm-legend">
{LEGEND.map((l) => (
<span className="pm-lg" key={l.label}>
<span className="pm-sw" style={l.swatch} />
{l.label}
</span>
))}
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
collides
</span>
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
learned_from
</span>
</div>
</>
)}
</div> </div>
); );
} }
+46 -3
View File
@@ -4,6 +4,8 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
CheckIcon, CheckIcon,
CircleDotIcon, CircleDotIcon,
ExternalLinkIcon,
MessageSquareIcon,
MonitorUpIcon, MonitorUpIcon,
RadioTowerIcon, RadioTowerIcon,
SparklesIcon, SparklesIcon,
@@ -80,7 +82,7 @@ export function PodView({
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const [playingBeat, setPlayingBeat] = useState(false); const [playingBeat, setPlayingBeat] = useState(false);
const [note, setNote] = useState<string | null>(null); const [note, setNote] = useState<string | null>(null);
const { active, respond } = useInterventions(room); const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
const audioRef = useRef<HTMLDivElement>(null); const audioRef = useRef<HTMLDivElement>(null);
const beatRef = useRef<BeatHandle | null>(null); const beatRef = useRef<BeatHandle | null>(null);
@@ -181,6 +183,15 @@ export function PodView({
} }
} }
async function answerIntervention(status: 'accepted' | 'dismissed', accepted: boolean) {
setNote(null);
try {
await respond(status, accepted);
} catch (e) {
setNote(`Action failed: ${(e as Error).message}`);
}
}
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman'); const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
return ( return (
@@ -299,6 +310,24 @@ export function PodView({
{active.suggestedAction.kind.replaceAll('_', ' ')} {active.suggestedAction.kind.replaceAll('_', ' ')}
</Badge> </Badge>
</div> </div>
{hermes?.interventionId === active.id && (
<div className="rounded-lg border border-dashed p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<MessageSquareIcon className="size-3.5" />
Hermes message
</div>
<p className="text-sm leading-6">{hermes.text}</p>
</div>
)}
{voiceCue && (
<div className="rounded-lg border border-dashed p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Volume2Icon className="size-3.5" />
Voice cue
</div>
<p className="text-sm leading-6">{voiceCue}</p>
</div>
)}
</div> </div>
) : ( ) : (
<Empty className="min-h-72 border-0 p-0"> <Empty className="min-h-72 border-0 p-0">
@@ -314,14 +343,28 @@ export function PodView({
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
)} )}
{actionUrl && (
<a
href={actionUrl}
target="_blank"
rel="noreferrer"
className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm font-medium hover:bg-muted"
>
Sync PR artifact opened
<ExternalLinkIcon className="size-4" />
</a>
)}
</CardContent> </CardContent>
{active && ( {active && (
<CardFooter className="justify-end gap-2"> <CardFooter className="justify-end gap-2">
<Button variant="outline" onClick={() => void respond('dismissed', false)}> <Button
variant="outline"
onClick={() => void answerIntervention('dismissed', false)}
>
<XIcon data-icon="inline-start" /> <XIcon data-icon="inline-start" />
Dismiss Dismiss
</Button> </Button>
<Button onClick={() => void respond('accepted', true)}> <Button onClick={() => void answerIntervention('accepted', true)}>
<CheckIcon data-icon="inline-start" /> <CheckIcon data-icon="inline-start" />
Accept Accept
</Button> </Button>
+14
View File
@@ -46,6 +46,20 @@ export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`); if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
} }
export async function createSyncPr(input: {
headBranch?: string;
file?: string;
summary?: string;
}): Promise<{ url: string; number: number }> {
return json(
await fetch(`${BACKEND_URL}/api/sync-pr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
}
// --- Pods CRUD --- // --- Pods CRUD ---
export async function listPods(): Promise<Pod[]> { export async function listPods(): Promise<Pod[]> {
+19 -4
View File
@@ -1,18 +1,26 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { RoomEvent, type Room } from 'livekit-client'; import { RoomEvent, type Room } from 'livekit-client';
import type { DataMessage, Intervention, InterventionStatus } from '@podman/shared'; import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared'; import { DATA_TOPIC } from '@podman/shared';
import { postOutcome } from '../lib/api'; import { createSyncPr, postOutcome } from '../lib/api';
export function useInterventions(room: Room | null) { export function useInterventions(room: Room | null) {
const [active, setActive] = useState<Intervention | null>(null); const [active, setActive] = useState<Intervention | null>(null);
const [hermes, setHermes] = useState<HermesMessage | null>(null);
const [voiceCue, setVoiceCue] = useState<string | null>(null);
const [actionUrl, setActionUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!room) return; if (!room) return;
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => { const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
if (topic !== DATA_TOPIC) return; if (topic !== DATA_TOPIC) return;
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage; const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'COLLISION') setActive(msg.intervention); if (msg.type === 'COLLISION') {
setActive(msg.intervention);
setActionUrl(null);
}
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
if (msg.type === 'VOICE_CUE') setVoiceCue(msg.text);
}; };
room.on(RoomEvent.DataReceived, onData); room.on(RoomEvent.DataReceived, onData);
return () => { return () => {
@@ -23,6 +31,13 @@ export function useInterventions(room: Room | null) {
const respond = useCallback( const respond = useCallback(
async (status: InterventionStatus, accepted: boolean) => { async (status: InterventionStatus, accepted: boolean) => {
if (!active) return; if (!active) return;
if (accepted && active.suggestedAction.kind === 'open_sync_pr') {
const pr = await createSyncPr({
file: String(active.suggestedAction.params?.file ?? ''),
summary: String(active.suggestedAction.params?.summary ?? active.message),
});
setActionUrl(pr.url);
}
await postOutcome({ await postOutcome({
interventionId: active.id, interventionId: active.id,
collisionId: active.collisionId, collisionId: active.collisionId,
@@ -37,5 +52,5 @@ export function useInterventions(room: Room | null) {
[active], [active],
); );
return { active, respond }; return { active, hermes, voiceCue, actionUrl, respond };
} }
+6 -1
View File
@@ -15,5 +15,10 @@
} }
lk.165-22-129-249.sslip.io { lk.165-22-129-249.sslip.io {
reverse_proxy localhost:7880 reverse_proxy https://meta-54zzak8x.livekit.cloud {
header_up Host meta-54zzak8x.livekit.cloud
transport http {
tls_server_name meta-54zzak8x.livekit.cloud
}
}
} }
+32 -5
View File
@@ -4,7 +4,7 @@ Deploy targets for PodMan on DigitalOcean.
- `Dockerfile` — builds the backend runtime image from the monorepo root - `Dockerfile` — builds the backend runtime image from the monorepo root
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker - `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
- `systemd/` — local droplet service units for the API and agent worker - `systemd/` — local droplet service/timer units for the API, agent worker, public healthcheck, and Hermes watchdog
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).
@@ -31,9 +31,10 @@ On the demo droplet, serve the API and worker with systemd instead of tmux:
```bash ```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/ 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 install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
sudo systemctl status podman-platform-api podman-platform-agent sudo systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
``` ```
The services expect: The services expect:
@@ -47,6 +48,7 @@ Useful checks:
```bash ```bash
curl http://127.0.0.1:8787/health curl http://127.0.0.1:8787/health
journalctl -u podman-platform-api -u podman-platform-agent -f journalctl -u podman-platform-api -u podman-platform-agent -f
journalctl -u podman-hermes-watchdog -f
``` ```
## DigitalOcean deploy ## DigitalOcean deploy
@@ -73,9 +75,34 @@ local LiveKit host.
```bash ```bash
sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/ sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/
sudo cp infra/systemd/podman-hermes-watchdog.* /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
systemctl status podman-platform-api podman-platform-agent systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
```
## Hermes operations layer
Hermes is the operations copilot for the droplet. The durable layer is:
- `podman-hermes-watchdog.timer` runs `pnpm hermes:watchdog` every five minutes.
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes and deploys clean fast-forward changes.
- `podman-public-healthcheck.timer` keeps the fast public URL restart loop.
- `/var/log/podman/hermes-watchdog-latest.json` records the latest watchdog report.
- `.git/hooks/pre-push`, installed by `pnpm hermes:install`, gates major pushes with typecheck, lint, and a non-remediating watchdog check.
Install or refresh all local ops wiring:
```bash
pnpm hermes:install
```
Manual one-shot checks:
```bash
pnpm hermes:watchdog
pnpm hermes:watchdog:strict
pnpm hermes:sync-deploy
``` ```
## Fallback (demo safety) ## Fallback (demo safety)
+4 -2
View File
@@ -48,7 +48,8 @@ services:
- { 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_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash } - { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET } - { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman } - { 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 }
@@ -73,7 +74,8 @@ workers:
- { 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_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash } - { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET } - { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman } - { 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 }
@@ -0,0 +1,16 @@
[Unit]
Description=PodMan Hermes git sync and deploy
After=network-online.target podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=NODE_ENV=production
Environment=PODMAN_DEPLOY_REMOTE=origin
Environment=PODMAN_DEPLOY_BRANCH=main
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node scripts/hermes-sync-deploy.mjs
Nice=5
IOSchedulingClass=best-effort
@@ -0,0 +1,11 @@
[Unit]
Description=Poll origin/main and let Hermes deploy clean fast-forward changes
[Timer]
OnBootSec=90s
OnUnitActiveSec=2min
AccuracySec=30s
Unit=podman-hermes-sync-deploy.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,17 @@
[Unit]
Description=PodMan Hermes operations watchdog
After=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=NODE_ENV=production
Environment=PODMAN_HERMES_STRICT=0
Environment=PODMAN_HERMES_REMEDIATE=1
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
Environment=PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node scripts/hermes-watchdog.mjs
Nice=5
IOSchedulingClass=best-effort
@@ -0,0 +1,11 @@
[Unit]
Description=Run PodMan Hermes operations watchdog every five minutes
[Timer]
OnBootSec=45s
OnUnitActiveSec=5min
AccuracySec=30s
Unit=podman-hermes-watchdog.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
[Unit]
Description=PodMan public URL healthcheck
After=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
Environment=PODMAN_HEALTH_TIMEOUT_MS=8000
ExecStart=/usr/bin/node scripts/healthcheck-public.mjs
@@ -0,0 +1,11 @@
[Unit]
Description=Run PodMan public URL healthcheck every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=10s
Unit=podman-public-healthcheck.service
[Install]
WantedBy=timers.target
+5
View File
@@ -20,6 +20,11 @@
"doctor": "node scripts/deploy-doctor.mjs", "doctor": "node scripts/deploy-doctor.mjs",
"doctor:strict": "node scripts/deploy-doctor.mjs --strict", "doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"deploy:static:local": "node scripts/deploy-static-local.mjs", "deploy:static:local": "node scripts/deploy-static-local.mjs",
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
"hermes:install": "node scripts/install-hermes-ops.mjs",
"healthcheck:public": "node scripts/healthcheck-public.mjs",
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend", "verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers", "verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
"verify:backend": "node scripts/verify-backend.mjs", "verify:backend": "node scripts/verify-backend.mjs",
+79 -11
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Buffer } from 'node:buffer';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
@@ -15,7 +16,6 @@ const requiredEnv = [
'LIVEKIT_URL', 'LIVEKIT_URL',
'LIVEKIT_API_KEY', 'LIVEKIT_API_KEY',
'LIVEKIT_API_SECRET', 'LIVEKIT_API_SECRET',
'GEMINI_API_KEY',
'GITHUB_TOKEN', 'GITHUB_TOKEN',
'GITHUB_REPO', 'GITHUB_REPO',
'MONGODB_URI', 'MONGODB_URI',
@@ -33,6 +33,19 @@ function isSet(name) {
return !!process.env[name]?.trim(); return !!process.env[name]?.trim();
} }
function configuredGeminiKey() {
const candidates = ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'];
for (const name of candidates) {
const value = process.env[name]?.trim();
if (!value) continue;
if (/replace|todo|example|your|xxx/i.test(value) || value.length < 20) {
throw new Error(`${name} looks like a placeholder or truncated key`);
}
return { name, value };
}
throw new Error('GEMINI_API_KEY is not set');
}
async function check(name, fn) { async function check(name, fn) {
try { try {
const detail = await fn(); const detail = await fn();
@@ -205,12 +218,12 @@ async function checkGitHub() {
} }
async function checkGeminiVision() { async function checkGeminiVision() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set'); const key = configuredGeminiKey();
const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash'; const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash';
const res = await doFetch( const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent( `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model, model,
)}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`, )}:generateContent?key=${encodeURIComponent(key.value)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -224,19 +237,63 @@ async function checkGeminiVision() {
return model; return model;
} }
async function checkGeminiLiveListed() { async function checkGeminiVoiceModel() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set'); const key = configuredGeminiKey();
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash'; const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview';
const res = await doFetch( const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent( `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key.value)}`,
process.env.GEMINI_API_KEY,
)}`,
); );
if (!res.ok) throw new Error(await responseError('Gemini model list', res)); if (!res.ok) throw new Error(await responseError('Gemini model list', res));
const body = await res.json(); const body = await res.json();
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, '')); const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`); if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
return model; if (!model.includes('tts')) return model;
const tts = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:generateContent?key=${encodeURIComponent(key.value)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: 'Say clearly: PodMan voice check.' }] }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
},
}),
},
);
if (!tts.ok) throw new Error(await responseError('Gemini voice check', tts));
const ttsBody = await tts.json();
const audio = ttsBody.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!audio) throw new Error('Gemini voice response had no audio');
return `${model}, generated ${Buffer.from(audio, 'base64').byteLength} audio bytes`;
}
async function checkGeminiEmbeddings() {
const key = configuredGeminiKey();
const model = process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:embedContent?key=${encodeURIComponent(key.value)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
content: { parts: [{ text: 'PodMan vector memory check' }] },
taskType: 'RETRIEVAL_DOCUMENT',
outputDimensionality: 768,
}),
},
);
if (!res.ok) throw new Error(await responseError('Gemini embedding check', res));
const body = await res.json();
const dims = body.embedding?.values?.length;
if (!dims) throw new Error('Gemini embedding response had no vector');
return `${model}, ${dims} dimensions`;
} }
async function checkVoyage() { async function checkVoyage() {
@@ -262,6 +319,16 @@ await check('workspace', checkWorkspace);
for (const name of requiredEnv) { for (const name of requiredEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing'); add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing');
} }
try {
const key = configuredGeminiKey();
add(
'env:GEMINI_API_KEY',
'ok',
key.name === 'GEMINI_API_KEY' ? 'set' : `using ${key.name} alias`,
);
} catch (err) {
add('env:GEMINI_API_KEY', 'fail', summarizeError(err));
}
for (const name of optionalEnv) { for (const name of optionalEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional'); add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
} }
@@ -281,7 +348,8 @@ await check('livekit room service', checkLiveKitApi);
await check('mongo ping', checkMongo); await check('mongo ping', checkMongo);
await check('github repo access', checkGitHub); await check('github repo access', checkGitHub);
await check('gemini vision model', checkGeminiVision); await check('gemini vision model', checkGeminiVision);
await check('gemini live model listed', checkGeminiLiveListed); await check('gemini voice model', checkGeminiVoiceModel);
await check('gemini embeddings', checkGeminiEmbeddings);
if (isSet('VOYAGE_API_KEY')) { if (isSet('VOYAGE_API_KEY')) {
await check('voyage embeddings', checkVoyage); await check('voyage embeddings', checkVoyage);
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
const rootUrl = process.env.PODMAN_PUBLIC_URL ?? 'https://165-22-129-249.sslip.io/';
const apiUrl = process.env.PODMAN_PUBLIC_API_URL ?? new URL('/api/pods', rootUrl).toString();
const timeoutMs = Number(process.env.PODMAN_HEALTH_TIMEOUT_MS ?? 8000);
const doFetch = globalThis.fetch;
const { AbortController, clearTimeout, setTimeout } = globalThis;
const requiredServices = [
'mongod.service',
'podman-platform-api.service',
'podman-platform-agent.service',
'caddy.service',
];
async function fetchOk(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await doFetch(url, { signal: controller.signal });
return { ok: res.ok, status: res.status };
} finally {
clearTimeout(timeout);
}
}
function run(command, args) {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code) => resolve({ code, stdout, stderr }));
child.on('error', (error) => resolve({ code: 127, stdout, stderr: error.message }));
});
}
async function runChecked(command, args) {
const result = await run(command, args);
if (result.code !== 0) {
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n');
throw new Error(
`${command} ${args.join(' ')} failed with ${result.code}${output ? `:\n${output}` : ''}`,
);
}
return result;
}
async function serviceOk(service) {
const result = await run('systemctl', ['is-active', '--quiet', service]);
return { ok: result.code === 0, code: result.code };
}
async function restartServices(reason) {
console.error(`[healthcheck] ${reason}; restarting public app services`);
for (const service of requiredServices) {
await runChecked('systemctl', ['restart', service]);
}
}
const urlChecks = [
['root', rootUrl, await fetchOk(rootUrl).catch((error) => ({ ok: false, error: error.message }))],
['api', apiUrl, await fetchOk(apiUrl).catch((error) => ({ ok: false, error: error.message }))],
];
const serviceChecks = await Promise.all(
requiredServices.map(async (service) => [
`service:${service}`,
service,
await serviceOk(service),
]),
);
const checks = [...urlChecks, ...serviceChecks];
const failed = checks.filter(([, , result]) => !result.ok);
if (failed.length) {
await restartServices(
failed
.map(([name, url, result]) => `${name} ${url} ${result.status ?? result.error}`)
.join('; '),
);
const retryUrlChecks = [
[
'root',
rootUrl,
await fetchOk(rootUrl).catch((error) => ({ ok: false, error: error.message })),
],
['api', apiUrl, await fetchOk(apiUrl).catch((error) => ({ ok: false, error: error.message }))],
];
const retryServiceChecks = await Promise.all(
requiredServices.map(async (service) => [
`service:${service}`,
service,
await serviceOk(service),
]),
);
const retry = [...retryUrlChecks, ...retryServiceChecks];
const stillFailed = retry.filter(([, , result]) => !result.ok);
console.log(JSON.stringify({ ok: stillFailed.length === 0, checks, retry }, null, 2));
process.exit(stillFailed.length === 0 ? 0 : 1);
}
console.log(JSON.stringify({ ok: true, checks }, null, 2));
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
const branch = process.env.PODMAN_DEPLOY_BRANCH ?? 'main';
const remote = process.env.PODMAN_DEPLOY_REMOTE ?? 'origin';
const services = (
process.env.PODMAN_DEPLOY_RESTART_SERVICES ??
['podman-platform-api.service', 'podman-platform-agent.service', 'caddy.service'].join(',')
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const report = {
ok: false,
branch,
remote,
startedAt: new Date().toISOString(),
completedAt: '',
changed: false,
from: '',
to: '',
steps: [],
};
function run(command, args, options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd ?? process.cwd(),
env: { ...process.env, ...options.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
child.on('error', (error) =>
resolve({ code: 127, signal: null, stdout, stderr: error.message }),
);
});
}
function detail(result, max = 1600) {
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
}
async function step(name, command, args, options) {
const result = await run(command, args, options);
const ok = result.code === 0;
report.steps.push({ name, ok, detail: detail(result) });
if (!ok) throw new Error(`${name} failed`);
return result;
}
async function gitOutput(args) {
const result = await run('git', args);
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed: ${detail(result)}`);
return result.stdout.trim();
}
async function main() {
const currentBranch = await gitOutput(['branch', '--show-current']);
if (currentBranch !== branch)
throw new Error(`expected branch ${branch}, found ${currentBranch}`);
await step('fetch', 'git', ['fetch', remote, branch]);
const dirty = await gitOutput(['status', '--porcelain']);
if (dirty) throw new Error(`working tree is dirty; refusing auto-deploy:\n${dirty}`);
const local = await gitOutput(['rev-parse', 'HEAD']);
const upstream = await gitOutput(['rev-parse', `${remote}/${branch}`]);
report.from = local;
report.to = upstream;
if (local === upstream) {
report.ok = true;
report.completedAt = new Date().toISOString();
console.log(JSON.stringify(report, null, 2));
return;
}
await step('fast-forward', 'git', ['merge', '--ff-only', `${remote}/${branch}`]);
report.changed = true;
await step('install', 'pnpm', ['install', '--frozen-lockfile'], {
env: { CI: 'true' },
});
await step('build', 'pnpm', ['build']);
await step('deploy static', 'pnpm', ['deploy:static:local']);
for (const service of services)
await step(`restart ${service}`, 'systemctl', ['restart', service]);
await step('hermes watchdog', 'pnpm', ['hermes:watchdog:strict']);
report.ok = true;
report.completedAt = new Date().toISOString();
console.log(JSON.stringify(report, null, 2));
}
try {
await main();
} catch (error) {
report.ok = false;
report.completedAt = new Date().toISOString();
report.error = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(report, null, 2));
process.exit(1);
}
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
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 { AbortController, clearTimeout, fetch, setTimeout } = globalThis;
const args = new Set(process.argv.slice(2));
const remediate = !args.has('--no-remediate') && process.env.PODMAN_HERMES_REMEDIATE !== '0';
const strict = args.has('--strict') || process.env.PODMAN_HERMES_STRICT === '1';
const jsonOnly = args.has('--json');
const rootUrl = process.env.PODMAN_PUBLIC_URL ?? 'https://165-22-129-249.sslip.io/';
const apiUrl = process.env.PODMAN_PUBLIC_API_URL ?? new URL('/api/pods', rootUrl).toString();
const healthUrl = process.env.PODMAN_PUBLIC_HEALTH_URL ?? new URL('/health', rootUrl).toString();
const timeoutMs = Number(process.env.PODMAN_HERMES_TIMEOUT_MS ?? 8000);
const stateDir = process.env.PODMAN_HERMES_STATE_DIR ?? '/var/log/podman';
const services = (
process.env.PODMAN_HERMES_SERVICES ??
[
'mongod.service',
'podman-platform-api.service',
'podman-platform-agent.service',
'caddy.service',
].join(',')
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const report = {
ok: false,
strict,
remediate,
startedAt: new Date().toISOString(),
completedAt: '',
checks: [],
remediation: [],
logs: {},
};
function addCheck(name, ok, detail = '') {
report.checks.push({ name, ok, detail });
return ok;
}
function summarizeOutput(result, max = 1200) {
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
}
function run(command, args = [], options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd ?? process.cwd(),
env: { ...process.env, ...options.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 2000).unref();
}, options.timeoutMs ?? timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal, stdout, stderr });
});
child.on('error', (error) => {
clearTimeout(timer);
resolve({ code: 127, signal: null, stdout, stderr: error.message });
});
});
}
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
const text = await res.text().catch(() => '');
return { ok: res.ok, status: res.status, text: text.slice(0, 300) };
} catch (error) {
return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
async function checkUrls() {
for (const [name, url] of [
['public root', rootUrl],
['public health', healthUrl],
['public api', apiUrl],
]) {
const result = await fetchWithTimeout(url);
addCheck(name, result.ok, `${url} -> ${result.status || result.text}`);
}
}
async function checkServices() {
for (const service of services) {
const active = await run('systemctl', ['is-active', '--quiet', service], { timeoutMs: 5000 });
addCheck(`service:${service}`, active.code === 0, `systemctl is-active exit ${active.code}`);
}
}
async function checkDoctor() {
const doctorArgs = ['deploy:doctor'];
if (strict) doctorArgs[0] = 'deploy:doctor:strict';
const result = await run('pnpm', doctorArgs, {
timeoutMs: Number(process.env.PODMAN_HERMES_DOCTOR_TIMEOUT_MS ?? 120000),
});
const ok = result.code === 0 && /"ok":\s*true/.test(result.stdout);
addCheck(`pnpm ${doctorArgs[0]}`, ok, summarizeOutput(result, 2000));
}
async function collectLogs(failedServices = services) {
for (const service of failedServices) {
const result = await run('journalctl', ['-u', service, '-n', '80', '--no-pager'], {
timeoutMs: 8000,
});
report.logs[service] = summarizeOutput(result, 6000);
}
}
async function restart(service) {
const result = await run('systemctl', ['restart', service], { timeoutMs: 20000 });
report.remediation.push({
action: `restart ${service}`,
ok: result.code === 0,
detail: summarizeOutput(result),
});
return result.code === 0;
}
async function validateCaddy() {
if (!existsSync('/etc/caddy/Caddyfile')) return;
const result = await run('caddy', ['validate', '--config', '/etc/caddy/Caddyfile'], {
timeoutMs: 10000,
});
report.remediation.push({
action: 'caddy validate',
ok: result.code === 0,
detail: summarizeOutput(result),
});
if (result.code === 0) {
const reload = await run('systemctl', ['reload', 'caddy.service'], { timeoutMs: 10000 });
report.remediation.push({
action: 'reload caddy.service',
ok: reload.code === 0,
detail: summarizeOutput(reload),
});
}
}
async function remediateFailures() {
const failed = report.checks.filter((c) => !c.ok);
if (!failed.length || !remediate) return;
const failedServiceNames = failed.map((c) => c.name.match(/^service:(.+)$/)?.[1]).filter(Boolean);
if (failedServiceNames.length) {
for (const service of failedServiceNames) await restart(service);
} else {
for (const service of services.filter((s) => s !== 'mongod.service')) await restart(service);
}
if (failed.some((c) => c.name.includes('public'))) await validateCaddy();
await delay(3000);
}
async function writeReport() {
report.completedAt = new Date().toISOString();
report.ok = report.checks.every((c) => c.ok);
await mkdir(stateDir, { recursive: true });
const payload = JSON.stringify(report, null, 2);
await writeFile(join(stateDir, 'hermes-watchdog-latest.json'), payload);
await writeFile(join(stateDir, `hermes-watchdog-${Date.now()}.json`), payload);
return payload;
}
async function alert(payload) {
const url = process.env.PODMAN_ALERT_WEBHOOK_URL;
if (!url || report.ok) return;
const failed = report.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
const text = `PodMan Hermes watchdog failed ${failed.length} check(s):\n${failed.join('\n')}`;
try {
await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
content: text,
text,
username: 'PodMan Hermes',
report: JSON.parse(payload),
}),
});
} catch (error) {
report.remediation.push({
action: 'send alert',
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
}
await checkServices();
await checkUrls();
await checkDoctor();
const firstFailed = report.checks.filter((c) => !c.ok);
await remediateFailures();
if (firstFailed.length && remediate) {
report.checks.push({ name: 'retry boundary', ok: true, detail: 'after remediation' });
await checkServices();
await checkUrls();
await checkDoctor();
}
await collectLogs(
report.checks
.filter((c) => !c.ok)
.map((c) => c.name.match(/^service:(.+)$/)?.[1])
.filter(Boolean),
);
const payload = await writeReport();
await alert(payload);
if (!jsonOnly) {
for (const check of report.checks) {
console.log(
`${check.ok ? 'OK ' : 'FAIL'} ${check.name}${check.detail ? ` - ${check.detail}` : ''}`,
);
}
for (const action of report.remediation) {
console.log(`${action.ok ? 'OK ' : 'FAIL'} remediate:${action.action}`);
}
}
console.log(payload);
process.exit(report.ok || !strict ? 0 : 1);
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { chmod, copyFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
const root = process.cwd();
const dryRun = process.argv.includes('--dry-run');
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'inherit' });
child.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)),
);
child.on('error', reject);
});
}
async function installFile(source, target, mode = 0o644) {
console.log(`${dryRun ? 'would install' : 'install'} ${source} -> ${target}`);
if (dryRun) return;
await copyFile(source, target);
await chmod(target, mode);
}
async function installGitHook() {
const hookDir = `${root}/.git/hooks`;
if (!existsSync(hookDir)) return;
const hook = `#!/usr/bin/env bash
set -euo pipefail
cd "${root}"
echo "[hermes] running pre-push verification"
pnpm -r typecheck
pnpm lint
pnpm hermes:watchdog -- --no-remediate --json >/tmp/podman-hermes-pre-push.json
echo "[hermes] pre-push verification passed"
`;
console.log(`${dryRun ? 'would write' : 'write'} ${hookDir}/pre-push`);
if (dryRun) return;
await writeFile(`${hookDir}/pre-push`, hook);
await chmod(`${hookDir}/pre-push`, 0o755);
}
await installFile(
'infra/systemd/podman-hermes-watchdog.service',
'/etc/systemd/system/podman-hermes-watchdog.service',
);
await installFile(
'infra/systemd/podman-hermes-watchdog.timer',
'/etc/systemd/system/podman-hermes-watchdog.timer',
);
await installFile(
'infra/systemd/podman-hermes-sync-deploy.service',
'/etc/systemd/system/podman-hermes-sync-deploy.service',
);
await installFile(
'infra/systemd/podman-hermes-sync-deploy.timer',
'/etc/systemd/system/podman-hermes-sync-deploy.timer',
);
await installFile(
'infra/systemd/podman-public-healthcheck.service',
'/etc/systemd/system/podman-public-healthcheck.service',
);
await installFile(
'infra/systemd/podman-public-healthcheck.timer',
'/etc/systemd/system/podman-public-healthcheck.timer',
);
await mkdir('/var/log/podman', { recursive: true });
await installGitHook();
if (!dryRun) {
await run('systemctl', ['daemon-reload']);
await run('systemctl', ['enable', '--now', 'podman-hermes-watchdog.timer']);
await run('systemctl', ['enable', '--now', 'podman-hermes-sync-deploy.timer']);
await run('systemctl', ['enable', '--now', 'podman-public-healthcheck.timer']);
await run('systemctl', [
'status',
'--no-pager',
'podman-hermes-watchdog.timer',
'podman-hermes-sync-deploy.timer',
'podman-public-healthcheck.timer',
]);
}
console.log(JSON.stringify({ ok: true, installed: !dryRun }, null, 2));
+54 -1
View File
@@ -155,8 +155,52 @@ async function verifyMemoryRecall() {
detectedAt: new Date().toISOString(), detectedAt: new Date().toISOString(),
}; };
await recordCollision(seed); await recordCollision(seed);
const { getDb } = await import('../backend/dist/memory/db.js');
const db = await getDb();
const stored = await db.collection('collisions').findOne({ id: seed.id });
if (!Array.isArray(stored?.embedding) || stored.embedding.length < 1) {
fail('memory collision was not enriched with an embedding');
}
const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` }); const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
if (!recalled) fail('memory recall did not find seeded collision'); if (!recalled) fail('memory recall did not find seeded collision');
const vectorRecalled = await recallSimilar({
...seed,
id: `${seed.id}_vector_query`,
file: 'src/nearby-memory.ts',
symbol: 'nearbyMemory',
});
if (!vectorRecalled) fail('vector memory recall did not find semantically similar collision');
}
async function verifyGraph() {
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 podId = `verify-graph-${Date.now()}`;
const { seedGraph } = await import('../backend/dist/graph/store.js');
await seedGraph(podId);
const graph = await json(await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(podId)}/graph`));
if (!Array.isArray(graph.nodes) || graph.nodes.length < 1)
fail('graph endpoint returned no nodes');
if (!Array.isArray(graph.edges) || graph.edges.length < 1)
fail('graph endpoint returned no edges');
const reach = await json(
await doFetch(
`${baseUrl}/api/pods/${encodeURIComponent(podId)}/graph/reach/${encodeURIComponent(
'engineer:karti',
)}`,
),
);
if (!Array.isArray(reach.reaches) || reach.reaches.length < 1) {
fail('graph reachability endpoint returned no reachable edges');
}
} }
async function verifyGitWatcher() { async function verifyGitWatcher() {
@@ -206,6 +250,7 @@ try {
await verifyApi(); await verifyApi();
await verifyCollisionAndMessages(); await verifyCollisionAndMessages();
await verifyMemoryRecall(); await verifyMemoryRecall();
await verifyGraph();
await verifyGitWatcher(); await verifyGitWatcher();
console.log( console.log(
JSON.stringify( JSON.stringify(
@@ -213,7 +258,15 @@ try {
ok: true, ok: true,
baseUrl, baseUrl,
mongoUri, mongoUri,
checks: ['health', 'token', 'pod-crud', 'collision', 'memory-recall', 'git-watcher'], checks: [
'health',
'token',
'pod-crud',
'collision',
'memory-recall',
'graph',
'git-watcher',
],
}, },
null, null,
2, 2,
+139 -5
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { TextEncoder } from 'node:util';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
@@ -7,6 +9,16 @@ const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
const shouldStartPreview = !process.env.FRONTEND_URL; const shouldStartPreview = !process.env.FRONTEND_URL;
const doFetch = globalThis.fetch; const doFetch = globalThis.fetch;
const verifyMember = `Verify ${process.pid}`; const verifyMember = `Verify ${process.pid}`;
const apiBase = process.env.BACKEND_URL
? process.env.BACKEND_URL.replace(/\/$/, '')
: process.env.FRONTEND_URL
? new URL(process.env.FRONTEND_URL).origin
: 'http://localhost:8787';
const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
DATA_TOPIC: 'podman.intervention',
}));
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
const { Room } = backendRequire('@livekit/rtc-node');
async function stopChild(child) { async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return; if (!child || child.exitCode !== null || child.signalCode !== null) return;
@@ -42,6 +54,75 @@ async function waitForPreview() {
throw new Error(`frontend preview did not become ready at ${frontendUrl}`); throw new Error(`frontend preview did not become ready at ${frontendUrl}`);
} }
async function fetchJson(path, init) {
const res = await doFetch(`${apiBase}${path}`, init);
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(`${path} returned ${res.status}: ${text}`);
return body;
}
async function connectPublisher(roomName) {
const { token, url } = await fetchJson('/api/token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
room: roomName,
identity: `verify-agent-${process.pid}`,
name: 'PodMan',
}),
});
const room = new Room();
await room.connect(url, token);
return room;
}
async function publishIntervention(room, podId) {
const now = new Date().toISOString();
const id = `verify-${process.pid}-${Date.now()}`;
const intervention = {
id: `int-${id}`,
collisionId: `col-${id}`,
podId,
kind: 'card',
message: 'Verification collision: two engineers are editing frontend/src/App.tsx.',
suggestedAction: { kind: 'sync_before_push' },
status: 'pending',
createdAt: now,
};
const message = {
type: 'COLLISION',
collision: {
id: intervention.collisionId,
podId,
file: 'src/App.tsx',
engineers: ['Verify', 'PodMan'],
severity: 'warn',
githubState: { branch: 'verify', unpushed: true, prs: [] },
detectedAt: now,
},
intervention,
};
await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), {
reliable: true,
topic: DATA_TOPIC,
});
}
async function waitForInterventionCard(page, room, podId) {
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
for (let attempt = 1; attempt <= 3; attempt++) {
await publishIntervention(room, podId);
try {
await page.getByText(cardText).waitFor({ timeout: 5_000 });
return;
} catch (error) {
if (attempt === 3) throw error;
await delay(500);
}
}
}
let preview = null; let preview = null;
if (shouldStartPreview) { if (shouldStartPreview) {
preview = spawn( preview = spawn(
@@ -57,6 +138,27 @@ if (shouldStartPreview) {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => {
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
configurable: true,
value: {
...(globalThis.navigator.mediaDevices ?? {}),
async getDisplayMedia() {
const canvas = globalThis.document.createElement('canvas');
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#111';
ctx.font = '28px sans-serif';
ctx.fillText('PodMan verification screen', 32, 72);
return canvas.captureStream(5);
},
},
});
});
const consoleErrors = []; const consoleErrors = [];
const pageErrors = []; const pageErrors = [];
@@ -86,8 +188,20 @@ try {
if (!hasPodCards) throw new Error('pod cards did not render'); if (!hasPodCards) throw new Error('pod cards did not render');
if (hasOverlay) throw new Error('Vite error overlay is visible'); if (hasOverlay) throw new Error('Vite error overlay is visible');
await page.getByPlaceholder('Your name').first().fill(verifyMember); await page.getByRole('button', { name: 'Team memory' }).click();
await page.getByRole('button', { name: 'Add and join' }).first().click(); await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
await page.getByText('Learning edges').waitFor({ timeout: 15_000 });
await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'engineer: Karti' }).click();
await page.getByText('Learned owner of auth; backend + DB wiring.').waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Whole graph' }).click();
await page.getByRole('button', { name: /Pods/i }).click();
const frontendPodCard = page
.getByText('Frontend Pod', { exact: true })
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
await frontendPodCard.getByPlaceholder('Your name').fill(verifyMember);
await frontendPodCard.getByRole('button', { name: 'Add and join' }).click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 }); await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const joinedText = await page.locator('body').innerText(); const joinedText = await page.locator('body').innerText();
@@ -99,6 +213,24 @@ try {
if (!hasPodView) { if (!hasPodView) {
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`); throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
} }
await page.getByRole('button', { name: 'Share screen' }).click();
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Stop sharing' }).click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const publisher = await connectPublisher('frontend-pod');
try {
await waitForInterventionCard(page, publisher, 'frontend-pod');
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
} finally {
await publisher.disconnect();
}
await page.getByRole('button', { name: 'Leave pod' }).click();
if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`); if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`);
if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`); if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`);
if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`); if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`);
@@ -109,7 +241,10 @@ try {
ok: true, ok: true,
frontendUrl, frontendUrl,
bodyLength: bodyText.length, bodyLength: bodyText.length,
graph: true,
joined: true, joined: true,
screenShare: true,
intervention: true,
member: verifyMember, member: verifyMember,
}, },
null, null,
@@ -117,12 +252,11 @@ try {
), ),
); );
} finally { } finally {
const apiBase = process.env.FRONTEND_URL
? new URL(process.env.FRONTEND_URL).origin
: 'http://localhost:8787';
await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, { await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, {
method: 'DELETE', method: 'DELETE',
}).catch(() => {}); }).catch(() => {});
await browser.close(); await browser.close();
await stopChild(preview); await stopChild(preview);
} }
process.exit(0);
+1
View File
@@ -9,6 +9,7 @@ export type {
SuggestedActionKind, SuggestedActionKind,
} from './intervention.js'; } from './intervention.js';
export * from './messages.js'; export * from './messages.js';
export type { HermesMessage } from './messages.js';
export type { export type {
PodGraph, PodGraph,
PodGraphNode, PodGraphNode,
+12
View File
@@ -7,10 +7,22 @@ export const DATA_TOPIC = 'podman.intervention' as const;
/** Wire messages exchanged between the PodMan agent and engineer PWAs. */ /** Wire messages exchanged between the PodMan agent and engineer PWAs. */
export type DataMessage = export type DataMessage =
| { type: 'COLLISION'; collision: Collision; intervention: Intervention } | { type: 'COLLISION'; collision: Collision; intervention: Intervention }
| { type: 'HERMES_MESSAGE'; message: HermesMessage }
| { type: 'VOICE_CUE'; text: string } | { type: 'VOICE_CUE'; text: string }
| { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string } | { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string }
| { type: 'GIT_REPORT'; report: LocalGitReport }; | { type: 'GIT_REPORT'; report: LocalGitReport };
/** A targeted teammate/project-channel notification from the Hermes action layer. */
export interface HermesMessage {
id: string;
podId: string;
interventionId: string;
recipients: string[];
text: string;
urgency: 'normal' | 'urgent';
createdAt: string;
}
/** Outcome of an intervention — the supervision signal for policy learning. */ /** Outcome of an intervention — the supervision signal for policy learning. */
export interface InterventionOutcome { export interface InterventionOutcome {
interventionId: string; interventionId: string;