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:
parent
81940a1598
commit
a06129132e
4 changed files with 528 additions and 0 deletions
121
manifests/acb-api-deployment.yml
Normal file
121
manifests/acb-api-deployment.yml
Normal 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
|
||||
106
manifests/acb-evolver-deployment.yml
Normal file
106
manifests/acb-evolver-deployment.yml
Normal 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
|
||||
89
web/src/lib/lazy-section.ts
Normal file
89
web/src/lib/lazy-section.ts
Normal 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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
212
web/src/lib/virtual-list.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue