454fdab13b
Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
import { MongoClient, type Db, type Collection } from 'mongodb';
|
|
import type {
|
|
EngineerContext,
|
|
Collision,
|
|
Intervention,
|
|
InterventionOutcome,
|
|
Pod,
|
|
} from '@podman/shared';
|
|
import { env } from '../env.js';
|
|
|
|
let clientPromise: Promise<MongoClient> | null = null;
|
|
|
|
function getClient(): Promise<MongoClient> {
|
|
if (!clientPromise) {
|
|
const client = new MongoClient(env.MONGODB_URI, { serverSelectionTimeoutMS: 3000 });
|
|
clientPromise = client.connect();
|
|
}
|
|
return clientPromise;
|
|
}
|
|
|
|
/** The PodMan database (name comes from the MONGODB_URI path, e.g. `podman`). */
|
|
export async function getDb(): Promise<Db> {
|
|
const client = await getClient();
|
|
return client.db();
|
|
}
|
|
|
|
export async function closeMemory(): Promise<void> {
|
|
if (!clientPromise) return;
|
|
const client = await clientPromise;
|
|
clientPromise = null;
|
|
await client.close();
|
|
}
|
|
|
|
/** A durable record that PodMan stayed quiet on a recurring collision because
|
|
* its signature was previously dismissed — the negative-feedback loop made
|
|
* auditable. Timestamped at the repeat; materialized as `suppressed` activity
|
|
* in graph/live.ts. The suppressed collision is never written to `collisions`
|
|
* (the agent returns before recordCollision), so this is its own record. */
|
|
export interface SuppressionDoc {
|
|
id: string;
|
|
podId: string;
|
|
collisionId: string;
|
|
file: string;
|
|
engineers: string[];
|
|
priorInterventionId?: string;
|
|
priorDismissedAt?: string;
|
|
suppressedAt: string;
|
|
}
|
|
|
|
export interface PodCollections {
|
|
pods: Collection<Pod>;
|
|
observations: Collection<EngineerContext>;
|
|
collisions: Collection<Collision>;
|
|
interventions: Collection<Intervention>;
|
|
outcomes: Collection<InterventionOutcome>;
|
|
suppressions: Collection<SuppressionDoc>;
|
|
}
|
|
|
|
export interface UserPodContextDoc {
|
|
id: string;
|
|
clerkUserId: string;
|
|
podId: string;
|
|
memberName?: string;
|
|
action: string;
|
|
source: 'clerk';
|
|
observedAt: string;
|
|
metadata?: Record<string, string | number | boolean | null>;
|
|
}
|
|
|
|
export async function collections(): Promise<PodCollections> {
|
|
const db = await getDb();
|
|
return {
|
|
pods: db.collection<Pod>('pods'),
|
|
observations: db.collection<EngineerContext>('observations'),
|
|
collisions: db.collection<Collision>('collisions'),
|
|
interventions: db.collection<Intervention>('interventions'),
|
|
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
|
suppressions: db.collection<SuppressionDoc>('suppressions'),
|
|
};
|
|
}
|
|
|
|
export interface GitState {
|
|
changedFiles: string[];
|
|
branch: string | null;
|
|
recentCommit: string | null;
|
|
gitUpdatedAt: Date | null;
|
|
}
|
|
|
|
const GIT_STATE_TTL_MS = Number(process.env.GIT_STATE_TTL_MS ?? '120000');
|
|
|
|
/** Fetch latest git state per engineer for a pod from the engineer_states collection.
|
|
* Returns a map keyed by engineer name (matches --name arg used in podman-agent.mjs). */
|
|
export async function getGitStates(podId: string): Promise<Map<string, GitState>> {
|
|
const db = await getDb();
|
|
const col = db.collection<{
|
|
_id: string;
|
|
name: string;
|
|
changedFiles?: string[];
|
|
branch?: string | null;
|
|
recentCommit?: string | null;
|
|
gitUpdatedAt?: Date;
|
|
}>('engineer_states');
|
|
const docs = await col.find({ podId }).toArray();
|
|
const map = new Map<string, GitState>();
|
|
const now = Date.now();
|
|
for (const doc of docs) {
|
|
const updatedAt = doc.gitUpdatedAt ? new Date(doc.gitUpdatedAt) : null;
|
|
if (
|
|
updatedAt &&
|
|
!Number.isNaN(updatedAt.getTime()) &&
|
|
GIT_STATE_TTL_MS > 0 &&
|
|
now - updatedAt.getTime() > GIT_STATE_TTL_MS
|
|
) {
|
|
continue;
|
|
}
|
|
map.set(doc.name, {
|
|
changedFiles: doc.changedFiles ?? [],
|
|
branch: doc.branch ?? null,
|
|
recentCommit: doc.recentCommit ?? null,
|
|
gitUpdatedAt: doc.gitUpdatedAt ?? null,
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Connect, verify reachability, and create helpful indexes. Call once on startup. */
|
|
export async function initMemory(): Promise<void> {
|
|
const db = await getDb();
|
|
await db.command({ ping: 1 });
|
|
const c = await collections();
|
|
// Create each index independently so one failure (e.g. the unique pods index
|
|
// failing on pre-existing duplicate ids) doesn't abort the others or block
|
|
// seeding. Failures are logged loudly rather than silently swallowed.
|
|
const indexes: Array<[string, () => Promise<unknown>]> = [
|
|
['pods.id (unique)', () => c.pods.createIndex({ id: 1 }, { unique: true })],
|
|
['observations.podId', () => c.observations.createIndex({ podId: 1, observedAt: -1 })],
|
|
['observations.engineerId', () => c.observations.createIndex({ engineerId: 1 })],
|
|
['collisions.podId', () => c.collisions.createIndex({ podId: 1, detectedAt: -1 })],
|
|
[
|
|
'collisions.memorySignature',
|
|
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
|
|
],
|
|
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
|
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
|
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
|
['suppressions.podId', () => c.suppressions.createIndex({ podId: 1, suppressedAt: -1 })],
|
|
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
|
[
|
|
'hermes_jobs.session',
|
|
() => db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }),
|
|
],
|
|
[
|
|
'hermes_job_events.job',
|
|
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
|
],
|
|
[
|
|
'user_pod_context.user',
|
|
() => db.collection('user_pod_context').createIndex({ clerkUserId: 1, observedAt: -1 }),
|
|
],
|
|
[
|
|
'user_pod_context.pod',
|
|
() => db.collection('user_pod_context').createIndex({ podId: 1, observedAt: -1 }),
|
|
],
|
|
[
|
|
'user_learning_profiles.user',
|
|
() => db.collection('user_learning_profiles').createIndex({ clerkUserId: 1 }, { unique: true }),
|
|
],
|
|
[
|
|
'user_learning_profiles.updated',
|
|
() => db.collection('user_learning_profiles').createIndex({ updatedAt: -1 }),
|
|
],
|
|
[
|
|
'conversation_notes.identity',
|
|
() => db.collection('conversation_notes').createIndex({ identity: 1, createdAt: -1 }),
|
|
],
|
|
];
|
|
for (const [name, make] of indexes) {
|
|
try {
|
|
await make();
|
|
} catch (err) {
|
|
console.error(`[memory] index "${name}" failed: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
console.log(`[memory] mongo connected -> ${db.databaseName}`);
|
|
}
|