Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07509c62bd | |||
| a49501b3c4 | |||
| 7c572fbbcb | |||
| 39a06499af | |||
| 146d7e4bd2 | |||
| 4253921532 | |||
| 89893110f1 | |||
| 4726e8ce80 | |||
| 1088196ba6 | |||
| b092a24941 | |||
| 3d99dd2449 | |||
| 205939616b |
+19
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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 } {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user