- 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>
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// worker runs the AI builder. It asks the panel for queued jobs (it only connects out,
|
|
// like node-agents do), runs research or build in a fresh clone of this repo, streams the output to the
|
|
// panel as the job's log, and finishes a build with a pull request opened as the bot account. CI then
|
|
// validates and boot-tests that pull request like any other.
|
|
//
|
|
// Panel API (headers X-Builder-Token and X-Builder-Worker):
|
|
//
|
|
// POST /api/v1/builder/jobs/claim {worker} -> 200 builderJob | 204 nothing queued
|
|
// POST /api/v1/builder/jobs/{id}/progress {stage, lines} -> 200 {cancelled}
|
|
// POST /api/v1/builder/jobs/{id}/result {research | build+prUrl | error}
|
|
|
|
type builderJob struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"` // "research" or "build"
|
|
GameName string `json:"gameName"`
|
|
Notes string `json:"notes"`
|
|
Variant json.RawMessage `json:"variant"`
|
|
}
|
|
|
|
type jobResult struct {
|
|
Research *researchResult `json:"research,omitempty"`
|
|
Build *buildResult `json:"build,omitempty"`
|
|
PRURL string `json:"prUrl,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type workerConfig struct {
|
|
panel, token string
|
|
gitea, repo, botToken string
|
|
workDir, name string
|
|
poll time.Duration
|
|
provider, model string
|
|
}
|
|
|
|
func runWorker(args []string) error {
|
|
fs := flag.NewFlagSet("worker", flag.ExitOnError)
|
|
once := fs.Bool("once", false, "handle at most one job, then exit")
|
|
fs.Parse(args)
|
|
host, _ := os.Hostname()
|
|
c := workerConfig{
|
|
panel: strings.TrimRight(os.Getenv("PANEL_URL"), "/"), token: os.Getenv("BUILDER_TOKEN"),
|
|
gitea: strings.TrimRight(envOr("GITEA_URL", "https://www.gitea.gbreed.com"), "/"), repo: envOr("GAMES_REPO", "gbreed-public/gbr-games"),
|
|
botToken: os.Getenv("GITEA_TOKEN"), workDir: envOr("WORK_DIR", "/work"), name: envOr("WORKER_NAME", host),
|
|
provider: envOr("MODEL_PROVIDER", "ollama"), model: os.Getenv("MODEL"),
|
|
}
|
|
var err error
|
|
if c.poll, err = time.ParseDuration(envOr("POLL_INTERVAL", "20s")); err != nil {
|
|
return err
|
|
}
|
|
for _, v := range []struct{ name, value string }{{"PANEL_URL", c.panel}, {"BUILDER_TOKEN", c.token}, {"GITEA_TOKEN", c.botToken}} {
|
|
if v.value == "" {
|
|
return fmt.Errorf("set %s", v.name)
|
|
}
|
|
}
|
|
fmt.Printf("[worker] %s polling %s every %s\n", c.name, c.panel, c.poll)
|
|
for {
|
|
job, err := c.claim()
|
|
if err != nil {
|
|
fmt.Printf("[worker] claim: %v\n", err)
|
|
}
|
|
if job != nil {
|
|
fmt.Printf("[worker] job %s: %s %q\n", job.ID, job.Kind, job.GameName)
|
|
res := c.handle(job)
|
|
if err := c.post("/jobs/"+job.ID+"/result", res, nil); err != nil {
|
|
fmt.Printf("[worker] posting the result of %s: %v\n", job.ID, err)
|
|
}
|
|
if *once {
|
|
return nil
|
|
}
|
|
continue
|
|
}
|
|
if *once {
|
|
return nil
|
|
}
|
|
time.Sleep(c.poll)
|
|
}
|
|
}
|
|
|
|
func envOr(name, def string) string {
|
|
if v := os.Getenv(name); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (c *workerConfig) claim() (*builderJob, error) {
|
|
var job builderJob
|
|
status, err := c.request("/jobs/claim", map[string]string{"worker": c.name}, &job)
|
|
if err != nil || status == http.StatusNoContent {
|
|
return nil, err
|
|
}
|
|
return &job, nil
|
|
}
|
|
|
|
func (c *workerConfig) post(p string, body, out any) error {
|
|
_, err := c.request(p, body, out)
|
|
return err
|
|
}
|
|
|
|
func (c *workerConfig) request(p string, body, out any) (int, error) {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, c.panel+"/api/v1/builder"+p, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Builder-Token", c.token)
|
|
req.Header.Set("X-Builder-Worker", c.name)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return resp.StatusCode, fmt.Errorf("HTTP %d: %.200s", resp.StatusCode, raw)
|
|
}
|
|
if out != nil && len(bytes.TrimSpace(raw)) > 0 {
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return resp.StatusCode, err
|
|
}
|
|
}
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// handle runs one job in its own clone and returns what to report; it never panics the worker.
|
|
func (c *workerConfig) handle(job *builderJob) *jobResult {
|
|
dir := filepath.Join(c.workDir, "job-"+job.ID)
|
|
os.RemoveAll(dir)
|
|
defer os.RemoveAll(dir)
|
|
if out, err := runCmd("git", "clone", "-q", "--depth", "1", c.gitea+"/"+c.repo+".git", dir); err != nil {
|
|
return &jobResult{Error: "git clone: " + out}
|
|
}
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
resultFile := filepath.Join(dir, "job-result.json")
|
|
modelArgs := []string{"-provider", c.provider}
|
|
if c.model != "" {
|
|
modelArgs = append(modelArgs, "-model", c.model)
|
|
}
|
|
|
|
switch job.Kind {
|
|
case "research":
|
|
args := append([]string{"research", "-name", job.GameName, "-notes", job.Notes, "-out", resultFile}, modelArgs...)
|
|
if err := c.runStreamed(job.ID, dir, self, args, "RESEARCH"); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
res := &researchResult{}
|
|
if err := readJSON(resultFile, res); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
return &jobResult{Research: res}
|
|
|
|
case "build":
|
|
variantFile := filepath.Join(dir, "job-variant.json")
|
|
if err := os.WriteFile(variantFile, job.Variant, 0o644); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
args := append([]string{"build", "-name", job.GameName, "-notes", job.Notes, "-variant", "@" + variantFile, "-out", resultFile}, modelArgs...)
|
|
runErr := c.runStreamed(job.ID, dir, self, args, "FIRST_BOOT")
|
|
res := &buildResult{}
|
|
if err := readJSON(resultFile, res); err != nil {
|
|
msg := "the build produced no result"
|
|
if runErr != nil {
|
|
msg = runErr.Error()
|
|
}
|
|
return &jobResult{Error: msg}
|
|
}
|
|
// A draft that validates but didn't boot still goes up as a draft PR: a person can finish it, and
|
|
// CI shows the same boot failure
|
|
if _, err := os.Stat(filepath.Join(dir, res.Dir, "game.yaml")); err != nil {
|
|
return &jobResult{Build: res, Error: "no template was written: " + strings.Join(res.Problems, "; ")}
|
|
}
|
|
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}
|
|
}
|
|
|
|
// Output prefixes that mark a new stage of a build, shown in the panel's progress bar
|
|
var stagePrefixes = []struct{ prefix, stage string }{
|
|
{"[build] first boot", "FIRST_BOOT"},
|
|
{"[first-boot]", ""},
|
|
{"[draft]", "DRAFTING"},
|
|
{"[build] boot ", "BOOT_TEST"},
|
|
}
|
|
|
|
// runStreamed runs a gbrgames command and sends its output to the panel every few seconds (which also
|
|
// tells the panel the worker is alive). The panel can answer "cancelled" to stop the job.
|
|
func (c *workerConfig) runStreamed(jobID, dir, self string, args []string, stage string) error {
|
|
cmd := exec.Command(self, args...)
|
|
cmd.Dir = dir
|
|
pr, pw := io.Pipe()
|
|
cmd.Stdout, cmd.Stderr = pw, pw
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
var mu sync.Mutex
|
|
var pending []string
|
|
var lastErr string
|
|
done := make(chan struct{})
|
|
go func() {
|
|
sc := bufio.NewScanner(pr)
|
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
line := strings.TrimRight(ansi.ReplaceAllString(sc.Text(), ""), "\r ")
|
|
fmt.Println(line)
|
|
mu.Lock()
|
|
pending = append(pending, line)
|
|
for _, s := range stagePrefixes {
|
|
if s.stage != "" && strings.HasPrefix(line, s.prefix) {
|
|
stage = s.stage
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "error: ") {
|
|
lastErr = strings.TrimPrefix(line, "error: ")
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
close(done)
|
|
}()
|
|
wait := make(chan error, 1)
|
|
go func() {
|
|
err := cmd.Wait()
|
|
pw.Close()
|
|
<-done
|
|
wait <- err
|
|
}()
|
|
|
|
tick := time.NewTicker(5 * time.Second)
|
|
defer tick.Stop()
|
|
flush := func() bool {
|
|
mu.Lock()
|
|
lines, st := pending, stage
|
|
pending = nil
|
|
mu.Unlock()
|
|
return c.progress(jobID, st, lines)
|
|
}
|
|
for {
|
|
select {
|
|
case err := <-wait:
|
|
flush()
|
|
if err != nil {
|
|
if lastErr != "" {
|
|
return errors.New(lastErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
case <-tick.C:
|
|
if cancelled := flush(); cancelled {
|
|
cmd.Process.Kill()
|
|
<-wait
|
|
return errors.New("cancelled from the panel")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// progress sends log lines (possibly none, as a heartbeat) and reports whether the job was cancelled.
|
|
func (c *workerConfig) progress(jobID, stage string, lines []string) bool {
|
|
var resp struct {
|
|
Cancelled bool `json:"cancelled"`
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
if err := c.post("/jobs/"+jobID+"/progress", map[string]any{"stage": stage, "lines": lines}, &resp); err != nil {
|
|
fmt.Printf("[worker] progress: %v\n", err)
|
|
}
|
|
return resp.Cancelled
|
|
}
|
|
|
|
// openPR commits the new game folder to its own branch as the bot and opens a pull request to main.
|
|
func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (string, error) {
|
|
short := job.ID
|
|
if len(short) > 8 {
|
|
short = short[:8]
|
|
}
|
|
branch := "draft/" + res.GameID + "-" + short
|
|
auth := "http.extraHeader=Authorization: token " + c.botToken
|
|
steps := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"checkout", []string{"checkout", "-q", "-b", branch}},
|
|
{"add", []string{"add", filepath.ToSlash(res.Dir)}},
|
|
{"commit", []string{"-c", "user.name=gbr-bot", "-c", "user.email=gbr-bot@users.noreply.gitea.gbreed.com", "commit", "-q", "-m",
|
|
fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}},
|
|
{"push", []string{"-c", auth, "push", "-q", "origin", branch}},
|
|
}
|
|
for _, s := range steps {
|
|
cmd := exec.Command("git", s.args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
// The push command holds the token: name the step only, and mask it in git's output
|
|
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)"
|
|
}
|
|
body := map[string]any{"title": title, "head": branch, "base": "main", "body": res.Summary}
|
|
payload, _ := json.Marshal(body)
|
|
req, err := http.NewRequest(http.MethodPost, c.gitea+"/api/v1/repos/"+c.repo+"/pulls", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "token "+c.botToken)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var pr struct {
|
|
HTMLURL string `json:"html_url"`
|
|
Message string `json:"message"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&pr)
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, pr.Message)
|
|
}
|
|
return pr.HTMLURL, nil
|
|
}
|
|
|
|
func readJSON(p string, out any) error {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|