- Add data/meta/rivalries.json to R2 upload list in uploadMetaJSONToR2 - Add attachCommunityHints() to narrative.go to enrich story arcs with highest-upvote community tactical hints (upvotes >= 3, idea/mistake types) - Fix detectRivalryArcs() key separator from "-" to "|" to avoid UUID hyphen collisions when parsing bot ID pairs - Fix partitionBots() call sites in bot_strategies_phase13.go to use struct field access (.friendly, .enemy) matching updated return type generator.go already contains generateArchetypes, generateCommunityHints, and generateMatchFeedback (all called from generateAllIndexes). main.go uploads all four outputs to R2 on every build cycle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
9.9 KiB
Go
343 lines
9.9 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")
|
|
}
|
|
|
|
// Run R2 pruning once per week (Monday)
|
|
if time.Now().Weekday() == time.Monday {
|
|
slog.Info("Running weekly R2 pruning")
|
|
if err := pruneR2Cache(context.Background(), cfg); err != nil {
|
|
slog.Error("R2 pruning failed", "error", err)
|
|
} else {
|
|
slog.Info("R2 pruning completed")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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/archetypes.json",
|
|
"data/meta/rivalries.json",
|
|
"data/evolution/community_hints.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 R2 warm cache
|
|
if err := uploadCardsToR2(ctx, cfg, cfg.OutputDir); err != nil {
|
|
slog.Error("Failed to upload cards to R2", "error", err)
|
|
// Non-fatal
|
|
}
|
|
|
|
// Upload cards to B2 cold archive
|
|
if err := uploadCardsToB2(ctx, cfg, cfg.OutputDir); err != nil {
|
|
slog.Error("Failed to upload cards to B2", "error", err)
|
|
// Non-fatal
|
|
}
|
|
|
|
// Promote recent replays from B2 to R2 warm cache
|
|
if err := promoteRecentReplaysForCycle(ctx, db, cfg); err != nil {
|
|
slog.Error("Failed to promote recent replays", "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
|
|
}
|
|
|
|
// promoteRecentReplaysForCycle promotes recent replays from B2 to R2
|
|
func promoteRecentReplaysForCycle(ctx context.Context, db *sql.DB, cfg *Config) 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 promote")
|
|
return nil
|
|
}
|
|
|
|
// Get exempt match IDs (playlists, series, seasons)
|
|
exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db, cfg.OutputDir)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch exempt match IDs, promoting all", "error", err)
|
|
exemptMatchIDs = make(map[string]bool)
|
|
}
|
|
|
|
// Combine recent and exempt matches for promotion
|
|
matchIDsToPromote := recentMatchIDs
|
|
for matchID := range exemptMatchIDs {
|
|
matchIDsToPromote = append(matchIDsToPromote, matchID)
|
|
}
|
|
|
|
if len(matchIDsToPromote) == 0 {
|
|
slog.Debug("No matches to promote")
|
|
return nil
|
|
}
|
|
|
|
slog.Info("Promoting replays to R2",
|
|
"recent_count", len(recentMatchIDs),
|
|
"exempt_count", len(exemptMatchIDs),
|
|
"total", len(matchIDsToPromote))
|
|
|
|
return promoteRecentReplays(ctx, cfg, matchIDsToPromote)
|
|
}
|
|
|
|
// 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
|
|
}
|