From f1271341ef681eef00a0370df3b542cfe1f6928b Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 26 Oct 2026 00:11:35 -0400 Subject: [PATCH] feat(builder): repair existing templates, a weekly check that queues repairs, and eval The builder could only create games; nothing noticed when a live template stopped working, and there was no way to tell whether a builder change made it better or worse. - repair -id ID: boots an existing template as nodes run it; if it fails, the same investigation as for a draft runs and the model changes only what the diagnosis says. The worker handles "repair" jobs (gameId from the panel) and opens "fix(games): repair ..." on draft/; a template that boots as it is reports "nothing to repair" and opens no PR. The boot and fix loop is now shared by builds and repairs. - validate.yml runs weekly and boot-tests every game; failed games are sent to the panel (X-GBR-Event: games_boot_failed) to queue repairs. - eval -ids all: builds games that already have a template from scratch, blind, and scores each draft against it (boots, same image and app, ports, ready line, settings found) into eval.md and eval.json. - research skips our own sites, so neither research nor eval reads our templates back. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/validate.yml | 22 ++++ BUILDER.md | 17 +++ cmd/gbrgames/build.go | 28 +++-- cmd/gbrgames/build_test.go | 29 +++++ cmd/gbrgames/draft.go | 6 +- cmd/gbrgames/eval.go | 225 ++++++++++++++++++++++++++++++++++ cmd/gbrgames/main.go | 10 +- cmd/gbrgames/repair.go | 140 +++++++++++++++++++++ cmd/gbrgames/research.go | 6 +- cmd/gbrgames/research_test.go | 11 ++ cmd/gbrgames/worker.go | 59 +++++++-- scripts/boot-test.sh | 2 + 12 files changed, 535 insertions(+), 20 deletions(-) create mode 100644 cmd/gbrgames/eval.go create mode 100644 cmd/gbrgames/repair.go diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 14c15ea..953f3ae 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -5,6 +5,10 @@ name: Validate game templates # still checked through their push. on: push: + # Weekly: boot-test every game, so a template that stopped working (a game update, a changed image) + # is found before players are, and a repair is queued for the AI builder + schedule: + - cron: "0 6 * * 1" workflow_dispatch: inputs: games: @@ -46,6 +50,8 @@ jobs: run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then games=$(scripts/changed-games.sh "${{ inputs.games }}") + elif [ "${{ github.event_name }}" = "schedule" ]; then + games=$(scripts/changed-games.sh all) elif [ "${{ github.ref }}" = "refs/heads/main" ]; then before="${{ github.event.before }}" if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then @@ -78,10 +84,26 @@ jobs: # Secrets named in a game's test.yaml must be listed here to reach the boot test - name: Boot test changed games + id: boottest env: DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }} run: scripts/boot-test.sh ${{ needs.validate.outputs.games }} + # The weekly run: each game that no longer boots gets a repair job in the panel; the AI builder takes + # it, and opens a pull request with the fix + - name: Queue repairs for games that stopped booting + if: failure() && github.event_name == 'schedule' && steps.boottest.outputs.failed != '' + env: + PANEL_WEBHOOK_SECRET: ${{ secrets.PANEL_WEBHOOK_SECRET }} + run: | + games=$(printf '"%s",' ${{ steps.boottest.outputs.failed }}) + curl -sS --fail-with-body --max-time 30 --retry 3 --retry-delay 10 \ + -X POST "${{ env.PANEL_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Token: $PANEL_WEBHOOK_SECRET" \ + -H "X-GBR-Event: games_boot_failed" \ + -d "{\"games\":[${games%,}],\"run\":\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" + # main is the source of truth: send every template to the panel, which saves the changed ones # (new games arrive disabled). Runs only if the changed games booted, or none changed. publish: diff --git a/BUILDER.md b/BUILDER.md index 66295e0..507f996 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -42,6 +42,23 @@ example is an invented server). `TestBuilderIsGameAgnostic` fails the build if o The worker only connects out (to the panel, Gitea, the model and the search services). +## Keeping games working + +- **Repair** (`gbrgames repair -id ID`): boots an existing template as nodes run it. If it boots, there is + nothing to do. If not, the same investigation as for a draft runs, the model changes only what the + diagnosis says, and the worker opens `fix(games): repair …` on `draft/` (replacing any open draft of + that game). The panel queues repairs from a game's admin page, or by itself (next point). +- **Weekly check**: `validate.yml` boot-tests every game each Monday. Each game that no longer boots is sent + to the panel (`X-GBR-Event: games_boot_failed`), which queues a repair job for it. +- **Eval** (`gbrgames eval -ids all`): builds games that already have a template from scratch, blind (the + builder never reads `games/`, and research skips our own sites), and scores each draft against the merged + template: boots, same image and Steam app, same ports, ready line, settings found. Run it before and after + changing the builder or the model. It takes as long as the builds; on the builder VM: + + ```bash + docker exec gbr-builder sh -c 'cd /work && rm -rf eval && git clone -q https://www.gitea.gbreed.com/gbreed-public/gbr-games.git eval && cd eval && gbrgames eval -ids all -out eval.md && cat eval.md' + ``` + ## Requirements A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index f452313..4dd371e 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -28,6 +28,9 @@ type buildResult struct { 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"` + // Repairs only: the existing template, and whether it already booted as it is (nothing to change) + Repair bool `json:"repair,omitempty"` + NoChange bool `json:"noChange,omitempty"` } const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest" @@ -159,16 +162,26 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { 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 + // 3. Boot the draft the way nodes run it + if err := bootAndFix(o, d, res, len(outsideDirs(report)) > 0); err != nil { + return res, err + } + res.Summary = buildSummary(o, res) + return res, nil +} + +// bootAndFix boots the template in games/ the way nodes run it, until it passes. A failure is +// investigated and diagnosed before the model changes anything, and a fix that changes nothing rules its +// cause out. Builds use it on a fresh draft; repairs on an existing template, where a first boot that +// passes means there is nothing to repair. wantOutside also checks no files are lost on a restart. +func bootAndFix(o buildOptions, d *drafter, res *buildResult, wantOutside bool) error { 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) + fmt.Printf("[build] boot %d of the template with the node-agent\n", boot) bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside) if err != nil { - return res, err + return err } failure := strings.Join(bootProblems, "\n") iv.outcome(len(bootProblems) == 0, lastFailure, failure) @@ -200,7 +213,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { problems, err := d.refine(o.attempts, fixRequest(failure, diag)) res.Rounds = d.rounds if err != nil { - return res, err + return err } if len(problems) > 0 { res.Problems = problems @@ -211,8 +224,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { if res.Passed { res.Problems = nil } - res.Summary = buildSummary(o, res) - return res, nil + return nil } func isSteamCMDImage(image string) bool { diff --git a/cmd/gbrgames/build_test.go b/cmd/gbrgames/build_test.go index 24b1c9e..ca8b85c 100644 --- a/cmd/gbrgames/build_test.go +++ b/cmd/gbrgames/build_test.go @@ -79,3 +79,32 @@ configSchema: t.Errorf("message: %s", msg) } } + +func TestScoreDraftAgainstTheRealTemplate(t *testing.T) { + real := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD", + "installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30001/udp"}, + "configSchema": map[string]any{"properties": map[string]any{"ServerName": map[string]any{}, "MaxPlayers": map[string]any{}}}} + draft := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD", + "installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30002/tcp"}, + "readyPattern": `Server is ready`, + "configSchema": map[string]any{"properties": map[string]any{"SERVER_NAME": map[string]any{}, "WorldName": map[string]any{}}}} + var s evalScore + scoreDraft(&s, real, map[string]any{"readyExample": "12:00:01 Server is ready for players"}, draft) + if !s.SameRoute || s.PortsSame || !s.ReadyMatch || s.SettingsHit != 1 || s.RealSetting != 2 || s.Settings != 2 { + t.Errorf("score %+v", s) + } + if strings.Join(s.PortsDiff, ",") != "extra 30002/tcp,missing 30001/udp" { + t.Errorf("ports diff %v", s.PortsDiff) + } +} + +func TestPRTextForBuildsAndRepairs(t *testing.T) { + _, title := prText("Example", "job1", &buildResult{Passed: true}) + if title != "feat(games): add Example" { + t.Errorf("build title %q", title) + } + commit, title := prText("Example", "job1", &buildResult{Repair: true}) + if title != "WIP: fix(games): repair Example (did not boot yet)" || !strings.HasPrefix(commit, "fix(games): repair Example") { + t.Errorf("repair %q / %q", title, commit) + } +} diff --git a/cmd/gbrgames/draft.go b/cmd/gbrgames/draft.go index 4ddc84b..c09595a 100644 --- a/cmd/gbrgames/draft.go +++ b/cmd/gbrgames/draft.go @@ -84,6 +84,8 @@ type draftJob struct { // Text of the pages research based the variant on; when set, every launch option in the startup // command must appear in it (or in the image's scripts) Docs string + // Repair: games/ is an existing template to fix, not a new game + Repair bool } // drafter keeps what the model needs about one game across rounds. Each request is rebuilt from it @@ -109,8 +111,8 @@ func newDrafter(j draftJob) (*drafter, error) { id = gameID(j.Name) } d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)} - if _, err := os.Stat(d.dir); err == nil { - return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir) + if _, err := os.Stat(d.dir); err == nil && !j.Repair { + return nil, fmt.Errorf("%s already exists; drafts only create new games (repair fixes existing ones)", d.dir) } var err error if d.m, err = newModel(j.Provider, j.Model); err != nil { diff --git a/cmd/gbrgames/eval.go b/cmd/gbrgames/eval.go new file mode 100644 index 0000000..8118677 --- /dev/null +++ b/cmd/gbrgames/eval.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// eval measures the builder: it builds games that already have a working template from scratch (research, +// first boot, draft, boots), without opening a pull request and without the builder seeing that template, +// and scores each draft against the merged one. Run it before and after changing the builder or the model +// to see whether the change helped. + +type evalScore struct { + Game string `json:"game"` + Boots bool `json:"boots"` + SameRoute bool `json:"sameRoute"` // same image, and same Steam app when installed with SteamCMD + PortsSame bool `json:"portsSame"` + PortsDiff []string `json:"portsDiff,omitempty"` + ReadyMatch bool `json:"readyMatch"` // the draft's readyPattern matches the real server's ready line + Settings int `json:"settings"` + SettingsHit int `json:"settingsHit"` // of the real template's settings, how many the draft has too + RealSetting int `json:"realSettings"` + Rounds int `json:"rounds"` + Minutes float64 `json:"minutes"` + Error string `json:"error,omitempty"` +} + +func runEval(args []string) error { + fs := flag.NewFlagSet("eval", flag.ExitOnError) + gamesDir := fs.String("games", "games", "the repo's games directory: the templates to compare against") + schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") + which := fs.String("ids", "all", "comma-separated game ids, or all") + provider := fs.String("provider", "ollama", "ollama or gemini") + modelName := fs.String("model", "", "model name (default as for draft)") + out := fs.String("out", "eval.md", "write the report here (and the scores as JSON next to it)") + fs.Parse(args) + + ids, err := evalIDs(*gamesDir, *which) + if err != nil { + return err + } + m, err := newModel(*provider, *modelName) + if err != nil { + return err + } + var scores []evalScore + for _, id := range ids { + fmt.Printf("[eval] ===== %s =====\n", id) + s := evalGame(m, *gamesDir, *schemaDir, id, *provider, *modelName) + fmt.Printf("[eval] %s: boots %v, same route %v, ports same %v, ready matches %v, settings %d/%d\n", + id, s.Boots, s.SameRoute, s.PortsSame, s.ReadyMatch, s.SettingsHit, s.RealSetting) + scores = append(scores, s) + } + if err := os.WriteFile(*out, []byte(evalReport(scores)), 0o644); err != nil { + return err + } + raw, _ := json.MarshalIndent(scores, "", " ") + return os.WriteFile(strings.TrimSuffix(*out, filepath.Ext(*out))+".json", raw, 0o644) +} + +func evalIDs(gamesDir, which string) ([]string, error) { + if which != "all" { + return strings.Split(which, ","), nil + } + entries, err := os.ReadDir(gamesDir) + if err != nil { + return nil, err + } + var ids []string + for _, e := range entries { + if _, err := os.Stat(filepath.Join(gamesDir, e.Name(), "game.yaml")); err == nil { + ids = append(ids, e.Name()) + } + } + if len(ids) == 0 { + return nil, errors.New("no templates to evaluate against") + } + return ids, nil +} + +// evalGame builds one game blind, in an empty games folder, and compares the draft with the real template. +func evalGame(m model, gamesDir, schemaDir, id, provider, modelName string) evalScore { + s := evalScore{Game: id} + start := time.Now() + defer func() { s.Minutes = time.Since(start).Minutes() }() + real, err := loadMap(filepath.Join(gamesDir, id, "game.yaml")) + if err != nil { + s.Error = err.Error() + return s + } + realTest, _ := loadMap(filepath.Join(gamesDir, id, "test.yaml")) + name, _ := real["name"].(string) + + res, err := research(m, name, "", 6) + if err != nil { + s.Error = "research: " + err.Error() + return s + } + var chosen *variant + for i := range res.Variants { + if res.Variants[i].Recommended { + chosen = &res.Variants[i] + } + } + if chosen == nil { + s.Error = "research recommended no runnable option: " + res.Message + return s + } + blind, err := os.MkdirTemp("", "gbr-eval-") + if err != nil { + s.Error = err.Error() + return s + } + defer os.RemoveAll(blind) + built, err := build(buildOptions{gamesDir: blind, schemaDir: schemaDir, name: name, id: id, variant: *chosen, + provider: provider, model: modelName, attempts: 3, bootRounds: 5, agentImage: agentImageFromEnv()}) + if built != nil { + s.Boots, s.Rounds = built.Passed, built.Rounds + } + if err != nil { + s.Error = "build: " + err.Error() + } + draft, derr := loadMap(filepath.Join(blind, id, "game.yaml")) + if derr != nil { + if s.Error == "" { + s.Error = "no draft written" + } + return s + } + scoreDraft(&s, real, realTest, draft) + return s +} + +// scoreDraft compares a draft with the real template. +func scoreDraft(s *evalScore, real, realTest, draft map[string]any) { + s.SameRoute = real["defaultDockerImage"] == draft["defaultDockerImage"] && steamAppOf(real) == steamAppOf(draft) + + want, got := portSet(real), portSet(draft) + for p := range want { + if !got[p] { + s.PortsDiff = append(s.PortsDiff, "missing "+p) + } + } + for p := range got { + if !want[p] { + s.PortsDiff = append(s.PortsDiff, "extra "+p) + } + } + sort.Strings(s.PortsDiff) + s.PortsSame = len(s.PortsDiff) == 0 + + if pattern, _ := draft["readyPattern"].(string); pattern != "" { + example, _ := realTest["readyExample"].(string) + if re, err := regexp.Compile(pattern); err == nil && example != "" { + s.ReadyMatch = re.MatchString(example) + } + } + + realKeys := map[string]bool{} + for k := range schemaProperties(real) { + realKeys[envName(k)] = true + } + s.RealSetting = len(realKeys) + for k := range schemaProperties(draft) { + s.Settings++ + if realKeys[envName(k)] { + s.SettingsHit++ + } + } +} + +func steamAppOf(g map[string]any) string { + if g["installMethod"] != "STEAMCMD" { + return "" + } + ic, _ := g["installConfig"].(map[string]any) + return fmt.Sprint(ic["appId"]) +} + +// portSet is a template's published container ports, as "port/proto". +func portSet(g map[string]any) map[string]bool { + out := map[string]bool{} + ports, _ := g["defaultPorts"].([]any) + for _, p := range ports { + out[containerPort(fmt.Sprint(p))] = true + } + return out +} + +func evalReport(scores []evalScore) string { + var b strings.Builder + b.WriteString("# Builder evaluation\n\nEach game was built blind (research, first boot, draft, boots) and compared " + + "with its merged template.\n\n") + b.WriteString("| Game | Boots | Same image/app | Ports | Ready line | Settings found | Rounds | Minutes | Note |\n") + b.WriteString("| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n") + passed := 0 + for _, s := range scores { + if s.Boots { + passed++ + } + ports := "same" + if !s.PortsSame { + ports = strings.Join(s.PortsDiff, ", ") + } + fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %d of %d (draft has %d) | %d | %.0f | %s |\n", s.Game, mark(s.Boots), mark(s.SameRoute), + ports, mark(s.ReadyMatch), s.SettingsHit, s.RealSetting, s.Settings, s.Rounds, s.Minutes, s.Error) + } + fmt.Fprintf(&b, "\n%d of %d drafts booted.\n", passed, len(scores)) + return b.String() +} + +func mark(ok bool) string { + if ok { + return "yes" + } + return "no" +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 8c37d0f..9b95e90 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -9,7 +9,9 @@ // go run ./cmd/gbrgames draft -name N -image I # have an AI model write a new game's template from those facts // go run ./cmd/gbrgames research -name N # find how a game's server runs: variants, images, Steam app // go run ./cmd/gbrgames build -name N -variant V # first boot, draft, and boot the draft until it works -// go run ./cmd/gbrgames worker # take research/build jobs from the panel (see BUILDER.md) +// go run ./cmd/gbrgames repair -id ID # fix an existing template that no longer boots +// go run ./cmd/gbrgames eval -ids all # build known games blind and score the drafts +// go run ./cmd/gbrgames worker # take research/build/repair jobs from the panel (see BUILDER.md) package main import ( @@ -41,6 +43,10 @@ func main() { err = runResearch(os.Args[2:]) case "build": err = runBuild(os.Args[2:]) + case "repair": + err = runRepair(os.Args[2:]) + case "eval": + err = runEval(os.Args[2:]) case "worker": err = runWorker(os.Args[2:]) default: @@ -53,6 +59,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | worker [-once]") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | repair -id ID [-reason R] | eval [-ids all] [-out eval.md] | worker [-once]") os.Exit(2) } diff --git a/cmd/gbrgames/repair.go b/cmd/gbrgames/repair.go new file mode 100644 index 0000000..88cef56 --- /dev/null +++ b/cmd/gbrgames/repair.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// repair fixes an existing template that stopped working (a game update, a changed image), the way build +// fixes a fresh draft: the template is booted as nodes run it, a failure is investigated and diagnosed, +// and the model changes only what the diagnosis says. A template that boots as it is needs no repair. +// The result replaces games// in a pull request. + +func runRepair(args []string) error { + fs := flag.NewFlagSet("repair", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") + id := fs.String("id", "", "the game to repair (its folder under -games)") + reason := fs.String("reason", "", "why it is being repaired, e.g. the failing boot test") + 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 template at most") + out := fs.String("out", "", "write the result JSON here") + fs.Parse(args) + if *id == "" { + return errors.New("repair needs -id") + } + res, err := repair(buildOptions{gamesDir: *gamesDir, schemaDir: *schemaDir, id: *id, notes: *reason, + provider: *provider, model: *modelName, attempts: *attempts, bootRounds: *bootRounds, agentImage: agentImageFromEnv()}) + 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 + } + switch { + case res.NoChange: + fmt.Printf("[repair] %s boots as it is: nothing to repair\n", res.Dir) + case res.Passed: + fmt.Printf("[repair] %s repaired and booted with the node-agent\n", res.Dir) + default: + return fmt.Errorf("%s still has problems:\n- %s", res.Dir, strings.Join(res.Problems, "\n- ")) + } + return nil +} + +func repair(o buildOptions) (*buildResult, error) { + dir := filepath.Join(o.gamesDir, o.id) + g, err := loadMap(filepath.Join(dir, "game.yaml")) + if err != nil { + return nil, err + } + test, _ := loadMap(filepath.Join(dir, "test.yaml")) + if test == nil { + test = map[string]any{} + } + o.name, _ = g["name"].(string) + image, _ := g["defaultDockerImage"].(string) + steamApp := "" + if ic, ok := g["installConfig"].(map[string]any); ok && g["installMethod"] == "STEAMCMD" { + steamApp = fmt.Sprint(ic["appId"]) + } + res := &buildResult{GameID: o.id, Dir: dir, Image: image, Repair: true} + if out, err := runCmd("docker", "pull", "-q", image); err != nil { + return res, fmt.Errorf("docker pull %s: %v: %s", image, err, out) + } + + // The model starts from the template as it is, as if it had written it, with the facts read from the + // image; it only ever gets asked to fix what a diagnosis found + notes := "this game already has a template (your previous answer) that a node can no longer run. Repair it: " + + "change only what each diagnosis says, and keep everything that works" + if o.notes != "" { + notes += ". Why it is being repaired: " + o.notes + } + d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: image, + SteamApp: steamApp, Notes: notes, Provider: o.provider, Model: o.model, NoPull: true, Repair: true}) + if err != nil { + return res, err + } + current, err := json.Marshal(map[string]any{"game": g, "test": test}) + if err != nil { + return res, err + } + d.last = string(current) + + if err := bootAndFix(o, d, res, true); err != nil { + return res, err + } + res.NoChange = res.Passed && d.rounds == 0 + if res.NoChange { + res.Verified = append(res.Verified, "the template boots as it is: nothing to repair") + } + res.Summary = repairSummary(o, res) + return res, nil +} + +// repairSummary is the pull request's description for a repair. +func repairSummary(o buildOptions, res *buildResult) string { + var b strings.Builder + fmt.Fprintf(&b, "Repaired by the GBR AI builder. **Review before merging**: the diff shows exactly what changed.\n\n") + fmt.Fprintf(&b, "- Game: **%s** (`games/%s`)\n- Image: `%s`\n- Model rounds: %d\n", o.name, o.id, res.Image, res.Rounds) + if o.notes != "" { + fmt.Fprintf(&b, "- Why: %s\n", o.notes) + } + if len(res.Verified) > 0 { + b.WriteString("\n**Checked for real** (measured by the builder, not the model's word)\n") + for _, v := range res.Verified { + fmt.Fprintf(&b, "- %s\n", v) + } + } + if g, err := loadMap(filepath.Join(res.Dir, "game.yaml")); err == nil { + if urls := downloadURLs(g); len(urls) > 0 { + b.WriteString("\n**Review carefully: nodes download from these addresses**\n") + for _, u := range urls { + fmt.Fprintf(&b, "- %s\n", u) + } + } + } + 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) + } + } + return b.String() +} diff --git a/cmd/gbrgames/research.go b/cmd/gbrgames/research.go index 2d7a429..19b9821 100644 --- a/cmd/gbrgames/research.go +++ b/cmd/gbrgames/research.go @@ -237,11 +237,15 @@ func pickPages(hits []searchHit, n int) []searchHit { return out } +// Our own sites (the public templates, the panel) are never a source: research would read our templates +// back, and an eval build would copy the answer it's being scored against +var ownSites = regexp.MustCompile(`(?i)^https?://([a-z0-9-]+\.)*(gbreed\.com|gbrhosting\.com)(/|:|$)`) + func dedupeHits(hits []searchHit) []searchHit { seen := map[string]bool{} var out []searchHit for _, h := range hits { - if h.URL == "" || seen[h.URL] { + if h.URL == "" || seen[h.URL] || ownSites.MatchString(h.URL) { continue } seen[h.URL] = true diff --git a/cmd/gbrgames/research_test.go b/cmd/gbrgames/research_test.go index c79ceb2..c76d54e 100644 --- a/cmd/gbrgames/research_test.go +++ b/cmd/gbrgames/research_test.go @@ -112,3 +112,14 @@ func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) { } } } + +func TestResearchSkipsOurOwnSites(t *testing.T) { + got := dedupeHits([]searchHit{ + {URL: "https://www.gitea.gbreed.com/gbreed-public/gbr-games/src/branch/main/games/x/game.yaml"}, + {URL: "https://gbrhosting.com/games"}, + {URL: "https://example.com/server-guide"}, + }) + if len(got) != 1 || got[0].URL != "https://example.com/server-guide" { + t.Errorf("got %v", got) + } +} diff --git a/cmd/gbrgames/worker.go b/cmd/gbrgames/worker.go index 5b3a2c2..0dd507b 100644 --- a/cmd/gbrgames/worker.go +++ b/cmd/gbrgames/worker.go @@ -32,7 +32,8 @@ import ( type builderJob struct { ID string `json:"id"` - Kind string `json:"kind"` // "research" or "build" + Kind string `json:"kind"` // "research", "build" or "repair" + GameID string `json:"gameId"` // repair: the template to fix GameName string `json:"gameName"` Notes string `json:"notes"` Variant json.RawMessage `json:"variant"` @@ -43,6 +44,8 @@ type jobResult struct { Build *buildResult `json:"build,omitempty"` PRURL string `json:"prUrl,omitempty"` Error string `json:"error,omitempty"` + // A repair whose template already boots: no pull request + NoChange bool `json:"noChange,omitempty"` } type workerConfig struct { @@ -258,6 +261,30 @@ func (c *workerConfig) handle(job *builderJob) *jobResult { return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()} } return &jobResult{Build: res, PRURL: pr} + + case "repair": + if job.GameID == "" { + return &jobResult{Error: "a repair job needs gameId"} + } + args := append([]string{"repair", "-id", job.GameID, "-reason", job.Notes, "-out", resultFile}, modelArgs...) + runErr := c.runStreamed(job.ID, dir, self, args, "BOOT_TEST") + res := &buildResult{} + if err := readJSON(resultFile, res); err != nil { + msg := "the repair produced no result" + if runErr != nil { + msg = runErr.Error() + } + return &jobResult{Error: msg} + } + if res.NoChange { + return &jobResult{Build: res, NoChange: true} + } + c.progress(job.ID, "OPENING_PR", []string{"[worker] opening the pull request"}) + pr, err := c.openPR(dir, job, res) + if err != nil { + return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()} + } + return &jobResult{Build: res, PRURL: pr} } return &jobResult{Error: "unknown job kind " + job.Kind} } @@ -369,14 +396,22 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st summary += fmt.Sprintf("\nReplaces #%d (closed, branch deleted).\n", n) } auth := "http.extraHeader=Authorization: token " + c.botToken + commit, title := prText(job.GameName, job.ID, res) + if res.Repair { + // A repair that changed nothing has nothing to propose + diff := exec.Command("git", "status", "--porcelain", "--", filepath.ToSlash(res.Dir)) + diff.Dir = dir + if out, _ := diff.Output(); len(strings.TrimSpace(string(out))) == 0 { + return "", errors.New("the repair changed nothing in the template: " + strings.Join(res.Problems, "; ")) + } + } steps := []struct { name string args []string }{ {"checkout", []string{"checkout", "-q", "-b", branch}}, {"add", []string{"add", filepath.ToSlash(res.Dir)}}, - {"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", - fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}}, + {"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", commit}}, // Forced: the branch is the bot's own, and whatever was on it was the draft being replaced {"push", []string{"-c", auth, "push", "-q", "--force", "origin", branch}}, } @@ -388,10 +423,6 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***")) } } - title := "feat(games): add " + job.GameName - if !res.Passed { - title = "WIP: " + title + " (did not boot yet)" - } var pr struct { HTMLURL string `json:"html_url"` } @@ -489,3 +520,17 @@ func readJSON(p string, out any) error { } return json.Unmarshal(raw, out) } + +// prText is the commit message and pull request title for a new game or a repair. +func prText(gameName, jobID string, res *buildResult) (commit, title string) { + commit = fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", gameName, jobID) + title = "feat(games): add " + gameName + if res.Repair { + commit = fmt.Sprintf("fix(games): repair %s\n\nRepaired and boot-tested by the GBR builder (job %s).", gameName, jobID) + title = "fix(games): repair " + gameName + } + if !res.Passed { + title = "WIP: " + title + " (did not boot yet)" + } + return commit, title +} diff --git a/scripts/boot-test.sh b/scripts/boot-test.sh index a77ed01..6e12708 100755 --- a/scripts/boot-test.sh +++ b/scripts/boot-test.sh @@ -54,4 +54,6 @@ done echo "passed: ${passed[*]:-none}" echo "skipped: ${skipped[*]:-none} (missing secrets)" echo "failed: ${failed[*]:-none}" +# For the workflow: the weekly run queues a repair for each failed game +[ -n "${GITHUB_OUTPUT:-}" ] && echo "failed=${failed[*]}" >> "$GITHUB_OUTPUT" [ ${#failed[@]} -eq 0 ]