2026-05-19 00:26:53 +02:00
|
|
|
import { Worker } from 'bullmq';
|
|
|
|
|
import { Redis } from 'ioredis';
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
import { GeneratorSpec } from '@bmm/types';
|
2026-05-19 00:26:53 +02:00
|
|
|
import { builds, createDb, eq, mcpServers } from '@bmm/db';
|
|
|
|
|
import { config } from './config.js';
|
|
|
|
|
import { generateSpec } from './lib/claude.js';
|
|
|
|
|
import { renderServerCode } from './lib/render.js';
|
|
|
|
|
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
|
|
|
|
|
import { allocatePort, deployContainer, dockerAvailable } from './lib/deploy.js';
|
|
|
|
|
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
|
|
|
|
|
|
|
|
|
|
const db = createDb();
|
|
|
|
|
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
const cacheReader = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
|
2026-05-19 00:26:53 +02:00
|
|
|
|
|
|
|
|
interface JobData {
|
|
|
|
|
buildId: string;
|
|
|
|
|
serverId: string;
|
|
|
|
|
orgId: string;
|
|
|
|
|
prompt: string;
|
|
|
|
|
version: number;
|
|
|
|
|
slug: string;
|
|
|
|
|
serverName: string;
|
|
|
|
|
secrets: Record<string, string>;
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
previewId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadCachedSpec(previewId: string): Promise<GeneratorSpec | null> {
|
|
|
|
|
const raw = await cacheReader.get(`preview:${previewId}`);
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = GeneratorSpec.safeParse(JSON.parse(raw));
|
|
|
|
|
return parsed.success ? parsed.data : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-19 00:26:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const worker = new Worker<JobData>(
|
|
|
|
|
'build',
|
|
|
|
|
async (job) => {
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
|
2026-05-19 00:26:53 +02:00
|
|
|
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
|
|
|
|
|
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
|
|
|
|
await emitStatus(buildId, 'generating');
|
|
|
|
|
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
let spec: GeneratorSpec | null = null;
|
|
|
|
|
let source: 'claude' | 'mock' | 'cached' = 'mock';
|
|
|
|
|
|
|
|
|
|
if (previewId) {
|
|
|
|
|
spec = await loadCachedSpec(previewId);
|
|
|
|
|
if (spec) {
|
|
|
|
|
source = 'cached';
|
|
|
|
|
await log('info', `Re-using preview spec ${previewId} (skipping Claude call)`);
|
|
|
|
|
} else {
|
|
|
|
|
await log('warn', `Preview ${previewId} cache miss — regenerating`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!spec) {
|
|
|
|
|
await log('info', 'Generating MCP server spec...');
|
|
|
|
|
const result = await generateSpec(prompt);
|
|
|
|
|
spec = result.spec;
|
|
|
|
|
source = result.source;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`);
|
2026-05-19 00:26:53 +02:00
|
|
|
const generatedCode = renderServerCode(spec);
|
|
|
|
|
await db
|
|
|
|
|
.update(builds)
|
|
|
|
|
.set({ generatedSpec: spec, generatedCode })
|
|
|
|
|
.where(eq(builds.id, buildId));
|
|
|
|
|
|
|
|
|
|
await db.update(builds).set({ status: 'building' }).where(eq(builds.id, buildId));
|
|
|
|
|
await db.update(mcpServers).set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
|
|
|
|
await emitStatus(buildId, 'building');
|
|
|
|
|
await log('info', 'Preparing build context...');
|
|
|
|
|
|
|
|
|
|
const { contextDir, imageTag } = await prepareBuildContext(serverId, version, slug, generatedCode, spec);
|
|
|
|
|
await log('info', `Build context at ${contextDir}`);
|
|
|
|
|
|
|
|
|
|
await log('info', 'Running static checks...');
|
|
|
|
|
await staticCheck(contextDir);
|
|
|
|
|
await log('info', 'Static checks passed.');
|
|
|
|
|
|
|
|
|
|
const hasDocker = await dockerAvailable();
|
|
|
|
|
if (!hasDocker) {
|
|
|
|
|
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
|
|
|
|
|
await db.update(builds).set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() }).where(eq(builds.id, buildId));
|
|
|
|
|
await db.update(mcpServers).set({ status: 'failed', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
|
|
|
|
await emitDone(buildId, 'failed', serverId, null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await log('info', `Building Docker image ${imageTag}...`);
|
|
|
|
|
await dockerBuild(contextDir, imageTag, (line) => {
|
|
|
|
|
emitLog(buildId, 'info', line).catch(() => undefined);
|
|
|
|
|
});
|
|
|
|
|
await log('info', 'Image built.');
|
|
|
|
|
|
|
|
|
|
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
|
|
|
|
|
await db.update(mcpServers).set({ status: 'deploying', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
|
|
|
|
await emitStatus(buildId, 'deploying');
|
|
|
|
|
|
|
|
|
|
const port = await allocatePort();
|
|
|
|
|
const publicUrl = `http://${config.RUNNER_HOST}:${port}`;
|
|
|
|
|
const envVars: Record<string, string> = {
|
|
|
|
|
...secrets,
|
|
|
|
|
PUBLIC_URL: publicUrl,
|
|
|
|
|
CONTROL_PLANE_URL: config.CONTROL_PLANE_URL,
|
2026-05-19 00:57:23 +02:00
|
|
|
OAUTH_ISSUER: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
|
2026-05-19 00:26:53 +02:00
|
|
|
PORT: '3000',
|
|
|
|
|
SERVER_ID: serverId,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
|
|
|
|
|
await log('info', `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`);
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(builds)
|
|
|
|
|
.set({ status: 'success', finishedAt: new Date() })
|
|
|
|
|
.where(eq(builds.id, buildId));
|
|
|
|
|
await db
|
|
|
|
|
.update(mcpServers)
|
|
|
|
|
.set({ status: 'live', currentVersion: version, publicUrl: handle.publicUrl, updatedAt: new Date() })
|
|
|
|
|
.where(eq(mcpServers.id, serverId));
|
|
|
|
|
|
|
|
|
|
await emitStatus(buildId, 'success');
|
|
|
|
|
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
|
console.error('[worker] build failed:', err);
|
|
|
|
|
await db
|
|
|
|
|
.update(builds)
|
|
|
|
|
.set({ status: 'failed', errorMessage: msg, finishedAt: new Date() })
|
|
|
|
|
.where(eq(builds.id, buildId));
|
|
|
|
|
await db
|
|
|
|
|
.update(mcpServers)
|
|
|
|
|
.set({ status: 'failed', updatedAt: new Date() })
|
|
|
|
|
.where(eq(mcpServers.id, serverId));
|
|
|
|
|
await emitError(buildId, msg);
|
|
|
|
|
await emitDone(buildId, 'failed', serverId, null);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ connection, concurrency: 2 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
worker.on('ready', () => console.log('[generator] worker ready'));
|
|
|
|
|
worker.on('failed', (job, err) => console.error('[generator] job failed', job?.id, err?.message));
|
|
|
|
|
worker.on('error', (err) => console.error('[generator] worker error', err.message));
|