Add Hermes operations management layer
This commit is contained in:
@@ -11,6 +11,7 @@ GEMINI_API_KEY=
|
|||||||
# canonical deployment secret name used by DigitalOcean and docs.
|
# 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-3.1-flash-tts-preview
|
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=
|
||||||
@@ -35,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 voice audio into the room
|
await publishHermesMessage(this.room, collision, intervention);
|
||||||
|
if (collision.severity === 'critical') await speak(this.room, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const env = {
|
|||||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_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-3.1-flash-tts-preview'),
|
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
|
||||||
@@ -34,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. */
|
||||||
|
|||||||
+116
-19
@@ -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 {
|
||||||
@@ -33,6 +50,13 @@ function memoryText(collision: Collision): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +83,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,11 +122,40 @@ 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;
|
||||||
|
|
||||||
@@ -93,31 +178,43 @@ 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] vector recall unavailable: ${(err as Error).message}`);
|
||||||
return null;
|
return 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. Exact Mongo recall is the MVP path;
|
||||||
* Voyage + Atlas Vector Search is used first when configured.
|
* vector search is an optional broader fallback when Atlas is configured.
|
||||||
*/
|
*/
|
||||||
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 recallBySignature(collision)) ?? recallByVector(collision);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-22
@@ -178,29 +178,34 @@ 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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ services:
|
|||||||
- { 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-3.1-flash-tts-preview }
|
- { 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 }
|
||||||
@@ -74,6 +75,7 @@ workers:
|
|||||||
- { 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-3.1-flash-tts-preview }
|
- { 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
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
"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",
|
"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",
|
||||||
|
|||||||
@@ -272,6 +272,30 @@ async function checkGeminiVoiceModel() {
|
|||||||
return `${model}, generated ${Buffer.from(audio, 'base64').byteLength} audio bytes`;
|
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() {
|
||||||
if (!isSet('VOYAGE_API_KEY')) throw new Error('VOYAGE_API_KEY is not set');
|
if (!isSet('VOYAGE_API_KEY')) throw new Error('VOYAGE_API_KEY is not set');
|
||||||
const model = process.env.VOYAGE_EMBEDDING_MODEL ?? 'voyage-4-lite';
|
const model = process.env.VOYAGE_EMBEDDING_MODEL ?? 'voyage-4-lite';
|
||||||
@@ -325,6 +349,7 @@ 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 voice model', checkGeminiVoiceModel);
|
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,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,6 +155,12 @@ 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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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