package main import ( "bytes" "encoding/json" "errors" "flag" "fmt" "os" "path/filepath" "sort" "strings" ) // build turns one researched variant into a template that has really booted: first boot of the image // untouched (facts), a draft by the model, then the draft deployed with the node-agent's own code, exactly // as CI's boot test does. What goes wrong in that boot (a crash, an error line, saves outside the volume) // goes back to the model, a few rounds at most. The result is a games// folder ready for a pull request. type buildResult struct { GameID string `json:"gameId"` Dir string `json:"dir"` Image string `json:"image"` Passed bool `json:"passed"` Rounds int `json:"rounds"` Verified []string `json:"verified"` Problems []string `json:"problems"` Summary string `json:"summary"` // What the builder diagnosed after each failed boot, the fix it made, and how that went Diagnoses []attempt `json:"diagnoses,omitempty"` } const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest" func runBuild(args []string) error { fs := flag.NewFlagSet("build", flag.ExitOnError) gamesDir := fs.String("games", "games", "directory holding one folder per game") schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") gameName := fs.String("name", "", "the game's name") id := fs.String("id", "", "folder/id for the new game (default: from -name)") variantArg := fs.String("variant", "", "the chosen variant: a research result variant as JSON, or @file") notes := fs.String("notes", "", "what the admin asked for") provider := fs.String("provider", "ollama", "ollama or gemini") modelName := fs.String("model", "", "model name (default as for draft)") attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time") bootRounds := fs.Int("boot-rounds", 5, "boots of the draft at most (it stops earlier when a ruled-out cause comes back)") out := fs.String("out", "", "write the result JSON here") fs.Parse(args) if *gameName == "" || *variantArg == "" { return errors.New("build needs -name and -variant") } raw := []byte(*variantArg) if strings.HasPrefix(*variantArg, "@") { var err error if raw, err = os.ReadFile((*variantArg)[1:]); err != nil { return err } } var v variant if err := json.Unmarshal(raw, &v); err != nil { return fmt.Errorf("-variant: %w", err) } if *id == "" { *id = gameID(*gameName) } agentImage := os.Getenv("AGENT_IMAGE") if agentImage == "" { agentImage = defaultAgentImage } res, err := build(buildOptions{gamesDir: *gamesDir, schemaDir: *schemaDir, name: *gameName, id: *id, variant: v, notes: *notes, provider: *provider, model: *modelName, attempts: *attempts, bootRounds: *bootRounds, agentImage: agentImage}) if res != nil && *out != "" { raw, _ := json.MarshalIndent(res, "", " ") if werr := os.WriteFile(*out, raw, 0o644); werr != nil && err == nil { err = werr } } if err != nil { return err } if !res.Passed { return fmt.Errorf("the draft in %s still has problems:\n- %s", res.Dir, strings.Join(res.Problems, "\n- ")) } fmt.Printf("[build] %s booted with the node-agent and is ready for a pull request\n", res.Dir) return nil } type buildOptions struct { gamesDir, schemaDir, name, id string variant variant notes, provider, model string attempts, bootRounds int agentImage string } // build drafts the template for exactly the option the admin chose: one image (research lists each way to // run a game as its own option). It never switches to another image; if the chosen one can't be made to // work, the result says so and the admin can pick a different option. func build(o buildOptions) (*buildResult, error) { for _, image := range o.variant.Images { if isSteamCMDImage(image) && o.variant.SteamAppID == "" { continue // the SteamCMD image alone runs nothing } res, err := buildWithImage(o, image) if res != nil { res.Summary = buildSummary(o, res) } return res, err } return nil, errors.New("the variant has no image to build from") } // buildWithImage drafts the template for one image: first boot, draft, then boots of the draft with // failures going back to the model. func buildWithImage(o buildOptions, image string) (*buildResult, error) { res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id), Image: image} res.Verified = append(res.Verified, o.variant.Verified...) steam := isSteamCMDImage(image) // 1. First boot of the image untouched: its log, files and settings are the draft's facts var report *bootReport if steam && o.variant.StartupHint == "" { fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID) } else { fmt.Printf("[build] first boot of %s\n", image) r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint) if err != nil { return nil, err } if why := imageUnusable(r); why != "" { fmt.Printf("[build] %s can't be used: %s\n", image, why) res.Problems = []string{why} return res, nil } report = r res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome)) } // 2. Draft until validation and the image checks pass steamApp := "" if isSteamCMDImage(res.Image) { steamApp = o.variant.SteamAppID } d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: res.Image, SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true, Docs: sourceText(o.variant.Sources)}) if err != nil { return nil, err } problems, err := d.refine(o.attempts, nil) res.Rounds = d.rounds if err != nil { return res, err } if len(problems) > 0 { res.Problems = problems res.Summary = buildSummary(o, res) return res, nil } // 3. Boot the draft the way nodes run it. A failure is investigated and diagnosed before the model // changes anything; a fix that changes nothing rules its cause out wantOutside := len(outsideDirs(report)) > 0 iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage) lastFailure := "" for boot := 1; boot <= o.bootRounds; boot++ { fmt.Printf("[build] boot %d of the draft with the node-agent\n", boot) bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside) if err != nil { return res, err } failure := strings.Join(bootProblems, "\n") iv.outcome(len(bootProblems) == 0, lastFailure, failure) lastFailure = failure if len(bootProblems) == 0 { res.Passed = true res.Verified = append(res.Verified, notes...) break } fmt.Printf("[build] boot %d failed:\n", boot) for _, p := range bootProblems { fmt.Println(" - " + firstLine(p)) } res.Problems = bootProblems if boot == o.bootRounds { break } diag, err := iv.diagnose(failure) if err != nil { fmt.Printf("[build] diagnosis failed (%v); sending the failure as it is\n", err) diag = &diagnosis{Cause: "unknown", Fix: "find the cause in the server's output", Evidence: failure} } if iv.ruledOut(diag) { res.Problems = append(res.Problems, "the diagnosis came back to a cause an earlier fix ruled out ("+firstLine(diag.Cause)+ "); the builder stopped rather than guess. See what the builder found") break } iv.record(diag) problems, err := d.refine(o.attempts, fixRequest(failure, diag)) res.Rounds = d.rounds if err != nil { return res, err } if len(problems) > 0 { res.Problems = problems break } } res.Diagnoses = iv.log if res.Passed { res.Problems = nil } res.Summary = buildSummary(o, res) return res, nil } func isSteamCMDImage(image string) bool { return image == "cm2network/steamcmd" || strings.HasPrefix(image, "cm2network/steamcmd:") } func variantNotes(v variant, admin string) string { var parts []string if v.Title != "" { parts = append(parts, "variant: "+v.Title+" ("+v.Description+")") } if v.StartupHint != "" { parts = append(parts, "documented startup: "+v.StartupHint) } if len(v.Ports) > 0 { parts = append(parts, "documented ports: "+strings.Join(v.Ports, ", ")) } if v.Notes != "" { parts = append(parts, v.Notes) } if admin != "" { parts = append(parts, "the admin asked for: "+admin) } return strings.Join(parts, "; ") } // firstBoot runs first-boot and reads back its report. func firstBoot(name, image, steamApp string, steam bool, startup string) (*bootReport, error) { outDir := filepath.Join("firstboot-out", name) args := []string{"-image", image, "-out", outDir} if steam { args = append(args, "-steam-app", steamApp, "-startup", startup) } if err := runFirstBoot(args); err != nil { return nil, err } return readBootReport(filepath.Join(outDir, "report.json")) } // bootCheck deploys the template with the node-agent's BootTest, the same check CI runs, and when the // game wrote files outside its volume on the first boot, boots it once more to see that it no longer does. func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, verified []string, err error) { if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil { return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out) } output, passed, problems, err := agentBoot(gamesDir, id, agentImage, false) if err != nil || problems != nil { return problems, nil, err } if !passed { // The server's own output rarely says it stopped because a setting was empty. Booting once more // with the blank ones filled tells for certain whether they are the cause. if blank := blankRequiredLooking(gamesDir, id); len(blank) > 0 { fmt.Printf("[build] it failed with the defaults; booting again with %s filled to see whether empty settings are the cause\n", strings.Join(blank, ", ")) if _, filledPassed, _, err := agentBoot(gamesDir, id, agentImage, true); err == nil && filledPassed { return []string{emptySettingsProblem(blank, bootVerdict(output))}, nil, nil } } return []string{bootFailure(output)}, nil, nil } verified = append(verified, "boot test with the node-agent: "+bootVerdict(output)) if wantOutside { fmt.Println("[build] first boot wrote saves outside the volume; booting the draft to see that it no longer does") r, err := firstBootGame(gamesDir, id) if err != nil { return nil, nil, err } if dirs := outsideDirs(r); len(dirs) > 0 { return []string{fmt.Sprintf("booting your template, the server still wrote its own files outside the server volume, "+ "in %s, so your startup command or settings did not move them. Check the exact launch option name and "+ "syntax this server documents for its save/data folder (a wrong option is usually ignored silently) "+ "and point it at /server", strings.Join(dirs, ", "))}, nil, nil } verified = append(verified, "the server keeps its files inside /server") } return nil, verified, nil } // agentBoot runs one boot test with the node-agent's BootTest, with the settings a new server gets, like // CI. fillBlank also fills blank password-like settings, only to find out whether they are why a draft // fails. problems is set when the template can't be boot-tested at all. func agentBoot(gamesDir, id, agentImage string, fillBlank bool) (output string, passed bool, problems []string, err error) { payload, err := bootTestPayload(gamesDir, id, fillBlank) var missing *missingSecretError if errors.As(err, &missing) { // The builder has no CI secrets: that's the template's problem to fix, not a reason to stop return "", false, []string{"the boot test can't run: " + missing.Error() + ". Remove 'secrets' from " + "test.yaml; boot tests run with exactly what a new server gets"}, nil } if err != nil { return "", false, nil, err } output, passed, err = runAgentBootTest(payload, agentImage) return output, passed, nil, err } // blankRequiredLooking lists the settings a new server gets empty that look like a server might need a // value: password-like keys with an empty default and no x-generate. func blankRequiredLooking(gamesDir, id string) []string { g, err := loadMap(filepath.Join(gamesDir, id, "game.yaml")) if err != nil { return nil } defaults, _ := g["defaultConfig"].(map[string]any) var keys []string for key, p := range schemaProperties(g) { prop, _ := p.(map[string]any) if prop["x-generate"] != nil || !passwordKey.MatchString(key) { continue } if v, ok := defaults[key]; !ok || v == nil || v == "" { keys = append(keys, key) } } sort.Strings(keys) return keys } func emptySettingsProblem(keys []string, verdict string) string { return fmt.Sprintf("the server stops when %s is empty, as it is for every new server: booted with your defaults it "+ "failed (%s), and booted again with a value in %s it ran. A new server must start without the owner typing "+ "anything: give %s \"x-generate\": {\"type\": \"password\"} in configSchema (the panel then makes a password for "+ "each server; keep the default \"\"), or a default value that works. Keep the setting so owners can change it", strings.Join(keys, ", "), verdict, strings.Join(keys, ", "), strings.Join(keys, ", ")) } func firstBootGame(gamesDir, id string) (*bootReport, error) { outDir := filepath.Join("firstboot-out", id+"-draft") if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-as-deployed", "-out", outDir}); err != nil { return nil, err } return readBootReport(filepath.Join(outDir, "report.json")) } // bootFailure turns BootTest's output into feedback: its verdict and the server's last lines, which hold // the actual error (an unknown option, a missing file, a port in use). func bootFailure(output string) string { verdict, tail := bootVerdict(output), output if i := strings.Index(output, "---- last "); i >= 0 { tail = output[i:] } lines := strings.Split(strings.TrimSpace(tail), "\n") if len(lines) > 60 { lines = append(lines[:1], lines[len(lines)-59:]...) } return "booting your template with the node-agent (the real deploy code) failed: " + verdict + ". It was started with your defaults exactly as a new server gets them: settings left empty stay empty " + "(only x-generate fields are filled), so a server that needs a value must get it from a default or " + "x-generate. Find the cause in the server's output below and fix the template (startup command, settings, " + "ready pattern, memory):\n" + strings.Join(lines, "\n") } func bootVerdict(output string) string { for _, l := range strings.Split(output, "\n") { for _, k := range []string{"[boot-test] FAILED: ", "[boot-test] PASSED: "} { if i := strings.Index(l, k); i >= 0 { return strings.TrimSpace(l[i+len("[boot-test] "):]) } } } return "no verdict" } func firstLine(s string) string { line, _, _ := strings.Cut(s, "\n") return line } // teeWriter shows the boot as it happens (the worker streams it to the panel) while keeping it for the verdict. type teeWriter struct{ buf *bytes.Buffer } func (t *teeWriter) Write(p []byte) (int, error) { os.Stdout.Write(p) return t.buf.Write(p) } // buildSummary is the pull request's description: where the facts came from and what was really checked. func buildSummary(o buildOptions, res *buildResult) string { var b strings.Builder fmt.Fprintf(&b, "Drafted by the GBR AI builder. **Review before merging**: check the settings, "+ "ports and descriptions against the game's documentation.\n\n") fmt.Fprintf(&b, "- Game: **%s**", o.name) if o.variant.Title != "" { fmt.Fprintf(&b, " — %s", o.variant.Title) } fmt.Fprintf(&b, "\n- Image: `%s`", res.Image) if o.variant.SteamAppID != "" && isSteamCMDImage(res.Image) { fmt.Fprintf(&b, ", Steam app %s", o.variant.SteamAppID) } fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds) if len(res.Verified) > 0 { b.WriteString("\n**Checked for real**\n") for _, v := range res.Verified { fmt.Fprintf(&b, "- %s\n", v) } } if len(res.Problems) > 0 { b.WriteString("\n**Still open** (the builder could not fix these)\n") for _, p := range res.Problems { fmt.Fprintf(&b, "- %s\n", firstLine(p)) } } if len(res.Diagnoses) > 0 { b.WriteString("\n**What the builder found** (after each failed boot)\n") for i, a := range res.Diagnoses { fmt.Fprintf(&b, "%d. Cause: %s\n Fix: %s\n Outcome: %s\n", i+1, firstLine(a.Cause), firstLine(a.Fix), a.Outcome) } } if o.variant.Notes != "" { fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes) } if len(o.variant.Sources) > 0 { b.WriteString("\n**Sources**\n") for _, s := range o.variant.Sources { fmt.Fprintf(&b, "- %s\n", s) } } return b.String() } // sourceText reads the pages research based the variant on, in full, so the drafter can check that every // launch option the model uses is documented there. Empty (check skipped) without a reader. func sourceText(sources []string) string { reader := os.Getenv("READER_URL") if reader == "" || len(sources) == 0 { fmt.Println("[build] no research sources to check launch options against (READER_URL or sources missing)") return "" } var b strings.Builder read := 0 for _, s := range sources { text, err := readPageLimit(reader, s, 200_000) if err != nil { fmt.Printf("[build] could not read %s: %v\n", s, err) continue } read++ b.WriteString(text) b.WriteString("\n") } fmt.Printf("[build] read %d of %d research sources to check launch options against\n", read, len(sources)) return b.String() }