Launch options must be documented. A draft passed an option its server doesn't have; servers ignore unknown options, so it booted and passed every boot test while its setting did nothing. build now reads the pages research based the variant on, in full, and the drafter rejects any launch option in the startup command that none of them, nor the image's own scripts, mention. Shell tools' options (mkdir -p, [ -n ...]) are skipped; the check is off without sources. The builder is game-agnostic: - AUTHORING.md (the model's rules), the research prompt and the schema describe the rules without naming a game. - The draft prompt no longer includes real templates from the repo. Its format example is one invented server (no real command, options, ports or app id), checked by TestExampleTemplateFollowsTheRules. - Comments, help texts, BUILDER.md and test fixtures use invented names and values. - TestBuilderIsGameAgnostic reads every game's name and id from games/ and fails if any appears in the builder's code, tests, rules, schemas or docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
14 KiB
Go
398 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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/<id>/ 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"`
|
|
}
|
|
|
|
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", 3, "boots of the draft before giving up")
|
|
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
|
|
}
|
|
|
|
func build(o buildOptions) (*buildResult, error) {
|
|
if len(o.variant.Images) == 0 {
|
|
return nil, errors.New("the variant has no image to build from")
|
|
}
|
|
res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id)}
|
|
res.Verified = append(res.Verified, o.variant.Verified...)
|
|
|
|
// 1. First boot of each candidate image until one runs as a non-root user
|
|
var report *bootReport
|
|
for _, image := range o.variant.Images {
|
|
steam := isSteamCMDImage(image) && o.variant.SteamAppID != ""
|
|
if isSteamCMDImage(image) && !steam {
|
|
continue // the SteamCMD image alone runs nothing
|
|
}
|
|
res.Image = image
|
|
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)
|
|
report = nil
|
|
break
|
|
}
|
|
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 = append(res.Problems, why)
|
|
res.Image = ""
|
|
continue
|
|
}
|
|
report = r
|
|
res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome))
|
|
break
|
|
}
|
|
if res.Image == "" {
|
|
return res, errors.New("no image of this variant runs as a non-root user")
|
|
}
|
|
res.Problems = nil
|
|
|
|
// 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; failures go back to the model
|
|
wantOutside := len(outsideDirs(report)) > 0
|
|
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
|
|
}
|
|
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
|
|
}
|
|
problems, err := d.refine(o.attempts, bootProblems)
|
|
res.Rounds = d.rounds
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if len(problems) > 0 {
|
|
res.Problems = problems
|
|
break
|
|
}
|
|
}
|
|
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) {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
payloadCmd := exec.Command(self, "boottest-payload", "-games", gamesDir, id)
|
|
var stderr bytes.Buffer
|
|
payloadCmd.Stderr = &stderr
|
|
payload, err := payloadCmd.Output()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == exitMissingSecret {
|
|
// The builder has no CI secrets: that's the template's problem to fix, not a reason to stop
|
|
return []string{"the boot test can't run: " + strings.TrimSpace(stderr.String()) + ". Remove 'secrets' from " +
|
|
"test.yaml; boot tests fill password settings with a throwaway value"}, nil, nil
|
|
}
|
|
return nil, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil {
|
|
return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out)
|
|
}
|
|
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
|
|
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
|
|
"org.springframework.boot.loader.launch.PropertiesLauncher")
|
|
cmd.Stdin = bytes.NewReader(payload)
|
|
var output bytes.Buffer
|
|
cmd.Stdout = &teeWriter{&output}
|
|
cmd.Stderr = cmd.Stdout
|
|
runErr := cmd.Run()
|
|
if runErr != nil {
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(runErr, &exitErr) {
|
|
return nil, nil, runErr
|
|
}
|
|
return []string{bootFailure(output.String())}, nil, nil
|
|
}
|
|
verified = append(verified, "boot test with the node-agent: "+bootVerdict(output.String()))
|
|
|
|
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
|
|
}
|
|
|
|
func firstBootGame(gamesDir, id string) (*bootReport, error) {
|
|
outDir := filepath.Join("firstboot-out", id+"-draft")
|
|
if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-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 +
|
|
". 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 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()
|
|
}
|