feat(k8s): add deployment manifests for acb-evolver and acb-api per §9.2

Staging manifests for sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/:
- acb-evolver: Deployment + ServiceAccount with LLM/PG/R2 secrets
- acb-api: Deployment + Service + IngressRoute for api.ai-code-battle.ardenone.com

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 13:50:20 -04:00
parent 81940a1598
commit a06129132e
4 changed files with 528 additions and 0 deletions

View file

@ -0,0 +1,121 @@
# acb-api: HTTP API server for AI Code Battle
# Bot registration, job coordination, replay serving, bot profiles,
# leaderboards, predictions, and community feedback.
# Connects to PostgreSQL (CNPG) and Valkey (Redis-compatible).
#
# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: acb-api
namespace: ai-code-battle
labels:
app.kubernetes.io/name: acb-api
app.kubernetes.io/part-of: ai-code-battle
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: acb-api
template:
metadata:
labels:
app.kubernetes.io/name: acb-api
app.kubernetes.io/part-of: ai-code-battle
spec:
restartPolicy: Always
containers:
- name: api
image: ronaldraygun/acb-api:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
protocol: TCP
env:
- name: ACB_LISTEN_ADDR
value: ":8080"
- name: ACB_DATABASE_URL
valueFrom:
secretKeyRef:
name: acb-app-credentials-acb-app
key: uri
- name: ACB_VALKEY_ADDR
value: "valkey-master.valkey.svc.cluster.local:6379"
- name: ACB_VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: keydb-secret
key: password
optional: true
- name: ACB_WORKER_API_KEY
valueFrom:
secretKeyRef:
name: acb-api-secrets
key: worker-api-key
- name: ACB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: acb-api-secrets
key: encryption-key
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
imagePullSecrets:
- name: docker-hub-registry
---
apiVersion: v1
kind: Service
metadata:
name: acb-api
namespace: ai-code-battle
labels:
app.kubernetes.io/name: acb-api
app.kubernetes.io/part-of: ai-code-battle
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: acb-api
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: acb-api
namespace: ai-code-battle
labels:
app.kubernetes.io/name: acb-api
app.kubernetes.io/part-of: ai-code-battle
spec:
entryPoints:
- websecure
routes:
- match: Host(`api.ai-code-battle.ardenone.com`)
kind: Rule
services:
- name: acb-api
port: 80
tls:
certResolver: letsencrypt

View file

@ -0,0 +1,106 @@
# acb-evolver: LLM-driven bot evolution pipeline
# Reads match data from PostgreSQL, generates candidates via LLM, validates,
# evaluates in arena, promotes winners to the live bot fleet.
#
# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: acb-evolver
namespace: ai-code-battle
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: acb-evolver
namespace: ai-code-battle
labels:
app.kubernetes.io/name: acb-evolver
app.kubernetes.io/part-of: ai-code-battle
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: acb-evolver
template:
metadata:
labels:
app.kubernetes.io/name: acb-evolver
app.kubernetes.io/part-of: ai-code-battle
spec:
serviceAccountName: acb-evolver
restartPolicy: Always
containers:
- name: evolver
image: ronaldraygun/acb-evolver:latest
imagePullPolicy: Always
args: ["run", "-continuous"]
env:
- name: ACB_DATABASE_URL
valueFrom:
secretKeyRef:
name: acb-app-credentials-acb-app
key: uri
- name: ACB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: acb-evolver-secrets
key: encryption-key
- name: ACB_LLM_URL
valueFrom:
secretKeyRef:
name: openai-secret
key: url
optional: true
- name: ACB_LLM_API_KEY
valueFrom:
secretKeyRef:
name: openai-secret
key: api-key
optional: true
- name: ACB_KUBECTL_SERVER
value: "http://traefik-apexalgo-iad:8001"
- name: ACB_REGISTRY
value: "ronaldraygun"
- name: ACB_REPO_DIR
value: "/repo"
- name: ACB_R2_ENDPOINT
valueFrom:
secretKeyRef:
name: cloudflare-pages-secret
key: r2-endpoint
optional: true
- name: ACB_R2_BUCKET
value: "acb-data"
- name: ACB_R2_ACCESS_KEY
valueFrom:
secretKeyRef:
name: cloudflare-pages-secret
key: r2-access-key
optional: true
- name: ACB_R2_SECRET_KEY
valueFrom:
secretKeyRef:
name: cloudflare-pages-secret
key: r2-secret-key
optional: true
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "4Gi"
# Evolver is a batch loop, not an HTTP server — use exec probe
livenessProbe:
exec:
command:
- pgrep
- -x
- acb-evolver
initialDelaySeconds: 30
periodSeconds: 60
failureThreshold: 3
imagePullSecrets:
- name: docker-hub-registry

