feat(builder): research, build and a worker for the AI builder
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

- research: searches SearXNG, reads the best pages through the reader, has the
  model list the server's variants, then checks every Docker image and Steam
  app it named for real before an admin sees it.
- build: first boot of the variant's images (skipping ones that need root),
  a draft by the model, then the draft deployed with the node-agent's
  BootTest like CI does; crashes, error lines and saves outside /server go
  back to the model, three boots at most.
- worker: polls the panel's builder queue (outbound only), runs research or
  build in a fresh clone, streams the output as the job log, and opens the
  result as a pull request from the bot account.
- Dockerfile.builder, a workflow publishing gbr-builder:latest, and
  BUILDER.md. Everything about where it runs comes from environment
  variables; deployment files live outside this public repo.

draft now keeps its conversation in a drafter, so build can send boot
failures back to the same model session. The boot-test runner comment no
longer names its VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 12:51:29 -04:00
1 parent 05bfc9b061
commit c75190d8c4
12 files changed
+1495 -95

No files matched your search

+157 -92
View File
@@ -16,6 +16,8 @@ import (
"sort"
"strings"
"time"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// draft writes a first template for a new game with an AI model: it gives the model AUTHORING.md as its
@@ -52,102 +54,165 @@ func runDraft(args []string) error {
if *gameName == "" || *image == "" {
return errors.New("draft needs -name and -image")
}
if *id == "" {
*id = strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(*gameName), "-"), "-")
var report *bootReport
if *reportPath != "" {
var err error
if report, err = readBootReport(*reportPath); err != nil {
return err
}
}
dir := filepath.Join(*gamesDir, *id)
if _, err := os.Stat(dir); err == nil {
return fmt.Errorf("%s already exists; drafts only create new games", dir)
}
m, err := newModel(*provider, *modelName)
d, err := newDrafter(draftJob{GamesDir: *gamesDir, SchemaDir: *schemaDir, Name: *gameName, ID: *id, Image: *image,
SteamApp: *steamApp, Notes: *notes, Report: report, Provider: *provider, Model: *modelName, NoPull: *noPull})
if err != nil {
return err
}
if problems, err := d.refine(*attempts, nil); err != nil {
return err
} else if len(problems) > 0 {
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, d.dir)
}
fmt.Println("[draft] next: review it, then boot-test it (build / CI) before opening a PR")
return nil
}
type draftJob struct {
GamesDir, SchemaDir string
Name, ID, Image string
SteamApp, Notes string
Report *bootReport
Provider, Model string
NoPull bool
}
// drafter holds one conversation with the model about one game, so later problems (a failed boot) can
// be sent back to it with everything it was told before.
type drafter struct {
job draftJob
dir, id string
m model
facts *imageFacts
gameSchema, testSchema *jsonschema.Schema
answerSchema map[string]any
messages []chatMessage
rounds int
}
func newDrafter(j draftJob) (*drafter, error) {
id := j.ID
if id == "" {
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)
}
var err error
if d.m, err = newModel(j.Provider, j.Model); err != nil {
return nil, err
}
rules, err := os.ReadFile("AUTHORING.md")
if err != nil {
return fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
return nil, fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
}
gameSchema, err := compileSchema(filepath.Join(*schemaDir, "game.schema.json"))
if err != nil {
return err
if d.gameSchema, err = compileSchema(filepath.Join(j.SchemaDir, "game.schema.json")); err != nil {
return nil, err
}
testSchema, err := compileSchema(filepath.Join(*schemaDir, "test.schema.json"))
if err != nil {
return err
if d.testSchema, err = compileSchema(filepath.Join(j.SchemaDir, "test.schema.json")); err != nil {
return nil, err
}
answerSchema, err := draftAnswerSchema(*schemaDir)
if err != nil {
return err
if d.answerSchema, err = draftAnswerSchema(j.SchemaDir); err != nil {
return nil, err
}
if !*noPull {
fmt.Printf("[draft] pulling %s\n", *image)
if out, err := runCmd("docker", "pull", "-q", *image); err != nil {
return fmt.Errorf("docker pull: %v: %s", err, out)
if !j.NoPull {
fmt.Printf("[draft] pulling %s\n", j.Image)
if out, err := runCmd("docker", "pull", "-q", j.Image); err != nil {
return nil, fmt.Errorf("docker pull: %v: %s", err, out)
}
}
facts, err := inspectImage(*image, nil)
if err != nil {
return err
if d.facts, err = inspectImage(j.Image, nil); err != nil {
return nil, err
}
var report *bootReport
if *reportPath != "" {
raw, err := os.ReadFile(*reportPath)
if err != nil {
return err
}
report = &bootReport{}
if err := json.Unmarshal(raw, report); err != nil {
return fmt.Errorf("%s: %w", *reportPath, err)
}
if why := imageUnusable(report); why != "" {
return errors.New(why)
}
if why := imageUnusable(j.Report); why != "" {
return nil, errors.New(why)
}
messages := []chatMessage{
d.messages = []chatMessage{
{Role: "system", Content: draftSystemPrompt(string(rules))},
{Role: "user", Content: draftRequest(*gameName, *id, *image, *steamApp, *notes, facts, report, exampleTemplates(*gamesDir, 2))},
{Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report, exampleTemplates(j.GamesDir, 2))},
}
return d, nil
}
for round := 0; round <= *attempts; round++ {
fmt.Printf("[draft] round %d: asking %s\n", round+1, m.name())
func gameID(name string) string {
return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-")
}
// refine asks for a (corrected) template until it passes validation and the image checks, at most
// attempts+1 answers. feedback, when given, is what went wrong since the last answer (e.g. its boot).
// It returns the problems still left, nil when the draft passes.
func (d *drafter) refine(attempts int, feedback []string) ([]string, error) {
if len(feedback) > 0 {
d.ask(feedback)
}
var problems []string
for round := 0; round <= attempts; round++ {
d.rounds++
fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name())
start := time.Now()
answer, err := m.chat(messages, answerSchema)
answer, err := d.m.chat(d.messages, d.answerSchema)
if err != nil {
return err
return nil, err
}
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
messages = append(messages, chatMessage{Role: "assistant", Content: answer})
d.messages = append(d.messages, chatMessage{Role: "assistant", Content: answer})
problems := writeDraft(dir, *id, answer, report)
if len(problems) == 0 {
problems = validateGame(dir, *id, gameSchema, testSchema)
}
if len(problems) == 0 {
if g, err := loadMap(filepath.Join(dir, "game.yaml")); err == nil {
imageProblems, _ := checkTemplate(g, facts)
problems = append(problems, imageProblems...)
t, _ := loadMap(filepath.Join(dir, "test.yaml"))
problems = append(problems, draftCompleteness(g, t)...)
problems = append(problems, unreadSettings(g, facts)...)
problems = append(problems, savesOutsideVolumes(g, report)...)
}
}
if len(problems) == 0 {
fmt.Printf("[draft] %s passes validation and the image checks\n", dir)
fmt.Println("[draft] next: review it, then boot-test it (first-boot / CI) before opening a PR")
return nil
if problems = d.check(answer); len(problems) == 0 {
fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir)
return nil, nil
}
fmt.Printf("[draft] %d problem(s):\n", len(problems))
for _, p := range problems {
fmt.Println(" - " + p)
}
messages = append(messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " +
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")})
if round < attempts {
d.ask(problems)
}
}
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, dir)
return problems, nil
}
func (d *drafter) ask(problems []string) {
d.messages = append(d.messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " +
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")})
}
func (d *drafter) check(answer string) []string {
problems := writeDraft(d.dir, d.id, answer, d.job.Report)
if len(problems) == 0 {
problems = validateGame(d.dir, d.id, d.gameSchema, d.testSchema)
}
if len(problems) == 0 {
if g, err := loadMap(filepath.Join(d.dir, "game.yaml")); err == nil {
imageProblems, _ := checkTemplate(g, d.facts)
problems = append(problems, imageProblems...)
t, _ := loadMap(filepath.Join(d.dir, "test.yaml"))
problems = append(problems, draftCompleteness(g, t)...)
problems = append(problems, unreadSettings(g, d.facts)...)
problems = append(problems, savesOutsideVolumes(g, d.job.Report)...)
}
}
return problems
}
func readBootReport(p string) (*bootReport, error) {
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
r := &bootReport{}
if err := json.Unmarshal(raw, r); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
return r, nil
}
func draftSystemPrompt(rules string) string {
@@ -269,22 +334,7 @@ var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/"
// node recreates the container. A template passes once its startup command or settings point the game at
// /server or /data; a boot test then shows whether that really worked.
func savesOutsideVolumes(game map[string]any, report *bootReport) []string {
if report == nil {
return nil
}
dirs := map[string]bool{}
for _, c := range report.ContainerChanges {
_, p, _ := strings.Cut(c, " ")
noise := false
for _, n := range outsideNoise {
if strings.Contains(p, n) {
noise = true
}
}
if !noise && p != "" {
dirs[path.Dir(p)] = true
}
}
dirs := outsideDirs(report)
if len(dirs) == 0 {
return nil
}
@@ -302,7 +352,28 @@ func savesOutsideVolumes(game map[string]any, report *bootReport) []string {
return []string{fmt.Sprintf("first boot: the server wrote its own files outside the server volume, in %s. Those are lost "+
"whenever the node recreates the container (every start). Make the game keep them under /server, e.g. with "+
"its save-directory launch option or by pointing that folder at /server, and include it in backupPaths",
strings.Join(sortedKeysBool(dirs), ", "))}
strings.Join(dirs, ", "))}
}
// outsideDirs lists the folders outside the volumes where the game itself wrote files during a boot.
func outsideDirs(report *bootReport) []string {
if report == nil {
return nil
}
dirs := map[string]bool{}
for _, c := range report.ContainerChanges {
_, p, _ := strings.Cut(c, " ")
noise := false
for _, n := range outsideNoise {
if strings.Contains(p, n) {
noise = true
}
}
if !noise && p != "" {
dirs[path.Dir(p)] = true
}
}
return sortedKeysBool(dirs)
}
// imageUnusable recognises a first boot where the image died within seconds because it needs root (or
@@ -400,13 +471,7 @@ func exampleTemplates(gamesDir string, n int) string {
// writeDraft turns the model's JSON answer into games/<id>/game.yaml and test.yaml, using the importer's
// writer so drafts look like every other template.
func writeDraft(dir, id, answer string, report *bootReport) []string {
answer = strings.TrimSpace(answer)
if i := strings.Index(answer, "{"); i > 0 {
answer = answer[i:] // some models still wrap JSON in prose or code fences
}
if i := strings.LastIndex(answer, "}"); i >= 0 {
answer = answer[:i+1]
}
answer = trimToJSON(answer)
var parsed struct {
Game map[string]any `json:"game"`
Test map[string]any `json:"test"`