ai-code-battle/cmd/acb-index-builder/main.go

378 lines
11 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/aicodebattle/acb/metrics"
_ "github.com/lib/pq"
)
func main() {
// Setup structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg := LoadConfig()
// Connect to PostgreSQL
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cfg.PostgresHost, cfg.PostgresPort, cfg.PostgresUser, cfg.PostgresPassword, cfg.PostgresDatabase,
)
db, err := sql.Open("postgres", connStr)
if err != nil {
slog.Error("Failed to connect to PostgreSQL", "error", err)
os.Exit(1)
}
defer db.Close()
// Verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := db.PingContext(ctx); err != nil {
cancel()
slog.Error("Failed to ping PostgreSQL", "error", err)
os.Exit(1)
}
cancel()
slog.Info("Connected to PostgreSQL",
"host", cfg.PostgresHost,
"database", cfg.PostgresDatabase,
)
// Start internal metrics server (separate port for Prometheus scraping)
metricsSrv := metrics.StartServer()
defer metricsSrv.Close()
// Create output directory
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
slog.Error("Failed to create output directory", "error", err, "path", cfg.OutputDir)
os.Exit(1)
}
// Initialize crane auth for pulling site build images from the registry
if err := initCraneAuth(cfg); err != nil {
slog.Warn("Failed to initialize crane auth, site builds will use baked-in assets", "error", err)
}
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
startTime := time.Now()
buildCount := 0
for {
// Check lifetime
if time.Since(startTime) > cfg.MaxLifetime {
slog.Info("Max lifetime reached, exiting", "lifetime", cfg.MaxLifetime)
os.Exit(0)
}
// Check for shutdown signal
select {
case sig := <-sigChan:
slog.Info("Received signal, shutting down", "signal", sig)
os.Exit(0)
default:
}
// Run build cycle with timeout
buildCount++
slog.Info("Starting build cycle", "count", buildCount)
buildStart := time.Now()
buildCtx, buildCancel := context.WithTimeout(context.Background(), cfg.BuildTimeout)
if err := runBuildCycle(buildCtx, db, cfg); err != nil {
slog.Error("Build cycle failed", "error", err)
} else {
slog.Info("Build cycle completed", "count", buildCount)
}
buildCancel()
metrics.IndexBuildDuration.Observe(time.Since(buildStart).Seconds())
// Deploy every N cycles
if buildCount%cfg.DeployInterval == 0 {
slog.Info("Deploy interval reached, deploying to Pages")
if err := deployToPages(cfg); err != nil {
slog.Error("Failed to deploy to Pages", "error", err)
} else {
slog.Info("Deployed to Cloudflare Pages")
}
}
// Sleep until next cycle
slog.Info("Sleeping until next build cycle", "duration", cfg.BuildInterval)
time.Sleep(cfg.BuildInterval)
}
}
// uploadMetaJSONToR2 uploads the generated static meta JSON files to R2 so
// they are available at the R2 CDN URL in addition to the Pages deploy.
func uploadMetaJSONToR2(ctx context.Context, cfg *Config, outputDir string, data *IndexData) error {
if cfg.R2AccessKey == "" || cfg.R2BucketName == "" {
return nil
}
static := []string{
"data/meta/index.json",
"data/meta/archetypes.json",
"data/meta/rivalries.json",
"data/evolution/community_hints.json",
"data/evolution/meta.json",
"data/evolution/lineage.json",
}
for _, rel := range static {
localPath := fmt.Sprintf("%s/%s", outputDir, rel)
if err := uploadFileToR2(ctx, cfg, localPath, rel); err != nil {
slog.Error("Failed to upload meta JSON to R2", "path", rel, "error", err)
}
}
// Upload per-match feedback files for matches that have annotations.
matchIDs := make(map[string]bool)
for _, f := range data.Feedback {
matchIDs[f.MatchID] = true
}
for matchID := range matchIDs {
rel := fmt.Sprintf("data/matches/%s/feedback.json", matchID)
localPath := fmt.Sprintf("%s/%s", outputDir, rel)
if err := uploadFileToR2(ctx, cfg, localPath, rel); err != nil {
slog.Error("Failed to upload match feedback to R2", "match_id", matchID, "error", err)
}
}
slog.Info("Uploaded meta JSON to R2",
"static_files", len(static),
"match_feedback_files", len(matchIDs),
)
return nil
}
// runBuildCycle executes one full index build cycle
func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
// Create data directories
dirs := []string{
cfg.OutputDir + "/data",
cfg.OutputDir + "/data/bots",
cfg.OutputDir + "/data/matches",
cfg.OutputDir + "/data/series",
cfg.OutputDir + "/data/seasons",
cfg.OutputDir + "/data/playlists",
cfg.OutputDir + "/data/predictions",
cfg.OutputDir + "/data/meta",
cfg.OutputDir + "/data/evolution",
cfg.OutputDir + "/data/blog",
cfg.OutputDir + "/data/commentary",
cfg.OutputDir + "/cards",
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create dir %s: %w", dir, err)
}
}
// Sync site build from registry (if configured) and copy into output directory.
// Falls back to baked-in assets when registry is unreachable.
webDistDir, siteBuildChanged := syncSiteBuild(ctx, cfg)
// When a new site build is detected, remove old SPA files to prevent
// stale hashed JS/CSS accumulation toward Pages' 20K file limit.
if siteBuildChanged {
slog.Info("Site build changed, cleaning stale web assets before merge")
if err := cleanStaleWebAssets(cfg); err != nil {
slog.Error("Failed to clean stale web assets", "error", err)
}
}
if _, err := os.Stat(webDistDir); err == nil {
if err := copyWebAssets(cfg, webDistDir); err != nil {
slog.Error("Failed to copy web assets", "error", err)
// Non-fatal - continue
}
}
// Fetch all data from PostgreSQL
data, err := fetchAllData(ctx, db)
if err != nil {
return fmt.Errorf("fetch data: %w", err)
}
// Generate all index files
if err := generateAllIndexes(data, cfg.OutputDir, db, cfg); err != nil {
return fmt.Errorf("generate indexes: %w", err)
}
// Upload new static meta JSON files to R2 warm cache
if err := uploadMetaJSONToR2(ctx, cfg, cfg.OutputDir, data); err != nil {
slog.Error("Failed to upload meta JSON to R2", "error", err)
// Non-fatal
}
// Generate blog posts (weekly meta reports and chronicles)
var llmClient *LLMClient
if cfg.LLMBaseURL != "" {
llmClient = NewLLMClient(cfg.LLMBaseURL, cfg.LLMAPIKey)
}
if err := generateBlog(data, cfg.OutputDir, llmClient, cfg); err != nil {
slog.Error("Failed to generate blog", "error", err)
// Non-fatal - continue with rest of build
}
// Generate bot profile cards (PNG images for social sharing)
if err := generateAllBotCards(data, cfg.OutputDir); err != nil {
slog.Error("Failed to generate bot cards", "error", err)
// Non-fatal - continue with rest of build
}
// Upload cards to B2 cold archive (primary storage)
if err := uploadCardsToB2(ctx, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to upload cards to B2", "error", err)
// Non-fatal
}
// Bundle warm-set assets (replays, thumbnails, cards, evolution) from B2 into Pages deploy
if err := bundleWarmAssetsForCycle(ctx, db, cfg, data); err != nil {
slog.Error("Failed to bundle warm assets", "error", err)
// Non-fatal
}
// Enrich featured replays with AI commentary (§13.3)
if err := enrichReplays(ctx, data, cfg, llmClient); err != nil {
slog.Error("Failed to enrich replays", "error", err)
// Non-fatal - unenriched replays are still valid
}
// Generate enriched commentary index (list of matches with AI commentary)
if err := generateEnrichedIndex(ctx, data, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to generate enriched index", "error", err)
// Non-fatal
}
return nil
}
// bundleWarmAssetsForCycle bundles warm-set assets from B2 into the Pages deploy directory.
// This includes replays, thumbnails, bot cards, and evolution live.json.
func bundleWarmAssetsForCycle(ctx context.Context, db *sql.DB, cfg *Config, data *IndexData) error {
// Get recent match IDs from the last 24 hours
recentMatchIDs, err := fetchRecentMatchIDs(ctx, db, 24*time.Hour)
if err != nil {
return fmt.Errorf("fetch recent match IDs: %w", err)
}
if len(recentMatchIDs) == 0 {
slog.Debug("No recent matches to bundle")
return nil
}
// Get exempt match IDs (playlists, series, seasons) - these are also part of warm set
exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db, cfg.OutputDir)
if err != nil {
slog.Warn("Failed to fetch exempt match IDs, bundling only recent", "error", err)
exemptMatchIDs = make(map[string]bool)
}
// Combine recent and exempt matches for bundling
matchIDsToBundle := recentMatchIDs
for matchID := range exemptMatchIDs {
matchIDsToBundle = append(matchIDsToBundle, matchID)
}
// Deduplicate match IDs
uniqueMatchIDs := make(map[string]bool)
for _, id := range matchIDsToBundle {
uniqueMatchIDs[id] = true
}
var dedupedMatchIDs []string
for id := range uniqueMatchIDs {
dedupedMatchIDs = append(dedupedMatchIDs, id)
}
if len(dedupedMatchIDs) == 0 {
slog.Debug("No matches to bundle")
return nil
}
slog.Info("Bundling warm assets from B2",
"recent_count", len(recentMatchIDs),
"exempt_count", len(exemptMatchIDs),
"total_unique", len(dedupedMatchIDs))
// Create B2 client for bundling
b2Client, err := getB2Client(cfg)
if err != nil {
return fmt.Errorf("create B2 client: %w", err)
}
// Bundle replays
if err := bundleWarmReplays(ctx, cfg, b2Client, dedupedMatchIDs); err != nil {
slog.Error("Failed to bundle warm replays", "error", err)
// Continue with other assets
}
// Bundle thumbnails
if err := bundleWarmThumbnails(ctx, cfg, b2Client, dedupedMatchIDs); err != nil {
slog.Error("Failed to bundle warm thumbnails", "error", err)
// Continue
}
// Bundle bot cards for all active bots
var botIDs []string
for _, bot := range data.Bots {
botIDs = append(botIDs, bot.ID)
}
if len(botIDs) > 0 {
if err := bundleWarmCards(ctx, cfg, b2Client, botIDs); err != nil {
slog.Error("Failed to bundle warm cards", "error", err)
// Continue
}
}
// Bundle evolution live.json
if err := bundleEvolutionLive(ctx, cfg, b2Client); err != nil {
slog.Error("Failed to bundle evolution live.json", "error", err)
// Continue
}
return nil
}
// fetchRecentMatchIDs retrieves match IDs from the last duration
func fetchRecentMatchIDs(ctx context.Context, db *sql.DB, since time.Duration) ([]string, error) {
query := `
SELECT match_id
FROM matches
WHERE status = 'completed'
AND completed_at > NOW() - $1::interval
ORDER BY completed_at DESC
`
intervalStr := fmt.Sprintf("%.0f seconds", since.Seconds())
rows, err := db.QueryContext(ctx, query, intervalStr)
if err != nil {
return nil, err
}
defer rows.Close()
var matchIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
matchIDs = append(matchIDs, id)
}
return matchIDs, nil
}