View file

@ -0,0 +1,89 @@
// §16.15 Lazy section — defers rendering of below-the-fold content until
// it scrolls into view using IntersectionObserver. Shows a minimal
// placeholder until revealed.
export interface LazySectionOptions {
/** Placeholder HTML shown until the section is visible */
placeholder?: string;
/** Margin around the root to trigger early (default "200px") */
rootMargin?: string;
/** Threshold for triggering (default 0) */
threshold?: number;
}
const DEFAULT_PLACEHOLDER = '<div class="lazy-placeholder" style="min-height:60px"></div>';
/**
* Wraps a block of HTML in a lazy-loaded container. The container starts
* with a lightweight placeholder and swaps in the real content when it
* enters the viewport (with a configurable margin for preloading).
*
* Returns the outer HTML string (safe to use in innerHTML assignments).
*/
export function lazySection(
id: string,
contentHtml: string,
opts: LazySectionOptions = {}
): string {
const placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER;
return `<div class="lazy-section" data-lazy-id="${id}" data-lazy-content="${escapeAttr(contentHtml)}">${placeholder}</div>`;
}
/**
* Activates all lazy sections within the given root element.
* Call after mounting HTML (e.g., after innerHTML assignment).
*/
export function initLazySections(
root: HTMLElement,
opts: Pick<LazySectionOptions, 'rootMargin' | 'threshold'> = {}
): () => void {
const rootMargin = opts.rootMargin ?? '200px';
const threshold = opts.threshold ?? 0;
const sections = root.querySelectorAll<HTMLElement>('.lazy-section');
if (sections.length === 0) return () => {};
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target as HTMLElement;
reveal(el);
observer.unobserve(el);
}
},
{ rootMargin, threshold }
);
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}
/** Immediately reveal a lazy section (also used by IntersectionObserver). */
export function revealLazySection(root: HTMLElement, id: string): void {
const el = root.querySelector<HTMLElement>(`[data-lazy-id="${id}"]`);
if (el) reveal(el);
}
function reveal(el: HTMLElement): void {
const content = el.getAttribute('data-lazy-content');
if (content === null) return;
// Remove the data attribute to avoid re-revealing
el.removeAttribute('data-lazy-content');
el.classList.add('lazy-section-revealed');
el.innerHTML = decodeAttr(content);
}
function escapeAttr(html: string): string {
return html
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function decodeAttr(encoded: string): string {
const el = document.createElement('textarea');
el.innerHTML = encoded;
return el.value;
}

212
web/src/lib/virtual-list.ts Normal file
View file

@ -0,0 +1,212 @@
// §16.15 Virtual list — renders only visible rows for large datasets.
// Keeps the DOM small even with 1000+ entries. Each row has a fixed height
// and only rows in the viewport (plus a buffer) are materialised.
export interface VirtualListOptions<T> {
items: T[];
rowHeight: number;
/** Extra rows rendered above/below viewport (default 5) */
buffer?: number;
/** Initial number of rows to show before requiring "Show more" */
initialCount?: number;
renderRow: (item: T, index: number) => string;
renderExpanded?: (item: T, index: number) => string;
containerClass?: string;
ariaLabel?: string;
}
interface VirtualListState {
scrollTop: number;
viewportHeight: number;
visibleStart: number;
visibleEnd: number;
expandedIndex: number | null;
showMoreCount: number;
}
export class VirtualList<T> {
private opts: VirtualListOptions<T>;
private state: VirtualListState;
private scrollEl: HTMLElement | null = null;
private spacerAbove: HTMLElement | null = null;
private spacerBelow: HTMLElement | null = null;
private rowContainer: HTMLElement | null = null;
private showMoreEl: HTMLElement | null = null;
private rafId = 0;
private observer: ResizeObserver | null = null;
constructor(opts: VirtualListOptions<T>) {
this.opts = opts;
const initial = opts.initialCount ?? opts.items.length;
this.state = {
scrollTop: 0,
viewportHeight: 600,
visibleStart: 0,
visibleEnd: Math.min(initial, opts.items.length),
expandedIndex: null,
showMoreCount: initial,
};
}
/** Mount the virtual list into the given container. */
mount(container: HTMLElement): void {
container.innerHTML = '';
container.classList.add(this.opts.containerClass ?? 'virtual-list');
// Scrollable viewport
const scrollEl = document.createElement('div');
scrollEl.className = 'virtual-list-scroll';
scrollEl.setAttribute('role', 'list');
if (this.opts.ariaLabel) scrollEl.setAttribute('aria-label', this.opts.ariaLabel);
scrollEl.tabIndex = 0;
this.scrollEl = scrollEl;
// Spacers for total height
this.spacerAbove = document.createElement('div');
this.spacerBelow = document.createElement('div');
// Actual rendered rows
this.rowContainer = document.createElement('div');
this.rowContainer.className = 'virtual-list-rows';
scrollEl.appendChild(this.spacerAbove);
scrollEl.appendChild(this.rowContainer);
scrollEl.appendChild(this.spacerBelow);
container.appendChild(scrollEl);
// "Show more" button
const showMoreEl = document.createElement('button');
showMoreEl.className = 'virtual-list-show-more btn secondary';
showMoreEl.type = 'button';
this.showMoreEl = showMoreEl;
this.updateShowMore();
container.appendChild(this.showMoreEl);
// Bind events
scrollEl.addEventListener('scroll', () => this.onScroll(), { passive: true });
this.showMoreEl.addEventListener('click', () => this.onShowMore());
// Keyboard: Enter/Space on a row toggles expand
scrollEl.addEventListener('keydown', (e) => this.onKeyDown(e));
// Track viewport height
this.observer = new ResizeObserver((entries) => {
for (const entry of entries) {
this.state.viewportHeight = entry.contentRect.height;
this.render();
}
});
this.observer.observe(scrollEl);
// Initial render
this.state.viewportHeight = scrollEl.clientHeight || 600;
this.render();
}
destroy(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.observer?.disconnect();
}
private onScroll(): void {
if (this.rafId) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = 0;
if (!this.scrollEl) return;
this.state.scrollTop = this.scrollEl.scrollTop;
this.computeWindow();
this.render();
});
}
private computeWindow(): void {
const { rowHeight } = this.opts;
const buffer = this.opts.buffer ?? 5;
const maxIdx = this.state.showMoreCount;
const rawStart = Math.floor(this.state.scrollTop / rowHeight) - buffer;
const rawEnd = Math.ceil((this.state.scrollTop + this.state.viewportHeight) / rowHeight) + buffer;
this.state.visibleStart = Math.max(0, rawStart);
this.state.visibleEnd = Math.min(maxIdx, rawEnd);
}
private render(): void {
if (!this.rowContainer || !this.spacerAbove || !this.spacerBelow) return;
const { items, rowHeight, renderRow, renderExpanded } = this.opts;
const { visibleStart, visibleEnd, expandedIndex, showMoreCount } = this.state;
const frags: string[] = [];
for (let i = visibleStart; i < visibleEnd; i++) {
const item = items[i];
if (!item) continue;
const isExpanded = expandedIndex === i;
frags.push(`<div class="virtual-list-row${isExpanded ? ' expanded' : ''}" data-idx="${i}" role="listitem" tabindex="0">`);
frags.push(renderRow(item, i));
if (isExpanded && renderExpanded) {
frags.push(`<div class="virtual-list-expanded">${renderExpanded(item, i)}</div>`);
}
frags.push('</div>');
}
this.rowContainer.innerHTML = frags.join('');
this.spacerAbove.style.height = `${visibleStart * rowHeight}px`;
const belowStart = visibleEnd;
const belowCount = showMoreCount - belowStart;
this.spacerBelow.style.height = `${Math.max(0, belowCount) * rowHeight}px`;
// Set total scrollable height hint
if (this.scrollEl) {
this.scrollEl.style.setProperty('--vl-total-height', `${showMoreCount * rowHeight}px`);
}
// Wire expand toggles
this.rowContainer.querySelectorAll<HTMLElement>('.virtual-list-row').forEach(row => {
row.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('a, button')) return;
const idx = Number(row.dataset.idx);
this.toggleExpand(idx);
});
});
}
private toggleExpand(idx: number): void {
this.state.expandedIndex = this.state.expandedIndex === idx ? null : idx;
this.render();
}
private onKeyDown(e: KeyboardEvent): void {
const row = (e.target as HTMLElement).closest('.virtual-list-row') as HTMLElement | null;
if (!row) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const idx = Number(row.dataset.idx);
this.toggleExpand(idx);
}
}
private onShowMore(): void {
const batchSize = 100;
this.state.showMoreCount = Math.min(
this.state.showMoreCount + batchSize,
this.opts.items.length
);
this.computeWindow();
this.render();
this.updateShowMore();
}
private updateShowMore(): void {
if (!this.showMoreEl) return;
const remaining = this.opts.items.length - this.state.showMoreCount;
if (remaining <= 0) {
this.showMoreEl.style.display = 'none';
return;
}
this.showMoreEl.style.display = '';
const next = Math.min(100, remaining);
this.showMoreEl.textContent = `Show ${next} more (${remaining} remaining)`;
this.showMoreEl.setAttribute('aria-label', `Show ${next} more entries, ${remaining} remaining`);
}
}